Merge branch 'develop' of https://e.coding.net/g-xtcy5189/cunkebao/cunkebao_v3 into develop
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
Server/runtime/
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.bmp
|
||||
*.webp
|
||||
*.ico
|
||||
*.svg
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,13 +0,0 @@
|
||||
.idea
|
||||
.next
|
||||
.vscode
|
||||
vendor
|
||||
Backend/dist
|
||||
Backend/node_modules
|
||||
Store_vue/node_modules
|
||||
Store_vue/unpackage
|
||||
Server/.specstory/
|
||||
Store_vue/.specstory/
|
||||
*.zip
|
||||
*.cursorindexingignore
|
||||
*.md
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"presets": ["next/babel"]
|
||||
}
|
||||
|
||||
28
Cunkebao/.gitignore
vendored
28
Cunkebao/.gitignore
vendored
@@ -2,28 +2,22 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
/.history/
|
||||
# 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
|
||||
266
Cunkebao/README.md
Normal file
266
Cunkebao/README.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 内客宝 - 智能获客管理平台
|
||||
|
||||
## 📋 项目简介
|
||||
|
||||
内客宝是一个专业的微信获客和流量管理平台,基于 React 技术栈构建。平台提供智能化的客户获取、管理和运营解决方案,集成了多种自动化工具,帮助企业高效管理微信营销活动。
|
||||
|
||||
## 🚀 技术栈详解
|
||||
|
||||
### 核心框架
|
||||
- **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 组件库
|
||||
- **Radix UI** - 无样式的可访问组件库
|
||||
- 完整的组件生态系统(30+ 组件)
|
||||
- 优秀的无障碍访问支持
|
||||
- 高度可定制
|
||||
- **Tailwind CSS 3.4.17** - 实用优先的 CSS 框架
|
||||
- 响应式设计支持
|
||||
- 自定义主题配置
|
||||
- 原子化 CSS 类
|
||||
|
||||
### 图标和样式
|
||||
- **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 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** - 代码质量检查
|
||||
- **Jest** - 单元测试框架
|
||||
- **Testing Library** - React 测试工具
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
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 对话助手** - 智能客服和对话管理
|
||||
- **流量分发** - 流量分配和策略管理
|
||||
|
||||
### 设备管理 (Devices)
|
||||
- 设备状态监控和配置
|
||||
- 设备性能分析
|
||||
- 设备权限管理
|
||||
|
||||
### 场景管理 (Scenarios)
|
||||
- 营销场景配置
|
||||
- 自动化流程设计
|
||||
- 场景效果分析
|
||||
|
||||
### 内容管理 (Content)
|
||||
- 内容创建与编辑
|
||||
- 内容模板管理
|
||||
- 内容发布调度
|
||||
|
||||
### 其他模块
|
||||
- 用户管理 (Users)
|
||||
- 订单管理 (Orders)
|
||||
- 流量池管理 (Traffic Pool)
|
||||
- 联系人导入 (Contact Import)
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 环境要求
|
||||
- **Node.js** 16+
|
||||
- **npm** 或 **yarn**
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm install
|
||||
|
||||
# 使用 yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 开发环境启动
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm start
|
||||
|
||||
# 使用 yarn
|
||||
yarn start
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm run build
|
||||
|
||||
# 使用 yarn
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm test
|
||||
|
||||
# 使用 yarn
|
||||
yarn test
|
||||
```
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 路径别名配置
|
||||
项目使用 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'),
|
||||
```
|
||||
|
||||
### Tailwind CSS 配置
|
||||
- 自定义字体大小和间距
|
||||
- 响应式断点配置
|
||||
- 主题颜色系统
|
||||
|
||||
### TypeScript 配置
|
||||
- 严格模式启用
|
||||
- 路径映射配置
|
||||
- JSX 支持
|
||||
|
||||
## 📱 响应式设计
|
||||
|
||||
项目采用移动优先的响应式设计:
|
||||
- 支持桌面端、平板端、移动端
|
||||
- 自适应布局组件
|
||||
- 触摸友好的交互设计
|
||||
|
||||
## 🎨 UI 设计系统
|
||||
|
||||
### 设计原则
|
||||
- 简洁现代的设计风格
|
||||
- 一致的用户体验
|
||||
- 无障碍访问支持
|
||||
|
||||
### 组件库特点
|
||||
- 基于 Radix UI 的高质量组件
|
||||
- 完整的表单组件系统
|
||||
- 数据展示组件
|
||||
- 导航和布局组件
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
- 身份验证和授权
|
||||
- API 请求拦截
|
||||
- 数据验证和清理
|
||||
- 加密功能支持
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
- 代码分割和懒加载
|
||||
- 组件优化
|
||||
- 缓存策略
|
||||
- 包大小优化
|
||||
|
||||
## 🧪 测试策略
|
||||
|
||||
- 单元测试 (Jest + Testing Library)
|
||||
- 组件测试
|
||||
- 集成测试支持
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证。
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
如有问题或建议,请联系开发团队。
|
||||
|
||||
---
|
||||
|
||||
**项目名称**: 内客宝 (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,20 +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,69 +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,82 +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,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,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,273 +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,48 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import "./globals.css"
|
||||
import BottomNav from "./components/BottomNav"
|
||||
import "regenerator-runtime/runtime"
|
||||
import type React from "react"
|
||||
import ErrorBoundary from "./components/ErrorBoundary"
|
||||
import { VideoTutorialButton } from "@/components/VideoTutorialButton"
|
||||
import { AuthProvider } from "@/app/components/AuthProvider"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
// 创建一个包装组件来使用 usePathname hook
|
||||
function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// 只在主页路径显示底部导航栏
|
||||
const showBottomNav =
|
||||
pathname === "/" || pathname === "/devices" || pathname === "/content" || pathname === "/profile"
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full">
|
||||
<main className="w-full mx-auto bg-white min-h-screen flex flex-col relative lg:max-w-7xl xl:max-w-[1200px]">
|
||||
{children}
|
||||
{showBottomNav && <BottomNav />}
|
||||
{showBottomNav && <VideoTutorialButton />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="bg-gray-100">
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +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,194 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { validateToken } from "@/lib/api"
|
||||
|
||||
// 安全的localStorage访问方法
|
||||
const safeLocalStorage = {
|
||||
getItem: (key: string): string | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem(key)
|
||||
}
|
||||
return null
|
||||
},
|
||||
setItem: (key: string, value: string): void => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
account?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean
|
||||
token: string | null
|
||||
user: User | null
|
||||
login: (token: string, userData: User) => void
|
||||
logout: () => void
|
||||
updateToken: (newToken: string) => void
|
||||
}
|
||||
|
||||
// 创建默认上下文
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
user: null,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
updateToken: () => {}
|
||||
})
|
||||
|
||||
export const useAuth = () => useContext(AuthContext)
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
// 避免在服务端渲染时设置初始状态
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
// 初始页面加载时显示为false,避免在服务端渲染和客户端水合时不匹配
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
// 初始化认证状态
|
||||
useEffect(() => {
|
||||
// 仅在客户端执行初始化
|
||||
setIsLoading(true)
|
||||
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const storedToken = safeLocalStorage.getItem("token")
|
||||
|
||||
if (storedToken) {
|
||||
// 首先尝试从localStorage获取用户信息
|
||||
const userDataStr = safeLocalStorage.getItem("userInfo")
|
||||
if (userDataStr) {
|
||||
try {
|
||||
// 如果能解析用户数据,先设置登录状态
|
||||
const userData = JSON.parse(userDataStr) as User
|
||||
setToken(storedToken)
|
||||
setUser(userData)
|
||||
setIsAuthenticated(true)
|
||||
|
||||
// 然后在后台尝试验证token,但不影响当前登录状态
|
||||
validateToken().then(isValid => {
|
||||
// 只有在确认token绝对无效时才登出
|
||||
// 网络错误等情况默认保持登录状态
|
||||
if (isValid === false) {
|
||||
console.warn('验证token失败,但仍允许用户保持登录状态')
|
||||
}
|
||||
}).catch(error => {
|
||||
// 捕获所有验证过程中的错误,并记录日志
|
||||
console.error('验证token过程中出错:', error)
|
||||
// 网络错误等不会导致登出
|
||||
})
|
||||
} catch (parseError) {
|
||||
// 用户数据无法解析,需要清除
|
||||
console.error('解析用户数据失败:', parseError)
|
||||
handleLogout()
|
||||
}
|
||||
} else {
|
||||
// 有token但没有用户信息,可能是部分数据丢失
|
||||
console.warn('找到token但没有用户信息,尝试保持登录状态')
|
||||
|
||||
// 尝试验证token并获取用户信息
|
||||
try {
|
||||
const isValid = await validateToken()
|
||||
if (isValid) {
|
||||
// 如果token有效,尝试从API获取用户信息
|
||||
// 这里简化处理,直接使用token
|
||||
setToken(storedToken)
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
// token确认无效,清除
|
||||
handleLogout()
|
||||
}
|
||||
} catch (error) {
|
||||
// 验证过程出错,记录日志但不登出
|
||||
console.error('验证token过程中出错:', error)
|
||||
// 保留token,允许用户继续使用
|
||||
setToken(storedToken)
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("初始化认证状态时出错:", error)
|
||||
// 非401错误不应强制登出
|
||||
if (error instanceof Error &&
|
||||
(error.message.includes('401') ||
|
||||
error.message.includes('未授权') ||
|
||||
error.message.includes('token'))) {
|
||||
handleLogout()
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, []) // 空依赖数组,仅在组件挂载时执行一次
|
||||
|
||||
const handleLogout = () => {
|
||||
// 先清除所有认证相关的状态
|
||||
safeLocalStorage.removeItem("token")
|
||||
safeLocalStorage.removeItem("token_expired")
|
||||
safeLocalStorage.removeItem("s2_accountId")
|
||||
safeLocalStorage.removeItem("userInfo")
|
||||
safeLocalStorage.removeItem("user")
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setIsAuthenticated(false)
|
||||
|
||||
// 使用 window.location 而不是 router.push,避免状态更新和路由跳转的竞态条件
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
const login = (newToken: string, userData: User) => {
|
||||
safeLocalStorage.setItem("token", newToken)
|
||||
safeLocalStorage.setItem("userInfo", JSON.stringify(userData))
|
||||
setToken(newToken)
|
||||
setUser(userData)
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
handleLogout()
|
||||
}
|
||||
|
||||
// 用于刷新 token 的方法
|
||||
const updateToken = (newToken: string) => {
|
||||
safeLocalStorage.setItem("token", newToken)
|
||||
setToken(newToken)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, token, user, login, logout, updateToken }}>
|
||||
{isLoading && isInitialized ? (
|
||||
<div className="flex h-screen w-screen items-center justify-center">加载中...</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +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,36 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Home, Users, User, Briefcase } from "lucide-react"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", icon: Home, label: "首页" },
|
||||
{ href: "/scenarios", icon: Users, label: "场景获客" },
|
||||
{ href: "/workspace", icon: Briefcase, label: "工作台" },
|
||||
{ href: "/profile", icon: User, label: "我的" },
|
||||
]
|
||||
|
||||
export default function BottomNav() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200">
|
||||
<div className="w-full mx-auto flex justify-around">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center py-2 px-3 ${
|
||||
pathname === item.href ? "text-blue-500" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-6 h-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,85 +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,40 +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,106 +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,123 +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,101 +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"
|
||||
import { ImeiDisplay } from "@/components/ImeiDisplay"
|
||||
|
||||
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 flex items-center">
|
||||
<span className="mr-1">IMEI:</span>
|
||||
<ImeiDisplay imei={device.imei} containerWidth={160} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,47 +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,150 +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,58 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
import BottomNav from "./BottomNav"
|
||||
import { VideoTutorialButton } from "@/components/VideoTutorialButton"
|
||||
import type React from "react"
|
||||
import { createContext, useContext, useState, useEffect } from "react"
|
||||
|
||||
// 创建视图模式上下文
|
||||
const ViewModeContext = createContext<{ viewMode: "desktop" | "mobile" }>({ viewMode: "desktop" })
|
||||
|
||||
// 创建视图模式钩子函数
|
||||
export function useViewMode() {
|
||||
const context = useContext(ViewModeContext)
|
||||
if (!context) {
|
||||
throw new Error("useViewMode must be used within a LayoutWrapper")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export default function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const [viewMode, setViewMode] = useState<"desktop" | "mobile">("desktop")
|
||||
|
||||
// 检测视图模式
|
||||
useEffect(() => {
|
||||
const checkViewMode = () => {
|
||||
setViewMode(window.innerWidth < 768 ? "mobile" : "desktop")
|
||||
}
|
||||
|
||||
// 初始检测
|
||||
checkViewMode()
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener("resize", checkViewMode)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", checkViewMode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 只在四个主页显示底部导航栏:首页、场景获客、工作台和我的
|
||||
const mainPages = ["/", "/scenarios", "/workspace", "/profile"]
|
||||
const showBottomNav = mainPages.includes(pathname)
|
||||
|
||||
return (
|
||||
<ViewModeContext.Provider value={{ viewMode }}>
|
||||
<div className="mx-auto w-full">
|
||||
<main className="w-full mx-auto bg-white min-h-screen flex flex-col relative lg:max-w-7xl xl:max-w-[1200px]">
|
||||
{children}
|
||||
{showBottomNav && <BottomNav />}
|
||||
{showBottomNav && <VideoTutorialButton />}
|
||||
</main>
|
||||
</div>
|
||||
</ViewModeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +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,177 +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 { 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,99 +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,55 +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,75 +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,205 +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
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// 计算通过率
|
||||
function calculatePassRate(acquired: number, added: number) {
|
||||
if (acquired === 0) return 0
|
||||
return Math.round((added / acquired) * 100)
|
||||
}
|
||||
|
||||
@@ -1,97 +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,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,341 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } 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, Search, Power, RefreshCcw, Settings, AlertTriangle } from "lucide-react"
|
||||
import { ImeiDisplay } from "@/components/ImeiDisplay"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
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 [searchTerm, setSearchTerm] = useState("")
|
||||
const [filteredDevices, setFilteredDevices] = useState(devices)
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = devices.filter(
|
||||
(device) =>
|
||||
device.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
device.imei.includes(searchTerm) ||
|
||||
device.wechatId.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
setFilteredDevices(filtered)
|
||||
}, [searchTerm, devices])
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDevices.length === filteredDevices.length) {
|
||||
onSelect?.([])
|
||||
} else {
|
||||
onSelect?.(filteredDevices.map((d) => d.id))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
placeholder="搜索设备..."
|
||||
className="pl-8"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{selectable && (
|
||||
<div className="flex items-center justify-between w-full sm:w-auto">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedDevices.length === filteredDevices.length && filteredDevices.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm">全选</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 ml-4">已选择 {selectedDevices.length} 个设备</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredDevices.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="relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium flex items-center">
|
||||
<span>{device.name}</span>
|
||||
{device.addFriendStatus === "abnormal" && (
|
||||
<Badge variant="destructive" className="ml-2 text-xs">
|
||||
加友异常
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-0 right-0">
|
||||
<Badge
|
||||
variant={device.status === "online" ? "default" : "secondary"}
|
||||
className={`${
|
||||
device.status === "online"
|
||||
? "bg-green-100 text-green-800 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
device.status === "online" ? "bg-green-500" : "bg-gray-500"
|
||||
}`} />
|
||||
<span>{device.status === "online" ? "在线" : "离线"}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
|
||||
<Battery className={`w-4 h-4 ${
|
||||
device.battery < 20
|
||||
? "text-red-500"
|
||||
: device.battery < 50
|
||||
? "text-yellow-500"
|
||||
: "text-green-500"
|
||||
}`} />
|
||||
<span className={`${
|
||||
device.battery < 20
|
||||
? "text-red-700"
|
||||
: device.battery < 50
|
||||
? "text-yellow-700"
|
||||
: "text-green-700"
|
||||
}`}>{device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
|
||||
<Users className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-blue-700">{device.friendCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
|
||||
<MessageCircle className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-purple-700">{device.messageCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
|
||||
<Clock className="w-4 h-4 text-indigo-500" />
|
||||
<span className="text-indigo-700">+{device.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm space-y-1.5 mt-3">
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-16">IMEI:</span>
|
||||
<ImeiDisplay imei={device.imei} containerWidth={120} />
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="w-16">微信号:</span>
|
||||
<span className="font-mono">{device.wechatId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!selectedDevice} onOpenChange={() => setSelectedDevice(null)}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>设备详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedDevice && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-3 rounded-lg ${
|
||||
selectedDevice.status === "online"
|
||||
? "bg-green-100"
|
||||
: "bg-gray-100"
|
||||
}`}>
|
||||
<Smartphone className={`w-6 h-6 ${
|
||||
selectedDevice.status === "online"
|
||||
? "text-green-700"
|
||||
: "text-gray-700"
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium flex items-center space-x-2">
|
||||
<span>{selectedDevice.name}</span>
|
||||
<Badge
|
||||
variant={selectedDevice.status === "online" ? "default" : "secondary"}
|
||||
className={`${
|
||||
selectedDevice.status === "online"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
selectedDevice.status === "online" ? "bg-green-500" : "bg-gray-500"
|
||||
}`} />
|
||||
<span>{selectedDevice.status === "online" ? "在线" : "离线"}</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</h3>
|
||||
<div className="text-sm text-gray-500 mt-1 space-x-4">
|
||||
<span>IMEI: <ImeiDisplay imei={selectedDevice.imei} containerWidth={160} /></span>
|
||||
<span>微信号: {selectedDevice.wechatId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" className="space-x-1">
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
<span>刷新</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="space-x-1">
|
||||
<Power className="w-4 h-4" />
|
||||
<span>重启</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="status" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="status">状态信息</TabsTrigger>
|
||||
<TabsTrigger value="stats">统计数据</TabsTrigger>
|
||||
<TabsTrigger value="tasks">任务管理</TabsTrigger>
|
||||
<TabsTrigger value="logs">运行日志</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="status" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
|
||||
<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"
|
||||
: selectedDevice.battery < 50
|
||||
? "text-yellow-500"
|
||||
: "text-green-500"
|
||||
}`} />
|
||||
<span className={`font-medium ${
|
||||
selectedDevice.battery < 20
|
||||
? "text-red-700"
|
||||
: selectedDevice.battery < 50
|
||||
? "text-yellow-700"
|
||||
: "text-green-700"
|
||||
}`}>{selectedDevice.battery}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
|
||||
<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 text-blue-700">{selectedDevice.friendCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
|
||||
<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 text-green-700">+{selectedDevice.todayAdded}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
|
||||
<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 text-purple-700">{selectedDevice.messageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedDevice.addFriendStatus === "abnormal" && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
|
||||
<div className="flex items-center space-x-2 text-red-800">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span className="font-medium">加友异常警告</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
该设备当前存在加友异常情况,请检查设备状态和相关配置。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="stats">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
统计数据开发中...
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="tasks">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
任务管理开发中...
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
运行日志开发中...
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Search, RefreshCw, Filter } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { api } from "@/lib/api"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
imei: string
|
||||
status: "online" | "offline"
|
||||
wechatAccounts: {
|
||||
wechatId: string
|
||||
nickname: string
|
||||
remainingAdds: number
|
||||
maxDailyAdds: number
|
||||
}[]
|
||||
}
|
||||
|
||||
interface DeviceSelectionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedDevices: string[]
|
||||
onSelect: (deviceIds: string[]) => void
|
||||
}
|
||||
|
||||
export function DeviceSelectionDialog({ open, onOpenChange, selectedDevices, onSelect }: DeviceSelectionDialogProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSelectedDeviceIds(selectedDevices)
|
||||
}, [open, selectedDevices])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const fetchDevices = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = []
|
||||
if (searchQuery) params.push(`keyword=${encodeURIComponent(searchQuery)}`)
|
||||
if (statusFilter !== "all") params.push(`status=${statusFilter}`)
|
||||
params.push("page=1", "limit=100")
|
||||
const url = `/v1/devices?${params.join("&")}`
|
||||
const response = await api.get<any>(url)
|
||||
const list = response.data?.list || response.data?.items || []
|
||||
const devices = list.map((device: any) => ({
|
||||
id: device.id?.toString() || device.id,
|
||||
imei: device.imei || "",
|
||||
name: device.memo || device.name || `设备_${device.id}`,
|
||||
status: device.alive === 1 || device.status === "online" ? "online" : "offline",
|
||||
wechatAccounts: [
|
||||
{
|
||||
wechatId: device.wechatId || device.wxid || "",
|
||||
nickname: device.nickname || "",
|
||||
remainingAdds: device.remainingAdds || 0,
|
||||
maxDailyAdds: device.maxDailyAdds || 0,
|
||||
},
|
||||
],
|
||||
}))
|
||||
setDevices(devices)
|
||||
} catch {
|
||||
setDevices([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchDevices()
|
||||
}, [open, searchQuery, statusFilter])
|
||||
|
||||
const handleSelectDevice = (deviceId: string) => {
|
||||
setSelectedDeviceIds((prev) =>
|
||||
prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedDeviceIds.length === filteredDevices.length) {
|
||||
setSelectedDeviceIds([])
|
||||
} else {
|
||||
setSelectedDeviceIds(filteredDevices.map((device) => device.id))
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(selectedDeviceIds)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedDeviceIds(selectedDevices)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const searchLower = searchQuery.toLowerCase()
|
||||
const matchesSearch =
|
||||
(device.name || '').toLowerCase().includes(searchLower) ||
|
||||
(device.imei || '').toLowerCase().includes(searchLower) ||
|
||||
(device.wechatAccounts[0]?.wechatId || '').toLowerCase().includes(searchLower)
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline")
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl w-full p-0 rounded-2xl shadow-2xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold text-center py-3 border-b">选择设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-6 pt-4">
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Input
|
||||
placeholder="搜索设备IMEI/备注/微信号"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="flex-1 rounded-lg border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<select
|
||||
className="border rounded-lg px-3 py-2 text-sm bg-gray-50 focus:border-blue-500"
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* 设备列表 */}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-2">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 py-8">加载中...</div>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">暂无设备</div>
|
||||
) : (
|
||||
filteredDevices.map(device => {
|
||||
const checked = selectedDeviceIds.includes(device.id)
|
||||
const wx = device.wechatAccounts[0] || {}
|
||||
return (
|
||||
<label
|
||||
key={device.id}
|
||||
className={`
|
||||
flex items-center gap-3 p-4 rounded-xl border
|
||||
${checked ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"}
|
||||
hover:border-blue-400 transition-colors cursor-pointer
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-blue-500 scale-110"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedDeviceIds(prev =>
|
||||
prev.includes(device.id)
|
||||
? prev.filter(id => id !== device.id)
|
||||
: [...prev, device.id]
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-base">{device.name}</div>
|
||||
<div className="text-xs text-gray-500">IMEI: {device.imei}</div>
|
||||
<div className="text-xs text-gray-400">微信号: {wx.wechatId || '--'}({wx.nickname || '--'})</div>
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-xs font-medium">
|
||||
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-green-500' : 'bg-gray-300'}`}></span>
|
||||
<span className={device.status === 'online' ? 'text-green-600' : 'text-gray-400'}>
|
||||
{device.status === 'online' ? '在线' : '离线'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{/* 确认按钮 */}
|
||||
<div className="flex justify-center mt-8">
|
||||
<Button
|
||||
className="w-4/5 py-3 rounded-full text-base font-bold shadow-md"
|
||||
onClick={() => {
|
||||
onSelect(selectedDeviceIds)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Check } 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 transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button>新建海报</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,324 +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 { Search, Filter } from "lucide-react"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
interface UserTag {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
interface TrafficUser {
|
||||
id: string
|
||||
avatar: string
|
||||
nickname: string
|
||||
wechatId: string
|
||||
phone: string
|
||||
region: string
|
||||
note: string
|
||||
status: "pending" | "added" | "failed"
|
||||
addTime: string
|
||||
source: string
|
||||
assignedTo: string
|
||||
category: "potential" | "customer" | "lost"
|
||||
tags: UserTag[]
|
||||
}
|
||||
|
||||
interface TrafficPoolSelectorProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
selectedUsers: TrafficUser[]
|
||||
onSelect: (users: TrafficUser[]) => void
|
||||
}
|
||||
|
||||
export function TrafficPoolSelector({ open, onOpenChange, selectedUsers, onSelect }: TrafficPoolSelectorProps) {
|
||||
const [users, setUsers] = useState<TrafficUser[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [activeCategory, setActiveCategory] = useState("all")
|
||||
const [sourceFilter, setSourceFilter] = useState("all")
|
||||
const [tagFilter, setTagFilter] = useState("all")
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([])
|
||||
|
||||
// 初始化已选用户
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedUserIds(selectedUsers.map((user) => user.id))
|
||||
}
|
||||
}, [open, selectedUsers])
|
||||
|
||||
// 模拟获取用户数据
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 模拟API请求
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
// 生成模拟数据
|
||||
const mockUsers: TrafficUser[] = Array.from({ length: 30 }, (_, i) => {
|
||||
// 随机标签
|
||||
const tagPool = [
|
||||
{ id: "tag1", name: "潜在客户", color: "bg-blue-100 text-blue-800" },
|
||||
{ id: "tag2", name: "高意向", color: "bg-green-100 text-green-800" },
|
||||
{ id: "tag3", name: "已成交", color: "bg-purple-100 text-purple-800" },
|
||||
{ id: "tag4", name: "需跟进", color: "bg-yellow-100 text-yellow-800" },
|
||||
{ id: "tag5", name: "活跃用户", color: "bg-indigo-100 text-indigo-800" },
|
||||
{ id: "tag6", name: "沉默用户", color: "bg-gray-100 text-gray-800" },
|
||||
{ id: "tag7", name: "企业客户", color: "bg-red-100 text-red-800" },
|
||||
{ id: "tag8", name: "个人用户", color: "bg-pink-100 text-pink-800" },
|
||||
]
|
||||
|
||||
const randomTags = Array.from(
|
||||
{ length: Math.floor(Math.random() * 3) + 1 },
|
||||
() => tagPool[Math.floor(Math.random() * tagPool.length)],
|
||||
)
|
||||
|
||||
// 确保标签唯一
|
||||
const uniqueTags = randomTags.filter((tag, index, self) => index === self.findIndex((t) => t.id === tag.id))
|
||||
|
||||
const sources = ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号"]
|
||||
const statuses = ["pending", "added", "failed"] as const
|
||||
|
||||
return {
|
||||
id: `user-${i + 1}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40`,
|
||||
nickname: `用户${i + 1}`,
|
||||
wechatId: `wxid_${Math.random().toString(36).substring(2, 10)}`,
|
||||
phone: `1${Math.floor(Math.random() * 9 + 1)}${Array(9)
|
||||
.fill(0)
|
||||
.map(() => Math.floor(Math.random() * 10))
|
||||
.join("")}`,
|
||||
region: ["北京", "上海", "广州", "深圳", "杭州"][Math.floor(Math.random() * 5)],
|
||||
note: Math.random() > 0.7 ? `这是用户${i + 1}的备注信息` : "",
|
||||
status: statuses[Math.floor(Math.random() * 3)],
|
||||
addTime: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toISOString(),
|
||||
source: sources[Math.floor(Math.random() * sources.length)],
|
||||
assignedTo: Math.random() > 0.5 ? `销售${Math.floor(Math.random() * 5) + 1}` : "",
|
||||
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as
|
||||
| "potential"
|
||||
| "customer"
|
||||
| "lost",
|
||||
tags: uniqueTags,
|
||||
}
|
||||
})
|
||||
|
||||
setUsers(mockUsers)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUsers()
|
||||
}, [open])
|
||||
|
||||
// 过滤用户
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const matchesSearch =
|
||||
searchQuery === "" ||
|
||||
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.phone.includes(searchQuery)
|
||||
|
||||
const matchesCategory = activeCategory === "all" || user.category === activeCategory
|
||||
|
||||
const matchesSource = sourceFilter === "all" || user.source === sourceFilter
|
||||
|
||||
const matchesTag = tagFilter === "all" || user.tags.some((tag) => tag.id === tagFilter)
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSource && matchesTag
|
||||
})
|
||||
|
||||
// 处理选择用户
|
||||
const handleSelectUser = (userId: string) => {
|
||||
setSelectedUserIds((prev) => (prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]))
|
||||
}
|
||||
|
||||
// 处理全选
|
||||
const handleSelectAll = () => {
|
||||
if (selectedUserIds.length === filteredUsers.length) {
|
||||
setSelectedUserIds([])
|
||||
} else {
|
||||
setSelectedUserIds(filteredUsers.map((user) => user.id))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理确认选择
|
||||
const handleConfirm = () => {
|
||||
const selectedUsersList = users.filter((user) => selectedUserIds.includes(user.id))
|
||||
onSelect(selectedUsersList)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// 获取所有标签选项
|
||||
const allTags = Array.from(new Set(users.flatMap((user) => user.tags).map((tag) => JSON.stringify(tag)))).map(
|
||||
(tag) => JSON.parse(tag) as UserTag,
|
||||
)
|
||||
|
||||
// 获取所有来源选项
|
||||
const allSources = Array.from(new Set(users.map((user) => user.source)))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择流量池用户</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* 搜索和筛选区域 */}
|
||||
<div className="space-y-4 mb-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="搜索用户名/微信号/手机号"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分类标签页 */}
|
||||
<Tabs defaultValue="all" value={activeCategory} onValueChange={setActiveCategory}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="potential">潜在客户</TabsTrigger>
|
||||
<TabsTrigger value="customer">已转化</TabsTrigger>
|
||||
<TabsTrigger value="lost">已流失</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* 筛选器 */}
|
||||
<div className="flex space-x-2">
|
||||
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部来源</SelectItem>
|
||||
{allSources.map((source) => (
|
||||
<SelectItem key={source} value={source}>
|
||||
{source}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={tagFilter} onValueChange={setTagFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="标签" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部标签</SelectItem>
|
||||
{allTags.map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="outline" className="ml-auto" onClick={handleSelectAll}>
|
||||
{selectedUserIds.length === filteredUsers.length && filteredUsers.length > 0 ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<ScrollArea className="flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{searchQuery || activeCategory !== "all" || sourceFilter !== "all" || tagFilter !== "all"
|
||||
? "没有符合条件的用户"
|
||||
: "暂无用户数据"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 pr-4">
|
||||
{filteredUsers.map((user) => (
|
||||
<Card
|
||||
key={user.id}
|
||||
className={`p-3 hover:shadow-md transition-shadow ${
|
||||
selectedUserIds.includes(user.id) ? "border-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onCheckedChange={() => handleSelectUser(user.id)}
|
||||
id={`user-${user.id}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor={`user-${user.id}`} className="font-medium truncate cursor-pointer">
|
||||
{user.nickname}
|
||||
</label>
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs ${
|
||||
user.status === "added"
|
||||
? "bg-green-100 text-green-800"
|
||||
: user.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{user.status === "added" ? "已添加" : user.status === "pending" ? "待处理" : "已失败"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">微信号: {user.wechatId}</div>
|
||||
<div className="text-sm text-gray-500">来源: {user.source}</div>
|
||||
|
||||
{/* 标签展示 */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{user.tags.map((tag) => (
|
||||
<span key={tag.id} className={`text-xs px-2 py-0.5 rounded-full ${tag.color}`}>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="text-sm">
|
||||
已选择 <span className="font-medium text-primary">{selectedUserIds.length}</span> 个用户
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>确认</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,57 +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,41 +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",
|
||||
},
|
||||
},
|
||||
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,60 +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,55 +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,44 +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,29 +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,12 +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,182 +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,23 +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 }
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
|
||||
),
|
||||
)
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
|
||||
<span aria-hidden className={cn("flex h-9 w-9 items-center justify-center", className)} {...props}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
"use client"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./dialog"
|
||||
import { Button } from "./button"
|
||||
import { Eye } from "lucide-react"
|
||||
|
||||
interface PreviewDialogProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function PreviewDialog({ children, title = "预览效果" }: PreviewDialogProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
预览
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-[360px] p-0">
|
||||
<DialogHeader className="p-4 border-b">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative bg-gray-50">
|
||||
<div className="w-full overflow-hidden">{children}</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover 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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none 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">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator }
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from "react"
|
||||
import { cn } from "@/app/lib/utils"
|
||||
|
||||
interface StepsProps {
|
||||
currentStep: number
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface StepProps {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function Steps({ currentStep, className, children }: StepsProps) {
|
||||
const steps = React.Children.toArray(children)
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center", className)}>
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === currentStep
|
||||
const isCompleted = index < currentStep
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium",
|
||||
isActive && "bg-blue-500 text-white",
|
||||
isCompleted && "bg-green-500 text-white",
|
||||
!isActive && !isCompleted && "bg-gray-200 text-gray-500",
|
||||
)}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">{step}</div>
|
||||
</div>
|
||||
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn("flex-1 h-0.5 mx-4", index < currentStep ? "bg-green-500" : "bg-gray-200")} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Step({ title, description }: StepProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
{description && <div className="text-xs text-gray-500">{description}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
|
||||
)
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot ref={ref} className={cn("bg-primary font-medium text-primary-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background 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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
@@ -1,112 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background",
|
||||
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TooltipProps {
|
||||
children: React.ReactNode
|
||||
content: React.ReactNode
|
||||
className?: string
|
||||
delayDuration?: number
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}
|
||||
|
||||
const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
|
||||
({ children, content, className, delayDuration = 200, side = "top" }, ref) => {
|
||||
const [isVisible, setIsVisible] = React.useState(false)
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0 })
|
||||
const tooltipRef = React.useRef<HTMLDivElement>(null)
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout>()
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
const tooltipRect = tooltipRef.current?.getBoundingClientRect()
|
||||
|
||||
if (tooltipRect) {
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
switch (side) {
|
||||
case "top":
|
||||
top = rect.top - tooltipRect.height - 8
|
||||
left = rect.left + (rect.width - tooltipRect.width) / 2
|
||||
break
|
||||
case "bottom":
|
||||
top = rect.bottom + 8
|
||||
left = rect.left + (rect.width - tooltipRect.width) / 2
|
||||
break
|
||||
case "left":
|
||||
top = rect.top + (rect.height - tooltipRect.height) / 2
|
||||
left = rect.left - tooltipRect.width - 8
|
||||
break
|
||||
case "right":
|
||||
top = rect.top + (rect.height - tooltipRect.height) / 2
|
||||
left = rect.right + 8
|
||||
break
|
||||
}
|
||||
|
||||
setPosition({ top, left })
|
||||
setIsVisible(true)
|
||||
}
|
||||
}, delayDuration)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} ref={ref}>
|
||||
{children}
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={cn(
|
||||
"fixed z-50 px-2 py-1 text-xs text-primary-foreground bg-primary rounded-md shadow-sm scale-90 animate-in fade-in-0 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,
|
||||
)}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
transition: "opacity 150ms ease-in-out, transform 150ms ease-in-out",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
Tooltip.displayName = "Tooltip"
|
||||
|
||||
// 为了保持 API 兼容性,我们导出相同的组件名称
|
||||
export const TooltipProvider = ({ children }: { children: React.ReactNode }) => children
|
||||
export const TooltipTrigger = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((props, ref) => (
|
||||
<div ref={ref} {...props} />
|
||||
))
|
||||
TooltipTrigger.displayName = "TooltipTrigger"
|
||||
|
||||
export const TooltipContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((props, ref) => (
|
||||
<div ref={ref} {...props} />
|
||||
))
|
||||
TooltipContent.displayName = "TooltipContent"
|
||||
|
||||
export { Tooltip }
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
|
||||
interface ContactData {
|
||||
mobile: number;
|
||||
from: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export default function ContactImportPage() {
|
||||
const [parsedData, setParsedData] = useState<ContactData[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [isImportSuccessful, setIsImportSuccessful] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [detectedColumns, setDetectedColumns] = useState<{
|
||||
mobile?: string;
|
||||
from?: string;
|
||||
alias?: string;
|
||||
}>({});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
setError(null);
|
||||
setIsImportSuccessful(false);
|
||||
setParsedData([]);
|
||||
setDetectedColumns({});
|
||||
setIsProcessing(true);
|
||||
|
||||
const file = files[0];
|
||||
setFileName(file.name);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const data = event.target?.result;
|
||||
if (!data) {
|
||||
setError("文件内容为空,请检查文件是否有效");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let workbook;
|
||||
try {
|
||||
workbook = XLSX.read(data, { type: "binary" });
|
||||
} catch (parseErr) {
|
||||
console.error("解析Excel内容失败:", parseErr);
|
||||
setError("无法解析文件内容,请确保上传的是有效的Excel文件(.xlsx或.xls格式)");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
|
||||
setError("Excel文件中没有找到工作表");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
if (!worksheet) {
|
||||
setError(`无法读取工作表 "${sheetName}",请检查文件是否损坏`);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 转换为JSON
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: "A" });
|
||||
|
||||
// 检查是否有数据
|
||||
if (!jsonData || jsonData.length === 0) {
|
||||
setError("Excel 文件中没有数据");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找栏位对应的列
|
||||
let mobileColumn: string | null = null;
|
||||
let fromColumn: string | null = null;
|
||||
let aliasColumn: string | null = null;
|
||||
|
||||
// 遍历第一行查找栏位
|
||||
const firstRow = jsonData[0] as Record<string, any>;
|
||||
if (!firstRow) {
|
||||
setError("Excel 文件的第一行为空,无法识别栏位");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key in firstRow) {
|
||||
if (!firstRow[key]) continue; // 跳过空值
|
||||
|
||||
const value = String(firstRow[key]).toLowerCase();
|
||||
|
||||
// 扩展匹配列表,提高识别成功率
|
||||
if (value.includes("手机") || value.includes("电话") || value.includes("mobile") ||
|
||||
value.includes("phone") || value.includes("tel") || value.includes("cell")) {
|
||||
mobileColumn = key;
|
||||
} else if (value.includes("来源") || value.includes("source") || value.includes("from") ||
|
||||
value.includes("channel") || value.includes("渠道")) {
|
||||
fromColumn = key;
|
||||
} else if (value.includes("微信") || value.includes("alias") || value.includes("wechat") ||
|
||||
value.includes("wx") || value.includes("id") || value.includes("账号")) {
|
||||
aliasColumn = key;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存检测到的列名
|
||||
if (mobileColumn && firstRow[mobileColumn]) {
|
||||
setDetectedColumns(prev => ({ ...prev, mobile: String(firstRow[mobileColumn]) }));
|
||||
}
|
||||
if (fromColumn && firstRow[fromColumn]) {
|
||||
setDetectedColumns(prev => ({ ...prev, from: String(firstRow[fromColumn]) }));
|
||||
}
|
||||
if (aliasColumn && firstRow[aliasColumn]) {
|
||||
setDetectedColumns(prev => ({ ...prev, alias: String(firstRow[aliasColumn]) }));
|
||||
}
|
||||
|
||||
if (!mobileColumn) {
|
||||
setError("未找到手机号码栏位,请确保Excel中包含手机、电话、mobile或phone等栏位名称");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析数据,跳过首行
|
||||
const importedData: ContactData[] = [];
|
||||
for (let i = 1; i < jsonData.length; i++) {
|
||||
const row = jsonData[i] as Record<string, any>;
|
||||
|
||||
// 检查行是否存在且有手机号列
|
||||
if (!row || row[mobileColumn] === undefined || row[mobileColumn] === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 安全地转换并清理手机号
|
||||
const mobileStr = row[mobileColumn] !== undefined && row[mobileColumn] !== null
|
||||
? String(row[mobileColumn]).trim()
|
||||
: "";
|
||||
|
||||
// 过滤非数字字符,确保手机号是纯数字
|
||||
const mobile = mobileStr.replace(/\D/g, "");
|
||||
|
||||
// 手机号为空的跳过
|
||||
if (!mobile) continue;
|
||||
|
||||
// 安全地获取来源和别名
|
||||
const from = fromColumn && row[fromColumn] !== undefined && row[fromColumn] !== null
|
||||
? String(row[fromColumn]).trim()
|
||||
: "";
|
||||
|
||||
const alias = aliasColumn && row[aliasColumn] !== undefined && row[aliasColumn] !== null
|
||||
? String(row[aliasColumn]).trim()
|
||||
: "";
|
||||
|
||||
importedData.push({
|
||||
mobile: Number(mobile),
|
||||
from,
|
||||
alias
|
||||
});
|
||||
}
|
||||
|
||||
if (importedData.length === 0) {
|
||||
setError("未找到有效数据,请确保Excel中至少有一行有效的手机号码");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setParsedData(importedData);
|
||||
setIsProcessing(false);
|
||||
} catch (err) {
|
||||
console.error("解析Excel文件出错:", err);
|
||||
setError("解析Excel文件时出错,请确保文件格式正确");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
setError("读取文件时出错,请重试");
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (parsedData.length > 0) {
|
||||
// 打印解析数据
|
||||
console.log("导入的联系人数据:");
|
||||
console.log(JSON.stringify(parsedData, null, 2));
|
||||
|
||||
// 在界面上显示成功消息
|
||||
setIsImportSuccessful(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setParsedData([]);
|
||||
setFileName("");
|
||||
setError(null);
|
||||
setIsImportSuccessful(false);
|
||||
const fileInput = document.getElementById("file-input") as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 max-w-2xl">
|
||||
<Card className="p-6 shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">联系人导入</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">选择Excel文件</label>
|
||||
<Input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileChange}
|
||||
className="w-full"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
{fileName && (
|
||||
<p className="text-sm text-gray-500">
|
||||
当前文件: {fileName}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
请确保Excel文件包含以下列: 手机号码(必需)、来源(可选)、微信号(可选)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-solid border-blue-500 border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-600">正在处理Excel文件...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isImportSuccessful && (
|
||||
<Alert className="bg-green-50 text-green-800 border-green-200">
|
||||
<AlertDescription>
|
||||
已成功导入 {parsedData.length} 条联系人数据!数据已打印到控制台。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{parsedData.length > 0 && !isImportSuccessful && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">
|
||||
已解析 {parsedData.length} 条有效数据,点击下方按钮确认导入。
|
||||
</p>
|
||||
|
||||
{Object.keys(detectedColumns).length > 0 && (
|
||||
<div className="text-xs p-2 bg-blue-50 rounded border border-blue-100">
|
||||
<p className="font-medium text-blue-700 mb-1">检测到的列名:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{detectedColumns.mobile && <li>手机号: {detectedColumns.mobile}</li>}
|
||||
{detectedColumns.from && <li>来源: {detectedColumns.from}</li>}
|
||||
{detectedColumns.alias && <li>微信号: {detectedColumns.alias}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="font-medium text-sm mb-1">数据示例:</p>
|
||||
<div className="text-xs bg-gray-50 p-2 rounded overflow-hidden">
|
||||
<pre>{JSON.stringify(parsedData.slice(0, 3), null, 2)}</pre>
|
||||
{parsedData.length > 3 && <p className="mt-1 text-gray-500">...共 {parsedData.length} 条</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm mb-1">数据结构:</p>
|
||||
<div className="text-xs bg-gray-50 p-2 rounded">
|
||||
<pre>{`[
|
||||
{
|
||||
"mobile": 13800000000,
|
||||
"from": "小红书",
|
||||
"alias": "xxxxxx"
|
||||
},
|
||||
...
|
||||
]`}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
className="flex-1"
|
||||
disabled={parsedData.length === 0 || isImportSuccessful || isProcessing}
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="flex-1"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,492 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, use } from "react"
|
||||
import { ChevronLeft, X, Users } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker"
|
||||
import { WechatFriendSelector } from "@/components/WechatFriendSelector"
|
||||
import { WechatGroupSelector } from "@/components/WechatGroupSelector"
|
||||
import { WechatGroupMemberSelector } from "@/components/WechatGroupMemberSelector"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { api } from "@/lib/api"
|
||||
import { showToast } from "@/lib/toast"
|
||||
import { format, parse } from "date-fns"
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { WechatFriend, WechatGroup, WechatGroupMember } from "@/types/wechat"
|
||||
|
||||
interface ContentLibraryDetail {
|
||||
id: string
|
||||
name: string
|
||||
sourceType: number
|
||||
status: number
|
||||
sourceFriends: any[]
|
||||
sourceGroups: any[]
|
||||
selectedFriends?: WechatFriend[]
|
||||
selectedGroups?: WechatGroup[]
|
||||
selectedGroupMembers?: WechatGroupMember[]
|
||||
groupMembers?: Record<string, number[]>
|
||||
keywordInclude: string[]
|
||||
keywordExclude: string[]
|
||||
isEnabled: number
|
||||
aiPrompt: string
|
||||
timeEnabled: number
|
||||
timeStart: string
|
||||
timeEnd: string
|
||||
createTime: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export default function EditContentLibraryPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params)
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
sourceType: "friends" as "friends" | "groups",
|
||||
keywordsInclude: "",
|
||||
keywordsExclude: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
selectedFriends: [] as WechatFriend[],
|
||||
selectedGroups: [] as WechatGroup[],
|
||||
selectedGroupMembers: [] as WechatGroupMember[],
|
||||
useAI: false,
|
||||
aiPrompt: "",
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const [isWechatFriendSelectorOpen, setIsWechatFriendSelectorOpen] = useState(false)
|
||||
const [isWechatGroupSelectorOpen, setIsWechatGroupSelectorOpen] = useState(false)
|
||||
const [isWechatGroupMemberSelectorOpen, setIsWechatGroupMemberSelectorOpen] = useState(false)
|
||||
const [currentGroupId, setCurrentGroupId] = useState<string>("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isLoadingData, setIsLoadingData] = useState(true)
|
||||
|
||||
// 获取内容库详情
|
||||
useEffect(() => {
|
||||
const fetchLibraryDetail = async () => {
|
||||
setIsLoadingData(true)
|
||||
const loadingToast = showToast("正在加载内容库数据...", "loading", true);
|
||||
|
||||
try {
|
||||
const response = await api.get<ApiResponse<ContentLibraryDetail>>(`/v1/content/library/detail?id=${resolvedParams.id}`)
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
const data = response.data
|
||||
|
||||
// 直接使用API返回的好友和群组数据
|
||||
const friends = data.selectedFriends || [];
|
||||
const groups = data.selectedGroups || [];
|
||||
|
||||
// 根据接口返回的groupMembers字段构建selectedGroupMembers数组
|
||||
let groupMembers: WechatGroupMember[] = [];
|
||||
|
||||
// 检查是否有groupMembers数据
|
||||
if (data.groupMembers) {
|
||||
// 将 {"813941": [262439]} 格式转换为 WechatGroupMember[] 数组
|
||||
Object.entries(data.groupMembers).forEach(([groupId, memberIds]) => {
|
||||
// 确保memberIds是数组
|
||||
if (Array.isArray(memberIds)) {
|
||||
memberIds.forEach(memberId => {
|
||||
// 在groups中查找对应的group信息
|
||||
const group = groups.find(g => g.id === groupId);
|
||||
|
||||
if (group) {
|
||||
// 将group信息与member ID组合成 WechatGroupMember 对象
|
||||
groupMembers.push({
|
||||
id: String(memberId),
|
||||
groupId: groupId,
|
||||
nickname: "", // 默认为空,实际使用时会由WechatGroupMemberSelector填充
|
||||
avatar: "", // 默认为空,实际使用时会由WechatGroupMemberSelector填充
|
||||
wechatId: "" // 添加必要的wechatId字段
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (data.selectedGroupMembers) {
|
||||
// 如果有selectedGroupMembers字段,则直接使用
|
||||
groupMembers = data.selectedGroupMembers;
|
||||
}
|
||||
|
||||
setFormData({
|
||||
name: data.name,
|
||||
sourceType: data.sourceType === 1 ? "friends" : "groups",
|
||||
keywordsInclude: data.keywordInclude ? data.keywordInclude.join(", ") : "",
|
||||
keywordsExclude: data.keywordExclude ? data.keywordExclude.join(", ") : "",
|
||||
startDate: data.timeStart || "",
|
||||
endDate: data.timeEnd || "",
|
||||
selectedFriends: friends,
|
||||
selectedGroups: groups,
|
||||
selectedGroupMembers: groupMembers,
|
||||
useAI: !!data.aiPrompt,
|
||||
aiPrompt: data.aiPrompt || "",
|
||||
enabled: data.status === 1,
|
||||
})
|
||||
} else {
|
||||
showToast(response.msg || "获取内容库详情失败", "error")
|
||||
router.back()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("获取内容库详情失败:", error)
|
||||
showToast(error?.message || "请检查网络连接", "error")
|
||||
router.back()
|
||||
} finally {
|
||||
setIsLoadingData(false)
|
||||
loadingToast.remove()
|
||||
}
|
||||
}
|
||||
|
||||
fetchLibraryDetail()
|
||||
}, [resolvedParams.id, router])
|
||||
|
||||
const removeFriend = (friendId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
selectedFriends: prev.selectedFriends.filter((friend) => friend.id !== friendId),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeGroup = (groupId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
selectedGroups: prev.selectedGroups.filter((group) => group.id !== groupId),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectGroupMembers = (groupId: string) => {
|
||||
setCurrentGroupId(groupId)
|
||||
setIsWechatGroupMemberSelectorOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name) {
|
||||
showToast("请输入内容库名称", "error")
|
||||
return
|
||||
}
|
||||
|
||||
// if (formData.sourceType === "friends" && formData.selectedFriends.length === 0) {
|
||||
// showToast("请选择微信好友", "error")
|
||||
// return
|
||||
// }
|
||||
|
||||
// if (formData.sourceType === "groups" && formData.selectedGroups.length === 0) {
|
||||
// showToast("请选择聊天群", "error")
|
||||
// return
|
||||
// }
|
||||
|
||||
setLoading(true)
|
||||
const loadingToast = showToast("正在更新内容库...", "loading", true);
|
||||
|
||||
try {
|
||||
// 将群成员数据转换为{"813941": [262439]}格式
|
||||
const groupMembersMap: Record<string, string[]> = {}
|
||||
|
||||
formData.selectedGroupMembers.forEach(member => {
|
||||
if (member.groupId) {
|
||||
if (!groupMembersMap[member.groupId]) {
|
||||
groupMembersMap[member.groupId] = []
|
||||
}
|
||||
groupMembersMap[member.groupId].push(member.id)
|
||||
}
|
||||
})
|
||||
|
||||
const payload = {
|
||||
id: resolvedParams.id,
|
||||
name: formData.name,
|
||||
sourceType: formData.sourceType === "friends" ? 1 : 2,
|
||||
friends: formData.selectedFriends.map(f => f.id),
|
||||
groups: formData.selectedGroups.map(g => g.id),
|
||||
groupMembers: groupMembersMap, // 使用JSON字符串传递群成员数据
|
||||
keywordInclude: formData.keywordsInclude.split(",").map(k => k.trim()).filter(Boolean),
|
||||
keywordExclude: formData.keywordsExclude.split(",").map(k => k.trim()).filter(Boolean),
|
||||
aiPrompt: formData.useAI ? formData.aiPrompt : "",
|
||||
timeEnabled: formData.startDate && formData.endDate ? 1 : 0,
|
||||
startTime: formData.startDate || "",
|
||||
endTime: formData.endDate || "",
|
||||
status: formData.enabled ? 1 : 0
|
||||
}
|
||||
|
||||
const response = await api.post<ApiResponse>("/v1/content/library/update", payload)
|
||||
|
||||
if (response.code === 200) {
|
||||
loadingToast.remove()
|
||||
showToast("更新成功", "success")
|
||||
router.push("/content")
|
||||
} else {
|
||||
loadingToast.remove()
|
||||
showToast(response.msg || "更新失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("更新内容库失败:", error)
|
||||
loadingToast.remove()
|
||||
showToast(error?.message || "请检查网络连接", "error")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingData) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F9FA] flex justify-center items-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-500">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-base required">
|
||||
内容库名称
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="请输入内容库名称"
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">数据来源配置</Label>
|
||||
<Tabs
|
||||
value={formData.sourceType}
|
||||
onValueChange={(value) => setFormData({ ...formData, sourceType: value as "friends" | "groups" })}
|
||||
className="mt-1.5"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="friends">选择微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">选择聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="friends" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatFriendSelectorOpen(true)}>
|
||||
选择微信好友
|
||||
</Button>
|
||||
{formData.selectedFriends.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{formData.selectedFriends.map((friend) => (
|
||||
<div key={friend.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={friend.avatar || "/placeholder.svg"}
|
||||
alt={friend.nickname}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span>{friend.nickname}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeFriend(friend.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="groups" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatGroupSelectorOpen(true)}>
|
||||
选择聊天群
|
||||
</Button>
|
||||
{formData.selectedGroups.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{formData.selectedGroups.map((group) => (
|
||||
<div key={group.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={group.avatar || "/placeholder.svg"}
|
||||
alt={group.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span className="truncate max-w-[220px]">{group.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSelectGroupMembers(group.id)}
|
||||
className={`mr-1 ${formData.selectedGroupMembers.some(m => m.groupId === group.id) ? 'text-blue-500' : ''}`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{formData.selectedGroupMembers.some(m => m.groupId === group.id) && (
|
||||
<span className="ml-1 text-xs">
|
||||
+{formData.selectedGroupMembers.filter(m => m.groupId === group.id).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeGroup(group.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="keywords">
|
||||
<AccordionTrigger>关键字设置</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="keywordsInclude" className="text-base">
|
||||
关键字匹配
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsInclude"
|
||||
value={formData.keywordsInclude}
|
||||
onChange={(e) => setFormData({ ...formData, keywordsInclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="keywordsExclude" className="text-base">
|
||||
关键字排除
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsExclude"
|
||||
value={formData.keywordsExclude}
|
||||
onChange={(e) => setFormData({ ...formData, keywordsExclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,匹配到关键字的,系统将不会采集。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base">是否启用AI</Label>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
当启用AI之后,该内容库下的所有内容,都会通过AI重新生成内容。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.useAI}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, useAI: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.useAI && (
|
||||
<div>
|
||||
<Label htmlFor="aiPrompt" className="text-base">
|
||||
AI 提示词
|
||||
</Label>
|
||||
<Textarea
|
||||
id="aiPrompt"
|
||||
value={formData.aiPrompt}
|
||||
onChange={(e) => setFormData({ ...formData, aiPrompt: e.target.value })}
|
||||
placeholder="请输入 AI 提示词"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-base">时间限制</Label>
|
||||
<DateRangePicker
|
||||
className="mt-1.5"
|
||||
onChange={(range) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
startDate: range?.from ? format(range.from, "yyyy-MM-dd") : "",
|
||||
endDate: range?.to ? format(range.to, "yyyy-MM-dd") : "",
|
||||
})
|
||||
}}
|
||||
value={formData.startDate && formData.endDate ? {
|
||||
from: new Date(formData.startDate),
|
||||
to: new Date(formData.endDate)
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base required">是否启用</Label>
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatFriendSelector
|
||||
open={isWechatFriendSelectorOpen}
|
||||
onOpenChange={setIsWechatFriendSelectorOpen}
|
||||
selectedFriends={formData.selectedFriends}
|
||||
onSelect={(friends) => setFormData({ ...formData, selectedFriends: friends })}
|
||||
/>
|
||||
|
||||
<WechatGroupSelector
|
||||
open={isWechatGroupSelectorOpen}
|
||||
onOpenChange={setIsWechatGroupSelectorOpen}
|
||||
selectedGroups={formData.selectedGroups}
|
||||
onSelect={(groups) => setFormData({ ...formData, selectedGroups: groups })}
|
||||
/>
|
||||
|
||||
<WechatGroupMemberSelector
|
||||
open={isWechatGroupMemberSelectorOpen}
|
||||
onOpenChange={setIsWechatGroupMemberSelectorOpen}
|
||||
groupId={currentGroupId}
|
||||
selectedMembers={formData.selectedGroupMembers}
|
||||
onSelect={(members) => setFormData({ ...formData, selectedGroupMembers: members })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect, use } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Plus, X, Image as ImageIcon, UploadCloud, Link, Video, FileText, Layers, CalendarDays, ChevronDown } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { api } from "@/lib/api"
|
||||
import { showToast } from "@/lib/toast"
|
||||
import Image from "next/image"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useRef } from "react"
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface Material {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
content: string
|
||||
coverImage: string | null
|
||||
resUrls: string[]
|
||||
urls: ({ desc?: string; image?: string; url?: string } | string)[]
|
||||
createTime: string
|
||||
createMomentTime: number
|
||||
time: string
|
||||
wechatId: string
|
||||
friendId: string | null
|
||||
wechatChatroomId: number
|
||||
senderNickname: string
|
||||
location: string | null
|
||||
lat: string
|
||||
lng: string
|
||||
comment: string | null
|
||||
icon: string | null
|
||||
videoUrl?: string
|
||||
sendTime: string
|
||||
contentType: string
|
||||
}
|
||||
|
||||
const isImageUrl = (url: string) => {
|
||||
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) || url.includes('oss-cn-shenzhen.aliyuncs.com')
|
||||
}
|
||||
|
||||
// 素材类型枚举
|
||||
const MATERIAL_TYPES = [
|
||||
{ id: 1, name: "图片", icon: ImageIcon },
|
||||
{ id: 2, name: "链接", icon: Link },
|
||||
{ id: 3, name: "视频", icon: Video },
|
||||
{ id: 4, name: "文本", icon: FileText },
|
||||
{ id: 5, name: "小程序", icon: Layers }
|
||||
]
|
||||
|
||||
export default function EditMaterialPage({ params }: { params: Promise<{ id: string, materialId: string }> }) {
|
||||
const resolvedParams = use(params)
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [content, setContent] = useState("")
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([])
|
||||
const [originalMaterial, setOriginalMaterial] = useState<Material | null>(null)
|
||||
const [sendTime, setSendTime] = useState("")
|
||||
const [contentType, setContentType] = useState<number>(1)
|
||||
const [url, setUrl] = useState<string>("")
|
||||
const [desc, setDesc] = useState("")
|
||||
const [image, setImage] = useState("")
|
||||
const [title, setTitle] = useState<string>("")
|
||||
const [iconUrl, setIconUrl] = useState<string>("")
|
||||
const [videoUrl, setVideoUrl] = useState<string>("")
|
||||
const [comment, setComment] = useState("")
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 获取素材详情
|
||||
useEffect(() => {
|
||||
const fetchMaterialDetail = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await api.get<ApiResponse<Material>>(`/v1/content/library/get-item-detail?id=${resolvedParams.materialId}`)
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
const material = response.data
|
||||
setOriginalMaterial(material)
|
||||
setContent(material.content)
|
||||
setComment(material.comment || "")
|
||||
setSendTime(material.sendTime || "")
|
||||
setContentType(Number(material.contentType) || 1)
|
||||
// 链接类型
|
||||
if (Number(material.contentType) === 2 && material.urls && material.urls.length > 0) {
|
||||
const first = material.urls[0];
|
||||
if (typeof first === "object" && first !== null) {
|
||||
setDesc(first.desc || "");
|
||||
setImage(first.image || "");
|
||||
setUrl(first.url || "");
|
||||
} else {
|
||||
setDesc("");
|
||||
setImage("");
|
||||
setUrl(typeof first === "string" ? first : "");
|
||||
}
|
||||
}
|
||||
|
||||
// 视频类型
|
||||
if (Number(material.contentType) === 3 && material.urls && material.urls.length > 0) {
|
||||
const first = material.urls[0];
|
||||
if (typeof first === "string") {
|
||||
setVideoUrl(first);
|
||||
} else if (typeof first === "object" && first !== null && first.url) {
|
||||
setVideoUrl(first.url);
|
||||
} else {
|
||||
setVideoUrl("");
|
||||
}
|
||||
}
|
||||
|
||||
// 处理图片
|
||||
const imageUrls: string[] = []
|
||||
|
||||
// 检查内容本身是否为图片链接
|
||||
if (isImageUrl(material.content)) {
|
||||
if (!imageUrls.includes(material.content)) {
|
||||
imageUrls.push(material.content)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加资源URL中的图片
|
||||
material.resUrls.forEach(url => {
|
||||
if (isImageUrl(url) && !imageUrls.includes(url)) {
|
||||
imageUrls.push(url)
|
||||
}
|
||||
})
|
||||
|
||||
setImages(imageUrls)
|
||||
setPreviewUrls(imageUrls)
|
||||
} else {
|
||||
showToast(response.msg || "获取素材详情失败", "error")
|
||||
router.back()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch material detail:", error)
|
||||
showToast(error?.message || "请检查网络连接", "error")
|
||||
router.back()
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchMaterialDetail()
|
||||
}, [resolvedParams.materialId, router])
|
||||
|
||||
// 替换handleUploadImage为:
|
||||
const handleUploadImage = () => {
|
||||
if (images.length >= 9) {
|
||||
showToast("最多只能上传9张图片", "error")
|
||||
return
|
||||
}
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
// 新增真实上传逻辑
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (images.length >= 9) {
|
||||
showToast("最多只能上传9张图片", "error")
|
||||
return
|
||||
}
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showToast("请选择图片文件", "error")
|
||||
return
|
||||
}
|
||||
|
||||
showToast("正在上传图片...", "loading")
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: HeadersInit = {}
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (result.code === 200 && result.data?.url) {
|
||||
setImages((prev) => [...prev, result.data.url])
|
||||
setPreviewUrls((prev) => [...prev, result.data.url])
|
||||
showToast("图片上传成功", "success")
|
||||
} else {
|
||||
showToast(result.msg || "图片上传失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || "图片上传失败", "error")
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveImage = (indexToRemove: number) => {
|
||||
setImages(images.filter((_, index) => index !== indexToRemove))
|
||||
setPreviewUrls(previewUrls.filter((_, index) => index !== indexToRemove))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// 根据不同类型校验不同字段
|
||||
if (contentType === 1 && images.length === 0) {
|
||||
showToast("请上传图片", "error")
|
||||
return
|
||||
} else if (contentType === 2 && (!url || !desc)) {
|
||||
showToast("请输入描述和链接地址", "error")
|
||||
return
|
||||
} else if ((contentType === 4 || contentType === 6) && !content) {
|
||||
showToast("请输入文本内容", "error")
|
||||
return
|
||||
}
|
||||
|
||||
const loadingToast = showToast("正在更新素材...", "loading", true)
|
||||
try {
|
||||
const payload: any = {
|
||||
id: resolvedParams.materialId,
|
||||
contentType: contentType,
|
||||
content: content,
|
||||
comment: comment,
|
||||
sendTime: sendTime,
|
||||
}
|
||||
|
||||
// 根据类型添加不同的字段
|
||||
if (contentType === 1) {
|
||||
payload.resUrls = images
|
||||
} else if (contentType === 2) {
|
||||
payload.urls = [{ desc, image, url }]
|
||||
} else if (contentType === 3) {
|
||||
payload.urls = videoUrl ? [videoUrl] : []
|
||||
}
|
||||
|
||||
const response = await api.post<ApiResponse>('/v1/content/library/update-item', payload)
|
||||
|
||||
if (response.code === 200) {
|
||||
showToast("素材更新成功", "success")
|
||||
router.push(`/content/${resolvedParams.id}/materials`)
|
||||
} else {
|
||||
showToast(response.msg || "更新失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to update material:", error)
|
||||
showToast(error?.message || "更新失败", "error")
|
||||
} finally {
|
||||
loadingToast.remove && loadingToast.remove()
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">编辑素材</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<Card className="p-8 rounded-3xl shadow-xl bg-white max-w-lg mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* 基础信息分组 */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">基础信息</div>
|
||||
<div className="mb-4">
|
||||
<Label className="font-bold flex items-center mb-2">
|
||||
发布时间
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="publish-time"
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
value={sendTime}
|
||||
onChange={(e) => setSendTime(e.target.value)}
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
placeholder="请选择发布时间"
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>类型
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
className="appearance-none w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 pr-10 text-base bg-white placeholder:text-gray-300"
|
||||
value={contentType}
|
||||
onChange={e => setContentType(Number(e.target.value))}
|
||||
>
|
||||
{MATERIAL_TYPES.map(type => (
|
||||
<option key={type.id} value={type.id}>{type.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-gray-100 my-4" />
|
||||
{/* 内容信息分组 */}
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">内容信息</div>
|
||||
<Label htmlFor="content" className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>内容
|
||||
</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="请输入内容"
|
||||
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[120px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={10}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="comment" className="font-bold mb-2">评论</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="请输入评论内容"
|
||||
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[80px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contentType === 2 && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">内容信息</div>
|
||||
<Label htmlFor="title" className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>描述
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
placeholder="请输入描述"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"/>
|
||||
{/* 封面图上传(单图) */}
|
||||
<div className="mt-4">
|
||||
<Label className="font-bold mb-2">封面图</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
{!image ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-2xl border-dashed border-2 border-blue-300 bg-white hover:bg-blue-50 h-28 w-28 flex flex-col items-center justify-center p-0"
|
||||
onClick={() => {
|
||||
// 模拟上传,实际应对接上传接口
|
||||
const mock = [
|
||||
"https://cdn-icons-png.flaticon.com/512/732/732212.png",
|
||||
"https://cdn-icons-png.flaticon.com/512/5968/5968764.png",
|
||||
"https://cdn-icons-png.flaticon.com/512/5968/5968705.png"
|
||||
];
|
||||
const random = mock[Math.floor(Math.random() * mock.length)];
|
||||
setImage(random);
|
||||
}}
|
||||
>
|
||||
<UploadCloud className="h-8 w-8 mb-2 text-gray-400 mx-auto" />
|
||||
<span className="text-sm text-gray-500">上传封面图</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Image src={image} alt="封面图" width={80} height={80} className="object-contain rounded-xl mx-auto my-auto" />
|
||||
<Button type="button" variant="destructive" size="sm" className="absolute top-1 right-1 h-8 px-2 rounded-lg" onClick={() => setImage("")}>删除</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">建议尺寸 80x80,支持 PNG/JPG</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="url" className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>链接地址
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="请输入链接地址"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{contentType === 3 && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">内容信息</div>
|
||||
<Label className="font-bold mb-2">上传视频</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
{!videoUrl ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-2xl border-dashed border-2 border-blue-300 bg-white hover:bg-blue-50 h-28 w-44 flex flex-col items-center justify-center p-0"
|
||||
onClick={() => {
|
||||
// 模拟上传,实际应对接上传接口
|
||||
const mock = [
|
||||
"https://www.w3schools.com/html/mov_bbb.mp4",
|
||||
"https://www.w3schools.com/html/movie.mp4"
|
||||
];
|
||||
const random = mock[Math.floor(Math.random() * mock.length)];
|
||||
setVideoUrl(random);
|
||||
}}
|
||||
>
|
||||
<UploadCloud className="h-8 w-8 mb-2 text-gray-400 mx-auto" />
|
||||
<span className="text-sm text-gray-500">上传视频</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<video src={videoUrl} controls className="object-contain rounded-xl h-24 w-40 mx-auto my-auto" />
|
||||
<Button type="button" variant="destructive" size="sm" className="absolute top-1 right-1 h-8 px-2 rounded-lg" onClick={() => setVideoUrl("")}>删除</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">支持MP4,建议不超过20MB</div>
|
||||
</div>
|
||||
)}
|
||||
{(contentType === 4 || contentType === 6) && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">内容信息</div>
|
||||
<Label htmlFor="content" className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>内容
|
||||
</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="请输入内容"
|
||||
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[120px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={10}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="comment" className="font-bold mb-2">评论</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="请输入评论内容"
|
||||
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[80px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 素材上传分组(仅图片类型和小程序类型) */}
|
||||
{(contentType === 1 || contentType === 5) && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">素材上传</div>
|
||||
{contentType === 1 && (
|
||||
<>
|
||||
<Label className="font-bold mb-2">素材</Label>
|
||||
<div className="border border-dashed border-gray-300 rounded-2xl p-4 text-center bg-gray-50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleUploadImage}
|
||||
className="w-full py-8 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
|
||||
disabled={images.length >= 9}
|
||||
>
|
||||
<UploadCloud className="h-8 w-8 mb-2 text-gray-400" />
|
||||
<span>点击上传图片</span>
|
||||
<span className="text-xs text-gray-500 mt-1">{`已上传${images.length}张,最多可上传9张`}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
/>
|
||||
{previewUrls.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<Label className="font-bold mb-2">已上传图片</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-2">
|
||||
{previewUrls.map((url, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<div className="aspect-square relative rounded-2xl overflow-hidden border border-gray-200">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`图片 ${index + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0 rounded-full"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{contentType === 5 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="appTitle" className="font-bold mb-2">小程序名称</Label>
|
||||
<Input
|
||||
id="appTitle"
|
||||
placeholder="请输入小程序名称"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="appId" className="font-bold mb-2">AppID</Label>
|
||||
<Input
|
||||
id="appId"
|
||||
placeholder="请输入AppID"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="font-bold mb-2">小程序封面图</Label>
|
||||
<div className="border border-dashed border-gray-300 rounded-2xl p-4 text-center bg-gray-50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleUploadImage}
|
||||
className="w-full py-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
|
||||
>
|
||||
<UploadCloud className="h-6 w-6 mb-2 text-gray-400" />
|
||||
<span>上传小程序封面图</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full h-12 rounded-2xl bg-blue-600 hover:bg-blue-700 text-base font-bold mt-12 shadow">
|
||||
保存修改
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Plus, X, Image as ImageIcon, UploadCloud, Link, Video, FileText, Layers, CalendarDays, ChevronDown } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import Image from "next/image"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { api } from "@/lib/api"
|
||||
import { showToast } from "@/lib/toast"
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 素材类型枚举
|
||||
const MATERIAL_TYPES = [
|
||||
{ id: 1, name: "图片", icon: ImageIcon },
|
||||
{ id: 2, name: "链接", icon: Link },
|
||||
{ id: 3, name: "视频", icon: Video },
|
||||
{ id: 4, name: "文本", icon: FileText },
|
||||
{ id: 5, name: "小程序", icon: Layers }
|
||||
]
|
||||
|
||||
export default function NewMaterialPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [content, setContent] = useState("")
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [previewUrls, setPreviewUrls] = useState<string[]>([])
|
||||
const [materialType, setMaterialType] = useState<number>(1)
|
||||
const [url, setUrl] = useState<string>("")
|
||||
const [desc, setDesc] = useState<string>("")
|
||||
const [image, setImage] = useState<string>("")
|
||||
const [videoUrl, setVideoUrl] = useState<string>("")
|
||||
const [publishTime, setPublishTime] = useState("")
|
||||
const [comment, setComment] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 图片上传
|
||||
const handleUploadImage = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (images.length >= 9) {
|
||||
showToast("最多只能上传9张图片", "error")
|
||||
return
|
||||
}
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showToast("请选择图片文件", "error")
|
||||
return
|
||||
}
|
||||
|
||||
const loadingToast = showToast("正在上传图片...", "loading", true)
|
||||
setLoading(true)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: HeadersInit = {
|
||||
// 浏览器会自动为 FormData 设置 Content-Type 为 multipart/form-data,无需手动设置
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/attachment/upload`, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result: ApiResponse = await response.json();
|
||||
|
||||
if (result.code === 200 && result.data?.url) {
|
||||
setImages((prev) => [...prev, result.data.url]);
|
||||
setPreviewUrls((prev) => [...prev, result.data.url]);
|
||||
showToast("图片上传成功", "success");
|
||||
} else {
|
||||
showToast(result.msg || "图片上传失败", "error");
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || "图片上传失败", "error")
|
||||
} finally {
|
||||
loadingToast.remove && loadingToast.remove()
|
||||
setLoading(false)
|
||||
// 清空文件输入框,以便再次上传同一文件
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveImage = (indexToRemove: number) => {
|
||||
setImages(images.filter((_, index) => index !== indexToRemove))
|
||||
setPreviewUrls(previewUrls.filter((_, index) => index !== indexToRemove))
|
||||
}
|
||||
|
||||
// 创建素材
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// 校验
|
||||
if (!content) {
|
||||
showToast("请输入内容", "error")
|
||||
return
|
||||
}
|
||||
// if (!comment) {
|
||||
// showToast("请输入评论内容", "error")
|
||||
// return
|
||||
// }
|
||||
if (materialType === 1 && images.length === 0) {
|
||||
showToast("请上传图片", "error")
|
||||
return
|
||||
} else if (materialType === 2 && (!url || !desc)) {
|
||||
showToast("请输入描述和链接地址", "error")
|
||||
return
|
||||
} else if (materialType === 3 && (!url && !videoUrl)) {
|
||||
showToast("请填写视频链接或上传视频", "error")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const loadingToast = showToast("正在创建素材...", "loading", true)
|
||||
try {
|
||||
const payload: any = {
|
||||
libraryId: params.id,
|
||||
type: materialType,
|
||||
content: content,
|
||||
comment: comment,
|
||||
sendTime: publishTime,
|
||||
}
|
||||
if (materialType === 1) {
|
||||
payload.resUrls = images
|
||||
} else if (materialType === 2) {
|
||||
payload.urls = [{ desc, image, url }]
|
||||
} else if (materialType === 3) {
|
||||
payload.urls = videoUrl ? [videoUrl] : []
|
||||
}
|
||||
const response = await api.post<ApiResponse>('/v1/content/library/create-item', payload)
|
||||
if (response.code === 200) {
|
||||
showToast("创建成功", "success")
|
||||
router.push(`/content/${params.id}/materials`)
|
||||
} else {
|
||||
showToast(response.msg || "创建失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || "创建素材失败", "error")
|
||||
} finally {
|
||||
loadingToast.remove && loadingToast.remove()
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">新建素材</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4">
|
||||
<Card className="p-8 rounded-3xl shadow-xl bg-white max-w-lg mx-auto">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* 基础信息分组 */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">基础信息</div>
|
||||
<div className="mb-4">
|
||||
<Label className="font-bold flex items-center mb-2">发布时间</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="publish-time"
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
value={publishTime}
|
||||
onChange={(e) => setPublishTime(e.target.value)}
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
placeholder="请选择发布时间"
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
<CalendarDays className="absolute right-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>类型
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
className="appearance-none w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 pr-10 text-base bg-white placeholder:text-gray-300"
|
||||
value={materialType}
|
||||
onChange={e => setMaterialType(Number(e.target.value))}
|
||||
>
|
||||
{MATERIAL_TYPES.map(type => (
|
||||
<option key={type.id} value={type.id}>{type.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-gray-100 my-4" />
|
||||
{/* 内容信息分组(所有类型都展示内容和评论) */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">内容信息</div>
|
||||
<Label htmlFor="content" className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>内容
|
||||
</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="请输入内容"
|
||||
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[120px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={10}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="comment" className="font-bold mb-2">评论</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="请输入评论内容"
|
||||
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[80px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{(materialType === 2 || materialType === 3) && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">内容信息</div>
|
||||
{materialType === 2 && (
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="desc" className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>描述
|
||||
</Label>
|
||||
<Input
|
||||
id="desc"
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
placeholder="请输入描述"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"/>
|
||||
{/* 封面图上传 */}
|
||||
<div className="mt-4">
|
||||
<Label className="font-bold mb-2">封面图</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-2xl border-dashed border-2 border-blue-300 bg-white hover:bg-blue-50 h-28 w-28 flex flex-col items-center justify-center p-0"
|
||||
onClick={() => {
|
||||
const mock = [
|
||||
"https://cdn-icons-png.flaticon.com/512/732/732212.png",
|
||||
"https://cdn-icons-png.flaticon.com/512/5968/5968764.png",
|
||||
"https://cdn-icons-png.flaticon.com/512/5968/5968705.png"
|
||||
];
|
||||
const random = mock[Math.floor(Math.random() * mock.length)];
|
||||
setImage(random);
|
||||
}}
|
||||
>
|
||||
{image ? (
|
||||
<Image src={image} alt="封面图" width={80} height={80} className="object-contain rounded-xl mx-auto my-auto" />
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="h-8 w-8 mb-2 text-gray-400 mx-auto" />
|
||||
<span className="text-sm text-gray-500">上传封面图</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{image && (
|
||||
<Button type="button" variant="destructive" size="sm" className="h-8 px-2 rounded-lg" onClick={() => setImage("")}>删除</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">建议尺寸 80x80,支持 PNG/JPG</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{materialType === 2 && (
|
||||
<>
|
||||
<Label htmlFor="url" className="font-bold flex items-center mb-2">
|
||||
<span className="text-red-500 mr-1">*</span>链接地址
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="请输入链接地址"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{materialType === 3 && (
|
||||
<div className="mt-4">
|
||||
<Label className="font-bold mb-2">上传视频</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-2xl border-dashed border-2 border-blue-300 bg-white hover:bg-blue-50 h-28 w-44 flex flex-col items-center justify-center p-0"
|
||||
onClick={() => {
|
||||
const mock = [
|
||||
"https://www.w3schools.com/html/mov_bbb.mp4",
|
||||
"https://www.w3schools.com/html/movie.mp4"
|
||||
];
|
||||
const random = mock[Math.floor(Math.random() * mock.length)];
|
||||
setVideoUrl(random);
|
||||
}}
|
||||
>
|
||||
{videoUrl ? (
|
||||
<video src={videoUrl} controls className="object-contain rounded-xl h-24 w-40 mx-auto my-auto" />
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="h-8 w-8 mb-2 text-gray-400 mx-auto" />
|
||||
<span className="text-sm text-gray-500">上传视频</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{videoUrl && (
|
||||
<Button type="button" variant="destructive" size="sm" className="h-8 px-2 rounded-lg" onClick={() => setVideoUrl("")}>删除</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">支持MP4,建议不超过20MB</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 素材上传分组(仅图片类型和小程序类型) */}
|
||||
{(materialType === 1 || materialType === 5) && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">素材上传</div>
|
||||
{materialType === 1 && (
|
||||
<>
|
||||
<Label className="font-bold mb-2">素材</Label>
|
||||
<div className="border border-dashed border-gray-300 rounded-2xl p-4 text-center bg-gray-50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleUploadImage}
|
||||
className="w-full py-8 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
|
||||
disabled={loading || images.length >= 9}
|
||||
>
|
||||
<UploadCloud className="h-8 w-8 mb-2 text-gray-400" />
|
||||
<span>点击上传图片</span>
|
||||
<span className="text-xs text-gray-500 mt-1">{`已上传${images.length}张,最多可上传9张`}</span>
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
{previewUrls.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<Label className="font-bold mb-2">已上传图片</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-2">
|
||||
{previewUrls.map((url, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<div className="aspect-square relative rounded-2xl overflow-hidden border border-gray-200">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`图片 ${index + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0 rounded-full"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{materialType === 5 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="appTitle" className="font-bold mb-2">小程序名称</Label>
|
||||
<Input
|
||||
id="appTitle"
|
||||
placeholder="请输入小程序名称"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="appId" className="font-bold mb-2">AppID</Label>
|
||||
<Input
|
||||
id="appId"
|
||||
placeholder="请输入AppID"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="font-bold mb-2">小程序封面图</Label>
|
||||
<div className="border border-dashed border-gray-300 rounded-2xl p-4 text-center bg-gray-50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = handleUploadImage;
|
||||
input.click();
|
||||
}}
|
||||
className="w-full py-4 flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-blue-300 bg-white hover:bg-blue-50"
|
||||
>
|
||||
<UploadCloud className="h-6 w-6 mb-2 text-gray-400" />
|
||||
<span>上传小程序封面图</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full h-12 rounded-2xl bg-blue-600 hover:bg-blue-700 text-base font-bold mt-12 shadow" disabled={loading}>
|
||||
{loading ? "创建中..." : "保存素材"}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,536 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, use } from "react"
|
||||
import { ChevronLeft, Download, Plus, Search, Tag, Trash2, BarChart, RefreshCw, Image as ImageIcon, Edit } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { api } from "@/lib/api"
|
||||
import { showToast } from "@/lib/toast"
|
||||
import { Avatar } from "@/components/ui/avatar"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { format } from "date-fns"
|
||||
import Image from "next/image"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface MaterialListResponse {
|
||||
list: Material[]
|
||||
total: number
|
||||
}
|
||||
|
||||
interface Material {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
content: string
|
||||
coverImage: string | null
|
||||
resUrls: string[]
|
||||
urls: string[]
|
||||
createTime: string
|
||||
createMomentTime: number
|
||||
time: string
|
||||
wechatId: string
|
||||
friendId: string | null
|
||||
wechatChatroomId: number
|
||||
senderNickname: string
|
||||
senderAvatar: string // 发布朋友圈用户的头像
|
||||
location: string | null
|
||||
lat: string
|
||||
lng: string
|
||||
}
|
||||
|
||||
const isImageUrl = (url: string) => {
|
||||
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url) || url.includes('oss-cn-shenzhen.aliyuncs.com')
|
||||
}
|
||||
|
||||
export default function MaterialsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params)
|
||||
const router = useRouter()
|
||||
const [materials, setMaterials] = useState<Material[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const limit = 20
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState<number | null>(null)
|
||||
|
||||
const fetchMaterials = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
libraryId: resolvedParams.id,
|
||||
...(searchQuery ? { keyword: searchQuery } : {})
|
||||
})
|
||||
const response = await api.get<ApiResponse<MaterialListResponse>>(`/v1/content/library/item-list?${queryParams.toString()}`)
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
setMaterials(response.data.list)
|
||||
setTotal(response.data.total)
|
||||
} else {
|
||||
showToast(response.msg || "获取素材数据失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch materials:", error)
|
||||
showToast(error?.message || "请检查网络连接", "error")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [page, searchQuery, resolvedParams.id])
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials()
|
||||
}, [fetchMaterials])
|
||||
|
||||
const handleDownload = () => {
|
||||
showToast("正在将素材导出为Excel格式", "loading")
|
||||
}
|
||||
|
||||
const handleNewMaterial = () => {
|
||||
router.push(`/content/${resolvedParams.id}/materials/new`)
|
||||
}
|
||||
|
||||
const handleAIAnalysis = async (material: Material) => {
|
||||
try {
|
||||
// 模拟AI分析过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
const analysis = "这是一条" + material.title + "相关的内容,情感倾向积极。"
|
||||
setSelectedMaterial(material)
|
||||
showToast("AI分析完成", "success")
|
||||
} catch (error) {
|
||||
console.error("AI analysis failed:", error)
|
||||
showToast("AI分析失败", "error")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
fetchMaterials()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchMaterials()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const loadingToast = showToast("正在删除...", "loading", true)
|
||||
try {
|
||||
const response = await api.delete<ApiResponse>(`/v1/content/library/delete-item?id=${id}`)
|
||||
if (response.code === 200) {
|
||||
showToast("删除成功", "success")
|
||||
fetchMaterials()
|
||||
} else {
|
||||
showToast(response.msg || "删除失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || "删除失败", "error")
|
||||
} finally {
|
||||
loadingToast.remove && loadingToast.remove()
|
||||
setDeleteDialogOpen(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:根据类型渲染内容
|
||||
const renderMaterialByType = (material: any) => {
|
||||
const type = Number(material.contentType || material.type);
|
||||
// 链接类型
|
||||
if (type === 2 && material.urls && material.urls.length > 0) {
|
||||
const first = material.urls[0];
|
||||
return (
|
||||
<a
|
||||
href={first.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className=" items-center bg-white rounded p-2 hover:bg-gray-50 transition group"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<p className="text-gray-700 whitespace-pre-line">{material.content}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center" style={{ border: '1px solid #ededed' }}>
|
||||
<div className="flex-shrink-0 w-14 h-14 rounded overflow-hidden mr-3 bg-gray-100">
|
||||
<Image
|
||||
src={first.image ?? 'https://api.dicebear.com/7.x/avataaars/svg?seed=123'}
|
||||
alt="封面图"
|
||||
width={56}
|
||||
height={56}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-medium truncate">{first.desc ?? '这是一条链接'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// 视频类型
|
||||
if (type === 3 && material.urls && material.urls.length > 0) {
|
||||
const first = material.urls[0];
|
||||
const videoUrl = typeof first === "string" ? first : (first.url || "");
|
||||
return videoUrl ? (
|
||||
<div className="mb-3">
|
||||
<p className="text-gray-700 whitespace-pre-line">{material.content}</p>
|
||||
<video src={videoUrl} controls className="rounded w-full max-w-md" />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
// 文本类型
|
||||
if (type === 4 || type === 6) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<p className="text-gray-700 whitespace-pre-line">{material.content}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 小程序类型
|
||||
if (type === 5 && material.urls && material.urls.length > 0) {
|
||||
const first = material.urls[0];
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div>小程序名称:{first.appTitle}</div>
|
||||
<div>AppID:{first.appId}</div>
|
||||
{first.image && (
|
||||
<div className="mb-2">
|
||||
<Image src={first.image} alt="小程序封面图" width={80} height={80} className="rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 图片类型
|
||||
if (type === 1) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
{/* 内容字段(如有) */}
|
||||
{material.content && (
|
||||
<div className="mb-2 text-base font-medium text-gray-800 whitespace-pre-line">
|
||||
{material.content}
|
||||
</div>
|
||||
)}
|
||||
{/* 图片资源 */}
|
||||
{renderImageResources(material)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 其它类型
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理图片资源
|
||||
const renderImageResources = (material: Material) => {
|
||||
const imageUrls = material.resUrls.filter(isImageUrl)
|
||||
// 如果内容本身是图片,也添加到图片数组中
|
||||
if (isImageUrl(material.content) && !imageUrls.includes(material.content)) {
|
||||
imageUrls.unshift(material.content)
|
||||
}
|
||||
|
||||
if (imageUrls.length === 0) return null
|
||||
|
||||
// 微信朋友圈风格的图片布局
|
||||
if (imageUrls.length === 1) {
|
||||
// 单张图片:大图显示
|
||||
return (
|
||||
<div className="relative rounded-md overflow-hidden">
|
||||
<Image
|
||||
src={imageUrls[0]}
|
||||
alt="图片内容"
|
||||
width={600}
|
||||
height={400}
|
||||
className="object-cover w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} else if (imageUrls.length === 2) {
|
||||
// 两张图片:横向排列
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{imageUrls.map((url, idx) => (
|
||||
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`图片 ${idx + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (imageUrls.length === 3) {
|
||||
// 三张图片:使用3x3网格的前三个格子
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{imageUrls.map((url, idx) => (
|
||||
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`图片 ${idx + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (imageUrls.length === 4) {
|
||||
// 四张图片:2x2网格
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{imageUrls.map((url, idx) => (
|
||||
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`图片 ${idx + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
// 五张及以上:3x3网格
|
||||
const displayImages = imageUrls.slice(0, 9)
|
||||
const hasMore = imageUrls.length > 9
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{displayImages.map((url, idx) => (
|
||||
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`图片 ${idx + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
{idx === 8 && hasMore && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<span className="text-white text-lg font-medium">+{imageUrls.length - 9}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
fetchMaterials()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-20">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">已采集素材</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button onClick={handleNewMaterial}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建素材
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
<Card className="p-4 mb-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="搜索素材..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
// 加载状态
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 animate-pulse"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded"></div>
|
||||
<div className="h-3 w-16 bg-gray-200 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-3 h-0.5 bg-gray-100"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-full bg-gray-200 animate-pulse rounded"></div>
|
||||
<div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded"></div>
|
||||
<div className="flex space-x-2 mt-3">
|
||||
<div className="h-20 w-20 bg-gray-200 animate-pulse rounded"></div>
|
||||
<div className="h-20 w-20 bg-gray-200 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 素材列表
|
||||
<div className="space-y-4">
|
||||
{materials.length === 0 ? (
|
||||
<Card className="p-8 text-center text-gray-500">
|
||||
暂无素材数据
|
||||
</Card>
|
||||
) : (
|
||||
materials.map(material => (
|
||||
<Card key={material.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar>
|
||||
<Image
|
||||
src={material.senderAvatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${material.senderNickname}`}
|
||||
alt={material.senderNickname}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{material.senderNickname}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{material.time && format(new Date(material.time), 'yyyy-MM-dd HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-blue-50">
|
||||
ID: {material.id}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
{/* 类型分发内容渲染 */}
|
||||
{renderMaterialByType(material)}
|
||||
|
||||
{/* 非图片资源标签 */}
|
||||
{material.resUrls.length > 0 && !material.resUrls.some(isImageUrl) && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{material.resUrls.map((url, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Tag className="h-3 w-3 mr-1" />
|
||||
资源 {index + 1}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="px-3 h-8 text-xs"
|
||||
onClick={() => router.push(`/content/${resolvedParams.id}/materials/edit/${material.id}`)}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="px-3 h-8 text-xs">
|
||||
<BarChart className="h-4 w-4 mr-1" />
|
||||
AI分析
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 分析结果</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<p>正在分析中...</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Dialog open={deleteDialogOpen === material.id} onOpenChange={(open) => setDeleteDialogOpen(open ? material.id : null)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" className="px-3 h-8 text-xs">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 mb-4 text-sm text-gray-700">确定要删除该素材吗?此操作不可恢复。</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setDeleteDialogOpen(null)}>取消</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => handleDelete(material.id)}>确认删除</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && total > limit && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(prev => Math.max(prev - 1, 1))}
|
||||
className="mx-1"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="mx-4 py-2 text-sm text-gray-500">
|
||||
第 {page} 页,共 {Math.ceil(total / limit)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= Math.ceil(total / limit)}
|
||||
onClick={() => setPage(prev => prev + 1)}
|
||||
className="mx-1"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ChevronLeft, Save } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker"
|
||||
import { WechatFriendSelector } from "@/components/WechatFriendSelector"
|
||||
import { WechatGroupSelector } from "@/components/WechatGroupSelector"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string
|
||||
name: string
|
||||
sourceType: "friends" | "groups"
|
||||
keywordsInclude: string
|
||||
keywordsExclude: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
selectedFriends: any[]
|
||||
selectedGroups: any[]
|
||||
useAI: boolean
|
||||
aiPrompt: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function ContentLibraryPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter()
|
||||
const [library, setLibrary] = useState<ContentLibrary | null>(null)
|
||||
const [isWechatFriendSelectorOpen, setIsWechatFriendSelectorOpen] = useState(false)
|
||||
const [isWechatGroupSelectorOpen, setIsWechatGroupSelectorOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLibrary = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// 模拟从API获取内容库数据
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
const data = {
|
||||
id: params.id,
|
||||
name: "示例内容库",
|
||||
sourceType: "friends",
|
||||
keywordsInclude: "关键词1,关键词2",
|
||||
keywordsExclude: "排除词1,排除词2",
|
||||
startDate: "2024-01-01",
|
||||
endDate: "2024-12-31",
|
||||
selectedFriends: [
|
||||
{ id: "1", nickname: "张三", avatar: "/placeholder.svg?height=40&width=40" },
|
||||
{ id: "2", nickname: "李四", avatar: "/placeholder.svg?height=40&width=40" },
|
||||
],
|
||||
selectedGroups: [],
|
||||
useAI: true,
|
||||
aiPrompt: "AI提示词示例",
|
||||
enabled: true,
|
||||
}
|
||||
setLibrary(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch library data:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "获取内容库数据失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
fetchLibrary()
|
||||
}, [params.id])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!library) return
|
||||
try {
|
||||
// 模拟保存到API
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
toast({
|
||||
title: "成功",
|
||||
description: "内容库已保存",
|
||||
})
|
||||
// 这里应该调用一个函数来更新外部展示的数据
|
||||
// updateExternalDisplay(library)
|
||||
} catch (error) {
|
||||
console.error("Failed to save library:", error)
|
||||
toast({
|
||||
title: "错误",
|
||||
description: "保存内容库失败",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center items-center h-screen">加载中...</div>
|
||||
}
|
||||
|
||||
if (!library) {
|
||||
return <div className="flex justify-center items-center h-screen">内容库不存在</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">内容库详情</h1>
|
||||
</div>
|
||||
<Button onClick={handleSave}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-base required">
|
||||
内容库名称
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={library.name}
|
||||
onChange={(e) => setLibrary({ ...library, name: e.target.value })}
|
||||
placeholder="请输入内容库名称"
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">数据来源配置</Label>
|
||||
<Tabs
|
||||
value={library.sourceType}
|
||||
onValueChange={(value: "friends" | "groups") => setLibrary({ ...library, sourceType: value })}
|
||||
className="mt-1.5"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="friends">选择微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">选择聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="friends" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatFriendSelectorOpen(true)}>
|
||||
选择微信好友
|
||||
</Button>
|
||||
{library.selectedFriends.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{library.selectedFriends.map((friend) => (
|
||||
<div key={friend.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<span>{friend.nickname}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="groups" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatGroupSelectorOpen(true)}>
|
||||
选择聊天群
|
||||
</Button>
|
||||
{library.selectedGroups.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{library.selectedGroups.map((group) => (
|
||||
<div key={group.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="keywords">
|
||||
<AccordionTrigger>关键字设置</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="keywordsInclude" className="text-base">
|
||||
关键字匹配
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsInclude"
|
||||
value={library.keywordsInclude}
|
||||
onChange={(e) => setLibrary({ ...library, keywordsInclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="keywordsExclude" className="text-base">
|
||||
关键字排除
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsExclude"
|
||||
value={library.keywordsExclude}
|
||||
onChange={(e) => setLibrary({ ...library, keywordsExclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,匹配到关键字的,系统将不会采集。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base">是否启用AI</Label>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
当启用AI之后,该内容库下的所有内容,都会通过AI重新生成内容。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={library.useAI}
|
||||
onCheckedChange={(checked) => setLibrary({ ...library, useAI: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{library.useAI && (
|
||||
<div>
|
||||
<Label htmlFor="aiPrompt" className="text-base">
|
||||
AI 提示词
|
||||
</Label>
|
||||
<Textarea
|
||||
id="aiPrompt"
|
||||
value={library.aiPrompt}
|
||||
onChange={(e) => setLibrary({ ...library, aiPrompt: e.target.value })}
|
||||
placeholder="请输入 AI 提示词"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-base">时间限制</Label>
|
||||
<DateRangePicker
|
||||
className="mt-1.5"
|
||||
onChange={(range) => {
|
||||
if (range?.from) {
|
||||
setLibrary({
|
||||
...library,
|
||||
startDate: range.from.toISOString(),
|
||||
endDate: range.to?.toISOString() || "",
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base required">是否启用</Label>
|
||||
<Switch
|
||||
checked={library.enabled}
|
||||
onCheckedChange={(checked) => setLibrary({ ...library, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<WechatFriendSelector
|
||||
open={isWechatFriendSelectorOpen}
|
||||
onOpenChange={setIsWechatFriendSelectorOpen}
|
||||
selectedFriends={library.selectedFriends}
|
||||
onSelect={(friends) => setLibrary({ ...library, selectedFriends: friends })}
|
||||
/>
|
||||
|
||||
<WechatGroupSelector
|
||||
open={isWechatGroupSelectorOpen}
|
||||
onOpenChange={setIsWechatGroupSelectorOpen}
|
||||
selectedGroups={library.selectedGroups}
|
||||
onSelect={(groups) => setLibrary({ ...library, selectedGroups: groups })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Search, Plus } from "lucide-react"
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
name: string
|
||||
account: string
|
||||
status: "online" | "offline"
|
||||
}
|
||||
|
||||
const mockDevices: Device[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "iPhone 13 Pro",
|
||||
account: "wxid_abc123",
|
||||
status: "online",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Huawei P40",
|
||||
account: "wxid_xyz789",
|
||||
status: "offline",
|
||||
},
|
||||
]
|
||||
|
||||
interface DeviceSelectorProps {
|
||||
selectedDevices: string[]
|
||||
onChange: (devices: string[]) => void
|
||||
}
|
||||
|
||||
export function DeviceSelector({ selectedDevices, onChange }: DeviceSelectorProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedDevices.length === mockDevices.length) {
|
||||
onChange([])
|
||||
} else {
|
||||
onChange(mockDevices.map((device) => device.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDevice = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onChange(selectedDevices.filter((id) => id !== deviceId))
|
||||
} else {
|
||||
onChange([...selectedDevices, deviceId])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索设备"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button className="ml-2" size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加设备
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox checked={selectedDevices.length === mockDevices.length} onCheckedChange={toggleSelectAll} />
|
||||
</TableHead>
|
||||
<TableHead>设备名称</TableHead>
|
||||
<TableHead>微信账号</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mockDevices.map((device) => (
|
||||
<TableRow key={device.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onCheckedChange={() => toggleDevice(device.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{device.name}</TableCell>
|
||||
<TableCell>{device.account}</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>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronLeft, X, Users } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker"
|
||||
import { WechatFriendSelector } from "@/components/WechatFriendSelector"
|
||||
import { WechatGroupSelector } from "@/components/WechatGroupSelector"
|
||||
import { WechatGroupMemberSelector } from "@/components/WechatGroupMemberSelector"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { api } from "@/lib/api"
|
||||
import { showToast } from "@/lib/toast"
|
||||
import { format } from "date-fns"
|
||||
import { zhCN } from "date-fns/locale"
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { WechatFriend, WechatGroup, WechatGroupMember } from "@/types/wechat"
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export default function NewContentLibraryPage() {
|
||||
const router = useRouter()
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
sourceType: "friends" as "friends" | "groups",
|
||||
keywordsInclude: "",
|
||||
keywordsExclude: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
selectedFriends: [] as WechatFriend[],
|
||||
selectedGroups: [] as WechatGroup[],
|
||||
selectedGroupMembers: [] as WechatGroupMember[],
|
||||
useAI: false,
|
||||
aiPrompt: "",
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const [isWechatFriendSelectorOpen, setIsWechatFriendSelectorOpen] = useState(false)
|
||||
const [isWechatGroupSelectorOpen, setIsWechatGroupSelectorOpen] = useState(false)
|
||||
const [isWechatGroupMemberSelectorOpen, setIsWechatGroupMemberSelectorOpen] = useState(false)
|
||||
const [currentGroupId, setCurrentGroupId] = useState<string>("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const removeFriend = (friendId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
selectedFriends: prev.selectedFriends.filter((friend) => friend.id !== friendId),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeGroup = (groupId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
selectedGroups: prev.selectedGroups.filter((group) => group.id !== groupId),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectGroupMembers = (groupId: string) => {
|
||||
setCurrentGroupId(groupId)
|
||||
setIsWechatGroupMemberSelectorOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name) {
|
||||
showToast("请输入内容库名称", "error")
|
||||
return
|
||||
}
|
||||
|
||||
// if (formData.sourceType === "friends" && formData.selectedFriends.length === 0) {
|
||||
// showToast("请选择微信好友", "error")
|
||||
// return
|
||||
// }
|
||||
|
||||
// if (formData.sourceType === "groups" && formData.selectedGroups.length === 0) {
|
||||
// showToast("请选择聊天群", "error")
|
||||
// return
|
||||
// }
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 将群成员数据转换为{"群ID":[成员ID1, 成员ID2...]}的格式
|
||||
const groupMembersMap: Record<string, string[]> = {}
|
||||
|
||||
formData.selectedGroupMembers.forEach(member => {
|
||||
if (member.groupId) {
|
||||
if (!groupMembersMap[member.groupId]) {
|
||||
groupMembersMap[member.groupId] = []
|
||||
}
|
||||
groupMembersMap[member.groupId].push(member.id)
|
||||
}
|
||||
})
|
||||
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
sourceType: formData.sourceType === "friends" ? 1 : 2,
|
||||
friends: formData.selectedFriends.map(f => f.id),
|
||||
groups: formData.selectedGroups.map(g => g.id),
|
||||
groupMembers: groupMembersMap, // 使用JSON字符串传递群成员数据
|
||||
keywordInclude: formData.keywordsInclude.split(",").map(k => k.trim()).filter(Boolean),
|
||||
keywordExclude: formData.keywordsExclude.split(",").map(k => k.trim()).filter(Boolean),
|
||||
aiPrompt: formData.useAI ? formData.aiPrompt : "",
|
||||
timeEnabled: formData.startDate && formData.endDate ? 1 : 0,
|
||||
startTime: formData.startDate ? format(new Date(formData.startDate), "yyyy-MM-dd") : "",
|
||||
endTime: formData.endDate ? format(new Date(formData.endDate), "yyyy-MM-dd") : "",
|
||||
status: formData.enabled ? 1 : 0
|
||||
}
|
||||
|
||||
const response = await api.post<ApiResponse>("/v1/content/library/create", payload)
|
||||
|
||||
if (response.code === 200) {
|
||||
showToast("创建成功", "success")
|
||||
router.push("/content")
|
||||
} else {
|
||||
showToast(response.msg || "创建失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("创建内容库失败:", error)
|
||||
showToast(error?.message || "请检查网络连接", "error")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center p-4">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-base required">
|
||||
内容库名称
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="请输入内容库名称"
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-base">数据来源配置</Label>
|
||||
<Tabs
|
||||
value={formData.sourceType}
|
||||
onValueChange={(value) => setFormData({ ...formData, sourceType: value as "friends" | "groups" })}
|
||||
className="mt-1.5"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="friends">选择微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">选择聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="friends" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatFriendSelectorOpen(true)}>
|
||||
选择微信好友
|
||||
</Button>
|
||||
{formData.selectedFriends.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{formData.selectedFriends.map((friend) => (
|
||||
<div key={friend.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={friend.avatar || "/placeholder.svg"}
|
||||
alt={friend.nickname}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span>{friend.nickname}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeFriend(friend.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="groups" className="mt-4">
|
||||
<Button variant="outline" className="w-full" onClick={() => setIsWechatGroupSelectorOpen(true)}>
|
||||
选择聊天群
|
||||
</Button>
|
||||
{formData.selectedGroups.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{formData.selectedGroups.map((group) => (
|
||||
<div key={group.id} className="flex items-center justify-between bg-gray-100 p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<img
|
||||
src={group.avatar || "/placeholder.svg"}
|
||||
alt={group.name}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span className="truncate max-w-[220px]">{group.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSelectGroupMembers(group.id)}
|
||||
className={`mr-1 ${formData.selectedGroupMembers.some(m => m.groupId === group.id) ? 'text-blue-500' : ''}`}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{formData.selectedGroupMembers.some(m => m.groupId === group.id) && (
|
||||
<span className="ml-1 text-xs">
|
||||
+{formData.selectedGroupMembers.filter(m => m.groupId === group.id).length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeGroup(group.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="keywords">
|
||||
<AccordionTrigger>关键字设置</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="keywordsInclude" className="text-base">
|
||||
关键字匹配
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsInclude"
|
||||
value={formData.keywordsInclude}
|
||||
onChange={(e) => setFormData({ ...formData, keywordsInclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="keywordsExclude" className="text-base">
|
||||
关键字排除
|
||||
</Label>
|
||||
<Textarea
|
||||
id="keywordsExclude"
|
||||
value={formData.keywordsExclude}
|
||||
onChange={(e) => setFormData({ ...formData, keywordsExclude: e.target.value })}
|
||||
placeholder="如果设置了关键字,匹配到关键字的,系统将不会采集。多个关键字,用半角的','隔开。"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base">是否启用AI</Label>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
当启用AI之后,该内容库下的所有内容,都会通过AI重新生成内容。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.useAI}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, useAI: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.useAI && (
|
||||
<div>
|
||||
<Label htmlFor="aiPrompt" className="text-base">
|
||||
AI 提示词
|
||||
</Label>
|
||||
<Textarea
|
||||
id="aiPrompt"
|
||||
value={formData.aiPrompt}
|
||||
onChange={(e) => setFormData({ ...formData, aiPrompt: e.target.value })}
|
||||
placeholder="请输入 AI 提示词"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-base">时间限制</Label>
|
||||
<DateRangePicker
|
||||
className="mt-1.5"
|
||||
onChange={(range) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
startDate: range?.from ? format(range.from, "yyyy-MM-dd") : "",
|
||||
endDate: range?.to ? format(range.to, "yyyy-MM-dd") : "",
|
||||
})
|
||||
}}
|
||||
value={formData.startDate && formData.endDate ? {
|
||||
from: new Date(formData.startDate),
|
||||
to: new Date(formData.endDate)
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base required">是否启用</Label>
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatFriendSelector
|
||||
open={isWechatFriendSelectorOpen}
|
||||
onOpenChange={setIsWechatFriendSelectorOpen}
|
||||
selectedFriends={formData.selectedFriends}
|
||||
onSelect={(friends) => setFormData({ ...formData, selectedFriends: friends })}
|
||||
/>
|
||||
|
||||
<WechatGroupSelector
|
||||
open={isWechatGroupSelectorOpen}
|
||||
onOpenChange={setIsWechatGroupSelectorOpen}
|
||||
selectedGroups={formData.selectedGroups}
|
||||
onSelect={(groups) => setFormData({ ...formData, selectedGroups: groups })}
|
||||
/>
|
||||
|
||||
<WechatGroupMemberSelector
|
||||
open={isWechatGroupMemberSelectorOpen}
|
||||
onOpenChange={setIsWechatGroupMemberSelectorOpen}
|
||||
groupId={currentGroupId}
|
||||
selectedMembers={formData.selectedGroupMembers}
|
||||
onSelect={(members) => setFormData({ ...formData, selectedGroupMembers: members })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { ChevronLeft, Filter, Search, RefreshCw, Plus, Edit, Trash2, Eye, MoreVertical, Users } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import Image from "next/image"
|
||||
import { api } from "@/lib/api"
|
||||
import { showToast } from "@/lib/toast"
|
||||
import { WechatGroupMemberSelector } from "@/components/WechatGroupMemberSelector"
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number
|
||||
msg: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface LibraryListResponse {
|
||||
list: ContentLibrary[]
|
||||
total: number
|
||||
}
|
||||
|
||||
interface WechatGroupMember {
|
||||
id: string
|
||||
nickname: string
|
||||
wechatId: string
|
||||
avatar: string
|
||||
gender?: "male" | "female"
|
||||
role?: "owner" | "admin" | "member"
|
||||
joinTime?: string
|
||||
}
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string
|
||||
name: string
|
||||
source: "friends" | "groups"
|
||||
targetAudience: {
|
||||
id: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
}[]
|
||||
creator: string
|
||||
creatorName?: string
|
||||
itemCount: number
|
||||
lastUpdated: string
|
||||
enabled: boolean
|
||||
// 新增字段
|
||||
sourceFriends: string[]
|
||||
sourceGroups: string[]
|
||||
friendsData?: any[]
|
||||
groupsData?: any[]
|
||||
keywordInclude: string[]
|
||||
keywordExclude: string[]
|
||||
isEnabled: number
|
||||
aiPrompt: string
|
||||
timeEnabled: number
|
||||
timeStart: string
|
||||
timeEnd: string
|
||||
status: number
|
||||
createTime: string
|
||||
updateTime: string
|
||||
sourceType: number
|
||||
selectedGroupMembers?: WechatGroupMember[]
|
||||
}
|
||||
|
||||
export default function ContentLibraryPage() {
|
||||
const router = useRouter()
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [activeTab, setActiveTab] = useState("all")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isGroupMemberSelectorOpen, setIsGroupMemberSelectorOpen] = useState(false)
|
||||
const [currentGroupId, setCurrentGroupId] = useState<string>("")
|
||||
const [selectedGroupMembers, setSelectedGroupMembers] = useState<WechatGroupMember[]>([])
|
||||
|
||||
// 获取内容库列表
|
||||
const fetchLibraries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: '1',
|
||||
limit: '100',
|
||||
...(searchQuery ? { keyword: searchQuery } : {}),
|
||||
...(activeTab !== 'all' ? { sourceType: activeTab === 'friends' ? '1' : '2' } : {})
|
||||
})
|
||||
const response = await api.get<ApiResponse<LibraryListResponse>>(`/v1/content/library/list?${queryParams.toString()}`)
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 转换数据格式以匹配原有UI
|
||||
const transformedLibraries = response.data.list.map((item: any) => {
|
||||
// 提取好友数据,确保有头像
|
||||
const friendsData = Array.isArray(item.selectedFriends) ? item.selectedFriends : [];
|
||||
const groupsData = Array.isArray(item.selectedGroups) ? item.selectedGroups : [];
|
||||
|
||||
const transformedItem: ContentLibrary = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
source: item.sourceType === 1 ? "friends" : "groups",
|
||||
targetAudience: [
|
||||
...friendsData.map((friend: any) => ({
|
||||
id: friend.id,
|
||||
nickname: friend.nickname || `好友${friend.id}`,
|
||||
avatar: friend.avatar || "/placeholder.svg"
|
||||
})),
|
||||
...groupsData.map((group: any) => ({
|
||||
id: group.id,
|
||||
nickname: group.name || `群组${group.id}`,
|
||||
avatar: group.avatar || "/placeholder.svg"
|
||||
}))
|
||||
],
|
||||
creator: item.creatorName || "系统",
|
||||
creatorName: item.creatorName,
|
||||
itemCount: item.itemCount,
|
||||
lastUpdated: item.updateTime,
|
||||
enabled: item.isEnabled === 1,
|
||||
// 新增字段
|
||||
sourceFriends: item.sourceFriends || [],
|
||||
sourceGroups: item.sourceGroups || [],
|
||||
friendsData: friendsData,
|
||||
groupsData: groupsData,
|
||||
keywordInclude: item.keywordInclude || [],
|
||||
keywordExclude: item.keywordExclude || [],
|
||||
isEnabled: item.isEnabled,
|
||||
aiPrompt: item.aiPrompt || '',
|
||||
timeEnabled: item.timeEnabled,
|
||||
timeStart: item.timeStart || '',
|
||||
timeEnd: item.timeEnd || '',
|
||||
status: item.status,
|
||||
createTime: item.createTime,
|
||||
updateTime: item.updateTime,
|
||||
sourceType: item.sourceType,
|
||||
selectedGroupMembers: item.selectedGroupMembers || []
|
||||
}
|
||||
return transformedItem
|
||||
})
|
||||
setLibraries(transformedLibraries)
|
||||
} else {
|
||||
showToast(response.msg || "获取内容库列表失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("获取内容库列表失败:", error)
|
||||
showToast(error?.message || "请检查网络连接", "error")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchQuery, activeTab])
|
||||
|
||||
// 首次加载和搜索条件变化时获取列表
|
||||
useEffect(() => {
|
||||
fetchLibraries()
|
||||
}, [searchQuery, activeTab, fetchLibraries])
|
||||
|
||||
const handleCreateNew = () => {
|
||||
router.push('/content/new')
|
||||
}
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
router.push(`/content/${id}/edit`)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const response = await api.delete<ApiResponse>(`/v1/content/library/delete?id=${id}`)
|
||||
if (response.code === 200) {
|
||||
showToast("删除成功", "success")
|
||||
fetchLibraries()
|
||||
} else {
|
||||
showToast(response.msg || "删除失败", "error")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("删除内容库失败:", error)
|
||||
showToast(error?.message || "请检查网络连接", "error")
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewMaterials = (id: string) => {
|
||||
router.push(`/content/${id}/materials`)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchLibraries()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLibraries()
|
||||
}
|
||||
|
||||
const handleSelectGroupMembers = (groupId: string) => {
|
||||
setCurrentGroupId(groupId)
|
||||
setSelectedGroupMembers([])
|
||||
setIsGroupMemberSelectorOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveSelectedMembers = (members: WechatGroupMember[]) => {
|
||||
setSelectedGroupMembers(members)
|
||||
showToast(`已选择 ${members.length} 名群成员`, "success")
|
||||
}
|
||||
|
||||
const filteredLibraries = libraries.filter(
|
||||
(library) =>
|
||||
library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
library.targetAudience.some((target) => target.nickname.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">内容库</h1>
|
||||
</div>
|
||||
<Button onClick={() => router.push('/content/new')}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<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="搜索内容库..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={handleSearch}>
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="all">全部</TabsTrigger>
|
||||
<TabsTrigger value="friends">微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<RefreshCw className="h-8 w-8 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
) : filteredLibraries.length === 0 ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 mb-4">暂无数据</p>
|
||||
<Button onClick={() => router.push('/content/new')} size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建内容库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredLibraries.map((library) => (
|
||||
<Card key={library.id} className="p-4 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium text-base">{library.name}</h3>
|
||||
<Badge variant={library.isEnabled === 1 ? "default" : "secondary"} className="text-xs">
|
||||
已启用
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 space-y-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>来源:</span>
|
||||
{library.sourceType === 1 && library.sourceFriends?.length > 0 ? (
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{(library.friendsData || []).slice(0, 3).map((friend) => (
|
||||
<img
|
||||
key={friend.id}
|
||||
src={friend.avatar || "/placeholder.svg"}
|
||||
alt={friend.nickname || `好友${friend.id}`}
|
||||
className="inline-block h-6 w-6 rounded-full ring-2 ring-white"
|
||||
/>
|
||||
))}
|
||||
{library.sourceFriends.length > 3 && (
|
||||
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-gray-200 text-xs font-medium text-gray-800">
|
||||
+{library.sourceFriends.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : library.sourceType === 2 && library.sourceGroups?.length > 0 ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{(library.groupsData || []).slice(0, 3).map((group) => (
|
||||
<img
|
||||
key={group.id}
|
||||
src={group.avatar || "/placeholder.svg"}
|
||||
alt={group.name || `群组${group.id}`}
|
||||
className="inline-block h-6 w-6 rounded-full ring-2 ring-white"
|
||||
/>
|
||||
))}
|
||||
{library.sourceGroups.length > 3 && (
|
||||
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-gray-200 text-xs font-medium text-gray-800">
|
||||
+{library.sourceGroups.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-6 h-6 bg-gray-200 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
<div>创建人:{library.creator}</div>
|
||||
<div>内容数量:{library.itemCount}</div>
|
||||
<div>更新时间:{new Date(library.updateTime).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(library.id)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDelete(library.id)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleViewMaterials(library.id)}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
查看素材
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<WechatGroupMemberSelector
|
||||
open={isGroupMemberSelectorOpen}
|
||||
onOpenChange={setIsGroupMemberSelectorOpen}
|
||||
groupId={currentGroupId}
|
||||
selectedMembers={selectedGroupMembers}
|
||||
onSelect={handleSaveSelectedMembers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,999 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeft, Smartphone, Battery, Wifi, MessageCircle, Users, Settings, History, RefreshCw, Loader2 } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { fetchDeviceDetail, fetchDeviceRelatedAccounts, updateDeviceTaskConfig, fetchDeviceHandleLogs } from "@/api/devices"
|
||||
import { toast } from "sonner"
|
||||
import { ImeiDisplay } from "@/components/ImeiDisplay"
|
||||
import { api } from "@/lib/api"
|
||||
|
||||
interface WechatAccount {
|
||||
id: string
|
||||
avatar: string
|
||||
nickname: string
|
||||
wechatId: string
|
||||
gender: number
|
||||
status: number
|
||||
statusText: string
|
||||
wechatAlive: number
|
||||
wechatAliveText: string
|
||||
addFriendStatus: number
|
||||
totalFriend: number
|
||||
lastActive: string
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string
|
||||
imei: string
|
||||
name: string
|
||||
status: "online" | "offline"
|
||||
battery: number
|
||||
lastActive: string
|
||||
historicalIds: string[]
|
||||
wechatAccounts: WechatAccount[]
|
||||
features: {
|
||||
autoAddFriend: boolean
|
||||
autoReply: boolean
|
||||
momentsSync: boolean
|
||||
aiChat: boolean
|
||||
}
|
||||
history: {
|
||||
time: string
|
||||
action: string
|
||||
operator: string
|
||||
}[]
|
||||
totalFriend: number
|
||||
thirtyDayMsgCount: number
|
||||
}
|
||||
|
||||
// 这个helper函数用于获取Badge变体类型
|
||||
function getBadgeVariant(status: string): "default" | "destructive" | "outline" | "secondary" {
|
||||
if (status === "online" || status === "normal") {
|
||||
return "default"
|
||||
} else if (status === "abnormal") {
|
||||
return "destructive"
|
||||
} else if (status === "enabled") {
|
||||
return "outline"
|
||||
} else {
|
||||
return "secondary"
|
||||
}
|
||||
}
|
||||
|
||||
// 添加操作记录接口
|
||||
interface HandleLog {
|
||||
id: string | number;
|
||||
content: string; // 操作说明
|
||||
username: string; // 操作人
|
||||
createTime: string; // 操作时间
|
||||
}
|
||||
|
||||
export default function DeviceDetailPage() {
|
||||
const params = useParams()
|
||||
const deviceId = params?.id as string
|
||||
const router = useRouter()
|
||||
const [device, setDevice] = useState<Device | null>(null)
|
||||
const [activeTab, setActiveTab] = useState("info")
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accountsLoading, setAccountsLoading] = useState(false)
|
||||
const [logsLoading, setLogsLoading] = useState(false)
|
||||
const [handleLogs, setHandleLogs] = useState<HandleLog[]>([])
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
const [hasMoreLogs, setHasMoreLogs] = useState(true)
|
||||
const logsPerPage = 10
|
||||
const logsEndRef = useRef<HTMLDivElement>(null)
|
||||
const [savingFeatures, setSavingFeatures] = useState({
|
||||
autoAddFriend: false,
|
||||
autoReply: false,
|
||||
momentsSync: false,
|
||||
aiChat: false
|
||||
})
|
||||
const [tabChangeLoading, setTabChangeLoading] = useState(false)
|
||||
const [accountPage, setAccountPage] = useState(1)
|
||||
const [hasMoreAccounts, setHasMoreAccounts] = useState(true)
|
||||
const accountsPerPage = 10
|
||||
const accountsEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 添加登录检查
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`)
|
||||
return
|
||||
}
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) return
|
||||
|
||||
const fetchDevice = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetchDeviceDetail(deviceId)
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const serverData = response.data
|
||||
|
||||
// 构建符合前端期望格式的设备对象
|
||||
const formattedDevice: Device = {
|
||||
id: serverData.id?.toString() || "",
|
||||
imei: serverData.imei || "",
|
||||
name: serverData.memo || "未命名设备",
|
||||
status: serverData.alive === 1 ? "online" : "offline",
|
||||
battery: serverData.battery || 0,
|
||||
lastActive: serverData.lastUpdateTime || new Date().toISOString(),
|
||||
historicalIds: [], // 服务端暂无此数据
|
||||
wechatAccounts: [], // 默认空数组
|
||||
history: [], // 服务端暂无此数据
|
||||
features: {
|
||||
autoAddFriend: false,
|
||||
autoReply: false,
|
||||
momentsSync: false,
|
||||
aiChat: false
|
||||
},
|
||||
totalFriend: serverData.totalFriend || 0,
|
||||
thirtyDayMsgCount: serverData.thirtyDayMsgCount || 0
|
||||
}
|
||||
|
||||
// 解析features
|
||||
if (serverData.features) {
|
||||
formattedDevice.features = {
|
||||
autoAddFriend: Boolean(serverData.features.autoAddFriend),
|
||||
autoReply: Boolean(serverData.features.autoReply),
|
||||
momentsSync: Boolean(serverData.features.momentsSync || serverData.features.contentSync),
|
||||
aiChat: Boolean(serverData.features.aiChat)
|
||||
}
|
||||
} else if (serverData.taskConfig) {
|
||||
try {
|
||||
const taskConfig = JSON.parse(serverData.taskConfig || '{}');
|
||||
|
||||
if (taskConfig) {
|
||||
formattedDevice.features = {
|
||||
autoAddFriend: Boolean(taskConfig.autoAddFriend),
|
||||
autoReply: Boolean(taskConfig.autoReply),
|
||||
momentsSync: Boolean(taskConfig.momentsSync),
|
||||
aiChat: Boolean(taskConfig.aiChat)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('解析taskConfig失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
setDevice(formattedDevice)
|
||||
|
||||
// 如果当前激活标签是"accounts",则立即加载关联微信账号
|
||||
if (activeTab === "accounts") {
|
||||
fetchRelatedAccounts()
|
||||
}
|
||||
} else {
|
||||
toast.error("获取设备信息失败: " + ((response as any)?.msg || "未知错误"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备信息失败:", error)
|
||||
toast.error("获取设备信息出错,请稍后重试")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDevice()
|
||||
}, [deviceId]) // 使用 deviceId 替代 params.id
|
||||
|
||||
// 获取设备关联微信账号
|
||||
const fetchRelatedAccounts = async (page = 1) => {
|
||||
if (!deviceId || accountsLoading) return
|
||||
|
||||
try {
|
||||
setAccountsLoading(true)
|
||||
const response = await fetchDeviceRelatedAccounts(deviceId)
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const accounts = response.data.accounts || []
|
||||
|
||||
// 更新设备的微信账号信息
|
||||
setDevice(prev => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
// 如果是第一页,替换数据;否则追加数据
|
||||
wechatAccounts: page === 1
|
||||
? accounts
|
||||
: [...(prev.wechatAccounts || []), ...accounts]
|
||||
}
|
||||
})
|
||||
|
||||
// 判断是否还有更多数据
|
||||
setHasMoreAccounts(accounts.length === accountsPerPage)
|
||||
} else {
|
||||
console.error("获取关联微信账号失败")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取关联微信账号失败:", error)
|
||||
} finally {
|
||||
setAccountsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备操作记录
|
||||
const fetchHandleLogs = async () => {
|
||||
if (!deviceId || logsLoading) return
|
||||
|
||||
try {
|
||||
setLogsLoading(true)
|
||||
const response = await fetchDeviceHandleLogs(
|
||||
deviceId,
|
||||
logPage,
|
||||
logsPerPage
|
||||
)
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const logs = response.data.list || []
|
||||
|
||||
// 如果是第一页,替换数据;否则追加数据
|
||||
if (logPage === 1) {
|
||||
setHandleLogs(logs)
|
||||
} else {
|
||||
setHandleLogs(prev => [...prev, ...logs])
|
||||
}
|
||||
|
||||
// 判断是否还有更多数据
|
||||
setHasMoreLogs(logs.length === logsPerPage)
|
||||
|
||||
if (logs.length > 0) {
|
||||
console.log('获取到操作记录:', logs.length)
|
||||
} else {
|
||||
console.log('设备暂无操作记录')
|
||||
}
|
||||
} else {
|
||||
toast.error("获取操作记录失败")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取操作记录失败:", error)
|
||||
toast.error("获取操作记录失败,请稍后重试")
|
||||
} finally {
|
||||
setLogsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多日志
|
||||
const loadMoreLogs = () => {
|
||||
if (logsLoading || !hasMoreLogs) return
|
||||
|
||||
setLogPage(prevPage => prevPage + 1)
|
||||
}
|
||||
|
||||
// 监听滚动加载更多
|
||||
useEffect(() => {
|
||||
if (activeTab !== "history") return
|
||||
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1
|
||||
}
|
||||
|
||||
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
|
||||
const [entry] = entries
|
||||
if (entry.isIntersecting && hasMoreLogs && !logsLoading) {
|
||||
loadMoreLogs()
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(handleIntersect, observerOptions)
|
||||
|
||||
if (logsEndRef.current) {
|
||||
observer.observe(logsEndRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (logsEndRef.current) {
|
||||
observer.unobserve(logsEndRef.current)
|
||||
}
|
||||
}
|
||||
}, [activeTab, hasMoreLogs, logsLoading])
|
||||
|
||||
// 当切换到日志标签时重置页码
|
||||
useEffect(() => {
|
||||
if (activeTab === "history") {
|
||||
setLogPage(1)
|
||||
setHasMoreLogs(true)
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
// 观察logPage变化加载数据
|
||||
useEffect(() => {
|
||||
if (activeTab === "history") {
|
||||
fetchHandleLogs()
|
||||
}
|
||||
}, [logPage, activeTab])
|
||||
|
||||
// 获取任务配置
|
||||
const fetchTaskConfig = async () => {
|
||||
try {
|
||||
const response = await api.get(`/v1/devices/${deviceId}/task-config`)
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
setDevice(prev => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
features: {
|
||||
autoAddFriend: Boolean(response.data.autoAddFriend),
|
||||
autoReply: Boolean(response.data.autoReply),
|
||||
momentsSync: Boolean(response.data.momentsSync),
|
||||
aiChat: Boolean(response.data.aiChat)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取任务配置失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// 在组件加载时获取任务配置
|
||||
useEffect(() => {
|
||||
if (deviceId) {
|
||||
fetchTaskConfig()
|
||||
}
|
||||
}, [deviceId])
|
||||
|
||||
// 处理标签页切换
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value)
|
||||
|
||||
// 显示过渡加载状态
|
||||
setTabChangeLoading(true)
|
||||
|
||||
// 当切换到"关联账号"标签时,获取最新的关联微信账号信息
|
||||
if (value === "accounts") {
|
||||
fetchRelatedAccounts()
|
||||
}
|
||||
|
||||
// 当切换到"操作记录"标签时,获取最新的操作记录
|
||||
if (value === "history") {
|
||||
fetchHandleLogs()
|
||||
}
|
||||
|
||||
// 当切换到"基本信息"标签时,获取最新的任务配置
|
||||
if (value === "info") {
|
||||
fetchTaskConfig()
|
||||
}
|
||||
|
||||
// 设置短暂的延迟来关闭加载状态,模拟加载过程
|
||||
setTimeout(() => {
|
||||
setTabChangeLoading(false)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 处理功能开关状态变化
|
||||
const handleFeatureChange = async (feature: keyof Device['features'], checked: boolean) => {
|
||||
if (!device) return
|
||||
|
||||
// 避免已经在处理中的功能被重复触发
|
||||
if (savingFeatures[feature]) {
|
||||
return
|
||||
}
|
||||
|
||||
setSavingFeatures(prev => ({ ...prev, [feature]: true }))
|
||||
|
||||
try {
|
||||
// 准备更新后的功能状态
|
||||
const updatedFeatures = { ...device.features, [feature]: checked }
|
||||
|
||||
// 创建API请求参数,将布尔值转换为0/1
|
||||
const configUpdate = {
|
||||
deviceId: device.id,
|
||||
[feature]: checked ? 1 : 0
|
||||
}
|
||||
|
||||
// 立即更新UI状态,提供即时反馈
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: updatedFeatures
|
||||
} : null)
|
||||
|
||||
// 使用更安全的API调用方式,避免自动重定向
|
||||
try {
|
||||
// 获取token
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('未找到授权信息');
|
||||
}
|
||||
|
||||
// 直接使用fetch,而不是通过API工具
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/devices/task-config`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(configUpdate)
|
||||
});
|
||||
|
||||
// 检查是否是401错误(未授权),这是唯一应该处理token的情况
|
||||
if (response.status === 401) {
|
||||
// 此处我们不立即跳转,而是给出错误提示
|
||||
toast.error('认证已过期,请重新登录后再尝试操作');
|
||||
console.error('API请求返回401未授权错误');
|
||||
// 可以选择是否重定向到登录页面
|
||||
// window.location.href = '/login';
|
||||
throw new Error('认证已过期');
|
||||
}
|
||||
|
||||
// 检查响应是否正常
|
||||
if (!response.ok) {
|
||||
// 所有非401的HTTP错误
|
||||
console.warn(`API返回HTTP错误: ${response.status} ${response.statusText}`);
|
||||
|
||||
// 尝试解析错误详情
|
||||
try {
|
||||
const errorResult = await response.json();
|
||||
// 显示详细错误信息,但保持本地token
|
||||
const errorMsg = errorResult?.msg || `服务器错误 (${response.status})`;
|
||||
toast.error(`更新失败: ${errorMsg}`);
|
||||
|
||||
// 回滚UI更改
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: { ...prev.features, [feature]: !checked }
|
||||
} : null);
|
||||
} catch (parseError) {
|
||||
// 无法解析响应,可能是网络问题
|
||||
console.error('无法解析错误响应:', parseError);
|
||||
toast.error(`更新失败: 服务器无响应 (${response.status})`);
|
||||
|
||||
// 回滚UI更改
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: { ...prev.features, [feature]: !checked }
|
||||
} : null);
|
||||
}
|
||||
|
||||
return; // 提前返回,避免继续处理
|
||||
}
|
||||
|
||||
// 响应正常,尝试解析
|
||||
try {
|
||||
const result = await response.json();
|
||||
|
||||
// 检查API响应码
|
||||
if (result && result.code === 200) {
|
||||
toast.success(`${getFeatureName(feature)}${checked ? '已启用' : '已禁用'}`);
|
||||
} else if (result && result.code === 401) {
|
||||
// API明确返回401,提示用户但不自动登出
|
||||
toast.error('认证已过期,请重新登录后再尝试操作');
|
||||
console.error('API请求返回401未授权状态码');
|
||||
|
||||
// 回滚UI更改
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: { ...prev.features, [feature]: !checked }
|
||||
} : null);
|
||||
} else {
|
||||
// 其他API错误
|
||||
const errorMsg = result?.msg || '未知错误';
|
||||
console.warn(`API返回业务错误: ${result?.code} - ${errorMsg}`);
|
||||
toast.error(`更新失败: ${errorMsg}`);
|
||||
|
||||
// 回滚UI更改
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: { ...prev.features, [feature]: !checked }
|
||||
} : null);
|
||||
}
|
||||
} catch (parseError) {
|
||||
// 无法解析响应JSON
|
||||
console.error('无法解析API响应:', parseError);
|
||||
toast.error('更新失败: 无法解析服务器响应');
|
||||
|
||||
// 回滚UI更改
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: { ...prev.features, [feature]: !checked }
|
||||
} : null);
|
||||
}
|
||||
} catch (fetchError) {
|
||||
console.error('请求错误:', fetchError)
|
||||
|
||||
// 回滚UI更改
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: { ...prev.features, [feature]: !checked }
|
||||
} : null)
|
||||
|
||||
// 显示友好的错误提示
|
||||
toast.error('网络请求失败,请稍后重试')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`更新${getFeatureName(feature)}失败:`, error)
|
||||
|
||||
// 异常情况下也回滚UI变更
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: { ...prev.features, [feature]: !checked }
|
||||
} : null)
|
||||
|
||||
toast.error('更新失败,请稍后重试')
|
||||
} finally {
|
||||
setSavingFeatures(prev => ({ ...prev, [feature]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
// 获取功能中文名称
|
||||
const getFeatureName = (feature: string): string => {
|
||||
const nameMap: Record<string, string> = {
|
||||
autoAddFriend: '自动加好友',
|
||||
autoReply: '自动回复',
|
||||
momentsSync: '朋友圈同步',
|
||||
aiChat: 'AI会话'
|
||||
}
|
||||
|
||||
return nameMap[feature] || feature
|
||||
}
|
||||
|
||||
// 加载更多账号
|
||||
const loadMoreAccounts = () => {
|
||||
if (accountsLoading || !hasMoreAccounts) return
|
||||
setAccountPage(prev => prev + 1)
|
||||
fetchRelatedAccounts(accountPage + 1)
|
||||
}
|
||||
|
||||
// 监听账号列表滚动加载更多
|
||||
useEffect(() => {
|
||||
if (activeTab !== "accounts") return
|
||||
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1
|
||||
}
|
||||
|
||||
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
|
||||
const [entry] = entries
|
||||
if (entry.isIntersecting && hasMoreAccounts && !accountsLoading) {
|
||||
loadMoreAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(handleIntersect, observerOptions)
|
||||
|
||||
if (accountsEndRef.current) {
|
||||
observer.observe(accountsEndRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (accountsEndRef.current) {
|
||||
observer.unobserve(accountsEndRef.current)
|
||||
}
|
||||
}
|
||||
}, [activeTab, hasMoreAccounts, accountsLoading])
|
||||
|
||||
// 当切换到账号标签时重置页码
|
||||
useEffect(() => {
|
||||
if (activeTab === "accounts") {
|
||||
setAccountPage(1)
|
||||
setHasMoreAccounts(true)
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full justify-center items-center bg-gray-50">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
<div className="text-gray-500">正在加载设备详情...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<div className="flex h-screen w-full justify-center items-center bg-gray-50">
|
||||
<div className="flex flex-col items-center space-y-4 p-6 bg-white rounded-lg shadow-sm max-w-md">
|
||||
<div className="w-12 h-12 flex items-center justify-center rounded-full bg-red-100">
|
||||
<Smartphone className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<div className="text-xl font-medium text-center">设备不存在或已被删除</div>
|
||||
<div className="text-sm text-gray-500 text-center">
|
||||
无法加载ID为 "{deviceId}" 的设备信息,请检查设备是否存在。
|
||||
</div>
|
||||
<Button onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
返回上一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<div className="w-full mx-auto bg-white">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">设备详情</h1>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<Smartphone className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium truncate">{device.name}</h2>
|
||||
<Badge variant={getBadgeVariant(device.status)}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1 flex items-center imei-display-area">
|
||||
<span className="mr-1 whitespace-nowrap">IMEI:</span>
|
||||
<ImeiDisplay imei={device.imei} containerWidth="max-w-[calc(100%-60px)]" />
|
||||
</div>
|
||||
{device.historicalIds && device.historicalIds.length > 0 && (
|
||||
<div className="text-sm text-gray-500">历史ID: {device.historicalIds.join(", ")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
|
||||
<span className="text-sm">{device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wifi className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm">{device.status === "online" ? "已连接" : "未连接"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">最后活跃:{device.lastActive}</div>
|
||||
</Card>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="info">基本信息</TabsTrigger>
|
||||
<TabsTrigger value="accounts">关联账号</TabsTrigger>
|
||||
<TabsTrigger value="history">操作记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>自动加好友</Label>
|
||||
<div className="text-sm text-gray-500">自动通过好友验证</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.autoAddFriend && (
|
||||
<div className="w-4 h-4 mr-2 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
)}
|
||||
<Switch
|
||||
checked={Boolean(device.features.autoAddFriend)}
|
||||
onCheckedChange={(checked) => handleFeatureChange('autoAddFriend', checked)}
|
||||
disabled={savingFeatures.autoAddFriend}
|
||||
className="data-[state=checked]:bg-blue-500 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>自动回复</Label>
|
||||
<div className="text-sm text-gray-500">自动回复好友消息</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.autoReply && (
|
||||
<div className="w-4 h-4 mr-2 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
)}
|
||||
<Switch
|
||||
checked={Boolean(device.features.autoReply)}
|
||||
onCheckedChange={(checked) => handleFeatureChange('autoReply', checked)}
|
||||
disabled={savingFeatures.autoReply}
|
||||
className="data-[state=checked]:bg-blue-500 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>朋友圈同步</Label>
|
||||
<div className="text-sm text-gray-500">自动同步朋友圈内容</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.momentsSync && (
|
||||
<div className="w-4 h-4 mr-2 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
)}
|
||||
<Switch
|
||||
checked={Boolean(device.features.momentsSync)}
|
||||
onCheckedChange={(checked) => handleFeatureChange('momentsSync', checked)}
|
||||
disabled={savingFeatures.momentsSync}
|
||||
className="data-[state=checked]:bg-blue-500 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>AI会话</Label>
|
||||
<div className="text-sm text-gray-500">启用AI智能对话</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.aiChat && (
|
||||
<div className="w-4 h-4 mr-2 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
)}
|
||||
<Switch
|
||||
checked={Boolean(device.features.aiChat)}
|
||||
onCheckedChange={(checked) => handleFeatureChange('aiChat', checked)}
|
||||
disabled={savingFeatures.aiChat}
|
||||
className="data-[state=checked]:bg-blue-500 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center space-x-2 text-gray-500">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm">好友总数</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-2">
|
||||
{(device.totalFriend || 0).toLocaleString()}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center space-x-2 text-gray-500">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span className="text-sm">消息数量</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-2">
|
||||
{(device.thirtyDayMsgCount || 0).toLocaleString()}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts">
|
||||
<Card className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-md font-medium">微信账号列表</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAccountPage(1)
|
||||
setHasMoreAccounts(true)
|
||||
fetchRelatedAccounts(1)
|
||||
}}
|
||||
disabled={accountsLoading}
|
||||
>
|
||||
{accountsLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 mr-2 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
刷新中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
刷新
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-[120px] max-h-[calc(100vh-300px)]">
|
||||
{/* 标签切换时的加载状态 */}
|
||||
{tabChangeLoading && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-80 flex justify-center items-center z-10">
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
<div className="text-gray-500 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accountsLoading && !device?.wechatAccounts?.length ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mr-2"></div>
|
||||
<span className="text-gray-500">加载微信账号中...</span>
|
||||
</div>
|
||||
) : device?.wechatAccounts && device.wechatAccounts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{device.wechatAccounts.map((account) => (
|
||||
<div key={account.id} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
src={account.avatar || "/placeholder.svg"}
|
||||
alt={account.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium truncate">{account.nickname}</div>
|
||||
<Badge variant={account.wechatAlive === 1 ? "default" : "destructive"}>
|
||||
{account.wechatAliveText}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">微信号: {account.wechatId}</div>
|
||||
<div className="text-sm text-gray-500">性别: {account.gender === 1 ? "男" : "女"}</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-sm text-gray-500">好友数: {account.totalFriend}</span>
|
||||
<Badge variant={account.status === 1 ? "outline" : "secondary"}>
|
||||
{account.statusText}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">最后活跃: {account.lastActive}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多区域 */}
|
||||
<div
|
||||
ref={accountsEndRef}
|
||||
className="py-2 flex justify-center items-center"
|
||||
>
|
||||
{accountsLoading && hasMoreAccounts ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
<span className="text-sm text-gray-500">加载更多...</span>
|
||||
</div>
|
||||
) : hasMoreAccounts ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadMoreAccounts}
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
) : device.wechatAccounts.length > 0 && (
|
||||
<span className="text-xs text-gray-400">- 已加载全部记录 -</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>此设备暂无关联的微信账号</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => fetchRelatedAccounts(1)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history">
|
||||
<Card className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-md font-medium">操作记录</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLogPage(1)
|
||||
setHasMoreLogs(true)
|
||||
}}
|
||||
disabled={logsLoading}
|
||||
>
|
||||
{logsLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
加载中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
刷新
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(min(80vh, 500px))]">
|
||||
{/* 标签切换时的加载状态 */}
|
||||
{tabChangeLoading && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-80 flex justify-center items-center z-10">
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
<div className="text-gray-500 text-sm">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{logsLoading && handleLogs.length === 0 ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mr-2"></div>
|
||||
<span className="text-gray-500">加载操作记录中...</span>
|
||||
</div>
|
||||
) : handleLogs.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{handleLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-start space-x-3">
|
||||
<div className="p-2 bg-blue-50 rounded-full">
|
||||
<History className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{log.content}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
操作人: {log.username} · {log.createTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多区域 - 用于懒加载触发点 */}
|
||||
<div
|
||||
ref={logsEndRef}
|
||||
className="py-2 flex justify-center items-center"
|
||||
>
|
||||
{logsLoading && hasMoreLogs ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
|
||||
<span className="text-sm text-gray-500">加载更多...</span>
|
||||
</div>
|
||||
) : hasMoreLogs ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadMoreLogs}
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">- 已加载全部记录 -</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>暂无操作记录</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
setLogPage(1)
|
||||
setHasMoreLogs(true)
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,904 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ChevronLeft, Plus, Filter, Search, RefreshCw, QrCode, Smartphone, Loader2, AlertTriangle, Trash2 } 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 { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { fetchDeviceList, deleteDevice } from "@/api/devices"
|
||||
import { ServerDevice } from "@/types/device"
|
||||
import { api } from "@/lib/api"
|
||||
import { ImeiDisplay } from "@/components/ImeiDisplay"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
|
||||
// 设备接口更新为与服务端接口对应的类型
|
||||
interface Device extends ServerDevice {
|
||||
status: "online" | "offline";
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const router = useRouter()
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false)
|
||||
const [stats, setStats] = useState({
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
})
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const observerTarget = useRef<HTMLDivElement>(null)
|
||||
const pageRef = useRef(1)
|
||||
const [deviceImei, setDeviceImei] = useState("")
|
||||
const [deviceName, setDeviceName] = useState("")
|
||||
const [qrCodeImage, setQrCodeImage] = useState("")
|
||||
const [isLoadingQRCode, setIsLoadingQRCode] = useState(false)
|
||||
const [isSubmittingImei, setIsSubmittingImei] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState("scan")
|
||||
const [pollingStatus, setPollingStatus] = useState<{
|
||||
isPolling: boolean;
|
||||
message: string;
|
||||
messageType: 'default' | 'success' | 'error';
|
||||
showAnimation: boolean;
|
||||
}>({
|
||||
isPolling: false,
|
||||
message: '',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
|
||||
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const devicesPerPage = 20
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [deviceToDelete, setDeviceToDelete] = useState<number | null>(null)
|
||||
|
||||
const loadDevices = useCallback(async (page: number, refresh: boolean = false) => {
|
||||
if (isLoading) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await fetchDeviceList(page, devicesPerPage, searchQuery)
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
const serverDevices = response.data.list.map(device => ({
|
||||
...device,
|
||||
status: device.alive === 1 ? "online" as const : "offline" as const
|
||||
}))
|
||||
|
||||
if (refresh) {
|
||||
setDevices(serverDevices)
|
||||
} else {
|
||||
setDevices(prev => [...prev, ...serverDevices])
|
||||
}
|
||||
|
||||
const total = response.data.total
|
||||
const online = response.data.list.filter(d => d.alive === 1).length
|
||||
setStats({
|
||||
totalDevices: total,
|
||||
onlineDevices: online
|
||||
})
|
||||
|
||||
setTotalCount(response.data.total)
|
||||
|
||||
const hasMoreData = serverDevices.length > 0 &&
|
||||
serverDevices.length === devicesPerPage &&
|
||||
(page * devicesPerPage) < response.data.total;
|
||||
setHasMore(hasMoreData)
|
||||
|
||||
pageRef.current = page
|
||||
} else {
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败", error)
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
const nextPage = pageRef.current + 1;
|
||||
setCurrentPage(nextPage);
|
||||
loadDevices(nextPage, false);
|
||||
}, [hasMore, isLoading, loadDevices]);
|
||||
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setCurrentPage(1)
|
||||
pageRef.current = 1
|
||||
loadDevices(1, true)
|
||||
}, [searchQuery, loadDevices])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || isLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoading && isMounted.current) {
|
||||
loadNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
)
|
||||
|
||||
if (typeof window !== 'undefined' && observerTarget.current) {
|
||||
observer.observe(observerTarget.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
}
|
||||
}, [hasMore, isLoading, loadNextPage])
|
||||
|
||||
const fetchDeviceQRCode = async () => {
|
||||
try {
|
||||
setIsLoadingQRCode(true)
|
||||
setQrCodeImage("")
|
||||
|
||||
const accountId = localStorage.getItem('s2_accountId')
|
||||
if (!accountId) {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "未获取到用户信息,请重新登录",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/api/device/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accountId: accountId
|
||||
})
|
||||
})
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "服务器返回的数据格式无效",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.code === 200) {
|
||||
let qrcodeData = null;
|
||||
|
||||
if (result.data?.qrCode) {
|
||||
qrcodeData = result.data.qrCode;
|
||||
} else if (result.data?.qrcode) {
|
||||
qrcodeData = result.data.qrcode;
|
||||
} else if (result.data?.image) {
|
||||
qrcodeData = result.data.image;
|
||||
} else if (result.data?.url) {
|
||||
qrcodeData = result.data.url;
|
||||
setQrCodeImage(qrcodeData);
|
||||
|
||||
toast({
|
||||
title: "二维码已更新",
|
||||
description: "请使用手机扫描新的二维码添加设备",
|
||||
});
|
||||
|
||||
return;
|
||||
} else if (typeof result.data === 'string') {
|
||||
qrcodeData = result.data;
|
||||
} else {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "返回数据格式不正确",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qrcodeData) {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "服务器返回的二维码数据为空",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrcodeData.startsWith('data:image')) {
|
||||
setQrCodeImage(qrcodeData);
|
||||
}
|
||||
else if (qrcodeData.startsWith('http')) {
|
||||
setQrCodeImage(qrcodeData);
|
||||
}
|
||||
else {
|
||||
try {
|
||||
const cleanedBase64 = qrcodeData.trim();
|
||||
|
||||
setQrCodeImage(`data:image/png;base64,${cleanedBase64}`);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 图片加载成功
|
||||
};
|
||||
img.onerror = (e) => {
|
||||
toast({
|
||||
title: "二维码加载失败",
|
||||
description: "服务器返回的数据无法显示为图片",
|
||||
variant: "destructive",
|
||||
});
|
||||
};
|
||||
img.src = `data:image/png;base64,${cleanedBase64}`;
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "图片数据处理失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "二维码已更新",
|
||||
description: "请使用手机扫描新的二维码添加设备",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: result?.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingQRCode(false);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupPolling = useCallback(() => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearTimeout(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
let pollCount = 0;
|
||||
const maxPolls = 60;
|
||||
const pollInterval = 1000;
|
||||
const initialDelay = 5000;
|
||||
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '请扫描二维码添加设备,5秒后将开始检测添加结果',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const accountId = localStorage.getItem('s2_accountId');
|
||||
if (!accountId) {
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '未获取到用户信息,请重新登录',
|
||||
messageType: 'error',
|
||||
showAnimation: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/devices/add-results?accountId=${accountId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code === 200 && data.data) {
|
||||
if (data.data.added) {
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '设备添加成功。关闭后可继续',
|
||||
messageType: 'success',
|
||||
showAnimation: false
|
||||
});
|
||||
setQrCodeImage('/broken-qr.png');
|
||||
cleanupPolling();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pollCount++;
|
||||
if (pollCount >= maxPolls) {
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '未检测到设备添加,请关闭后重试',
|
||||
messageType: 'error',
|
||||
showAnimation: false
|
||||
});
|
||||
cleanupPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
pollingTimerRef.current = setTimeout(poll, pollInterval);
|
||||
} catch (error) {
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '检测过程中发生错误,请重试',
|
||||
messageType: 'error',
|
||||
showAnimation: false
|
||||
});
|
||||
cleanupPolling();
|
||||
}
|
||||
};
|
||||
|
||||
pollingTimerRef.current = setTimeout(() => {
|
||||
setPollingStatus({
|
||||
isPolling: true,
|
||||
message: '正在检测添加结果',
|
||||
messageType: 'default',
|
||||
showAnimation: true
|
||||
});
|
||||
poll();
|
||||
}, initialDelay);
|
||||
}, [cleanupPolling]);
|
||||
|
||||
const handleOpenAddDeviceModal = () => {
|
||||
setIsAddDeviceOpen(true);
|
||||
setDeviceImei("");
|
||||
setDeviceName("");
|
||||
setQrCodeImage("");
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
fetchDeviceQRCode();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
const handleCloseAddDeviceModal = () => {
|
||||
cleanupPolling();
|
||||
setIsAddDeviceOpen(false);
|
||||
}
|
||||
|
||||
const handleAddDeviceByImei = async () => {
|
||||
if (!deviceImei) {
|
||||
toast({
|
||||
title: "IMEI不能为空",
|
||||
description: "请输入有效的设备IMEI",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingImei(true);
|
||||
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/devices`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
imei: deviceImei,
|
||||
memo: deviceName
|
||||
})
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "添加设备失败",
|
||||
description: "服务器返回的数据格式无效",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.code === 200) {
|
||||
toast({
|
||||
title: "设备添加成功",
|
||||
description: result.data?.msg || "设备已成功添加",
|
||||
});
|
||||
|
||||
setDeviceImei("");
|
||||
setDeviceName("");
|
||||
setIsAddDeviceOpen(false);
|
||||
|
||||
loadDevices(1, true);
|
||||
} else {
|
||||
toast({
|
||||
title: "添加设备失败",
|
||||
description: result?.msg || "请检查设备信息是否正确",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "请求失败",
|
||||
description: "网络错误,请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingImei(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
setCurrentPage(1)
|
||||
pageRef.current = 1
|
||||
loadDevices(1, true)
|
||||
toast({
|
||||
title: "刷新成功",
|
||||
description: "设备列表已更新",
|
||||
})
|
||||
}
|
||||
|
||||
const filteredDevices = devices.filter(device => {
|
||||
const matchesStatus = statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.alive === 1) ||
|
||||
(statusFilter === "offline" && device.alive === 0)
|
||||
return matchesStatus
|
||||
})
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (!selectedDeviceId) {
|
||||
toast({
|
||||
title: "请选择设备",
|
||||
description: "请先选择要删除的设备",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
setDeviceToDelete(selectedDeviceId)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deviceToDelete) return
|
||||
|
||||
try {
|
||||
const response = await deleteDevice(deviceToDelete)
|
||||
if (response.code === 200) {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeviceToDelete(null)
|
||||
setSelectedDeviceId(null)
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "设备已成功删除",
|
||||
})
|
||||
handleRefresh()
|
||||
} else {
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: response.message || "请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeviceToDelete(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`删除设备 ${deviceToDelete} 失败`, error)
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeviceToDelete(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeviceToDelete(null)
|
||||
}
|
||||
|
||||
const handleDeviceClick = (deviceId: number, event: React.MouseEvent) => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/devices/${deviceId}`);
|
||||
}
|
||||
|
||||
const handleAddDevice = async () => {
|
||||
try {
|
||||
const s2_accountId = localStorage.getItem('s2_accountId');
|
||||
if (!s2_accountId) {
|
||||
toast({
|
||||
title: '未获取到用户信息,请重新登录',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/devices', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
imei: deviceImei,
|
||||
memo: deviceName,
|
||||
s2_accountId: s2_accountId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code === 200) {
|
||||
toast({
|
||||
title: '添加设备成功',
|
||||
variant: 'default',
|
||||
});
|
||||
setIsAddDeviceOpen(false);
|
||||
loadDevices(1, true);
|
||||
} else {
|
||||
toast({
|
||||
title: data.msg || '添加设备失败',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error);
|
||||
toast({
|
||||
title: '添加设备失败,请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">设备管理</h1>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleOpenAddDeviceModal}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加设备
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card className="p-3">
|
||||
<div className="text-sm text-gray-500">总设备数</div>
|
||||
<div className="text-xl font-bold text-blue-600">{stats.totalDevices}</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-sm text-gray-500">在线设备</div>
|
||||
<div className="text-xl font-bold text-green-600">{stats.onlineDevices}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm space-y-3 mb-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 bg-white border-gray-200 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="bg-white hover:bg-gray-50">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh} className="bg-white hover:bg-gray-50">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px] bg-white">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={!selectedDeviceId}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{filteredDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="bg-white p-3 rounded-lg shadow-sm hover:shadow-md transition-shadow cursor-pointer relative"
|
||||
onClick={(e) => handleDeviceClick(device.id, e)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
checked={selectedDeviceId === device.id}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedDeviceId(device.id)
|
||||
} else {
|
||||
setSelectedDeviceId(null)
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium truncate">{device.memo || "未命名设备"}</div>
|
||||
<Badge variant={device.status === "online" ? "default" : "secondary"} className="ml-2">
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<span className="mr-1">IMEI: {device.imei}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">微信号: {device.wechatId || "未绑定或微信离线"}</div>
|
||||
<div className="flex items-center justify-between mt-1 text-sm">
|
||||
<span className="text-gray-500">好友数: {device.totalFriend}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={observerTarget} className="h-10 flex items-center justify-center">
|
||||
{isLoading && <div className="text-sm text-gray-500">加载中...</div>}
|
||||
{!hasMore && devices.length > 0 && <div className="text-sm text-gray-500">没有更多设备了</div>}
|
||||
{!hasMore && devices.length === 0 && <div className="text-sm text-gray-500">暂无设备</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddDeviceOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCloseAddDeviceModal();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="scan" value={activeTab} onValueChange={setActiveTab} className="mt-4">
|
||||
<TabsContent value="scan" className="space-y-4 py-4">
|
||||
<div className="flex flex-col items-center justify-center p-6 space-y-4 relative">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 z-10">
|
||||
<div className="flex flex-col items-center w-full py-2">
|
||||
{pollingStatus.isPolling || pollingStatus.showAnimation ? (
|
||||
<>
|
||||
<span className="text-sm text-gray-800">正在检测添加结果</span>
|
||||
<div className="flex space-x-1 mt-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-800">5秒后将开始检测添加结果</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow-md border border-gray-200 w-full max-w-[280px] min-h-[280px] flex flex-col items-center justify-center">
|
||||
{isLoadingQRCode ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="text-sm text-gray-500">正在获取二维码...</p>
|
||||
</div>
|
||||
) : qrCodeImage ? (
|
||||
<div id="qrcode-container" className="flex flex-col items-center space-y-3">
|
||||
<div className="relative w-64 h-64 flex items-center justify-center">
|
||||
<img
|
||||
src={qrCodeImage}
|
||||
alt="设备添加二维码"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
console.error("二维码图片加载失败");
|
||||
e.currentTarget.style.display = 'none';
|
||||
const container = document.getElementById('qrcode-container');
|
||||
if (container) {
|
||||
const errorEl = container.querySelector('.qrcode-error');
|
||||
if (errorEl) {
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="qrcode-error hidden absolute inset-0 flex flex-col items-center justify-center text-center text-red-500 bg-white">
|
||||
<AlertTriangle className="h-10 w-10 mb-2" />
|
||||
<p>未能加载二维码,请点击刷新按钮重试</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-center text-gray-600 mt-2">
|
||||
请使用手机扫描此二维码添加设备
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500">
|
||||
<QrCode className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>点击下方按钮获取二维码</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={fetchDeviceQRCode}
|
||||
disabled={isLoadingQRCode}
|
||||
className="w-48 mt-8"
|
||||
>
|
||||
{isLoadingQRCode ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
获取中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新二维码
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual" className="space-y-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">设备名称</label>
|
||||
<Input
|
||||
placeholder="请输入设备名称"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
为设备添加一个便于识别的名称
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">设备IMEI</label>
|
||||
<Input
|
||||
placeholder="请输入设备IMEI"
|
||||
value={deviceImei}
|
||||
onChange={(e) => setDeviceImei(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
请输入设备IMEI码,可在设备信息中查看
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setIsAddDeviceOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddDevice}
|
||||
disabled={!deviceImei.trim() || !deviceName.trim()}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
{pollingStatus.message && (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<p className={`text-sm ${
|
||||
pollingStatus.messageType === 'success' ? 'text-green-600' :
|
||||
pollingStatus.messageType === 'error' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{pollingStatus.message}
|
||||
</p>
|
||||
{pollingStatus.showAnimation && (
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription className="pt-4">
|
||||
设备删除后,本设备配置的计划任务操作也将失效。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={handleCancelDelete}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete}>
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ExcelImporter } from "@/components/ExcelImporter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Code } from "@/components/ui/code";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface ImportedData {
|
||||
mobile: number;
|
||||
from: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export default function ExcelImportDemo() {
|
||||
const [importedData, setImportedData] = useState<ImportedData[]>([]);
|
||||
const [showData, setShowData] = useState(false);
|
||||
|
||||
const handleImport = (data: ImportedData[]) => {
|
||||
setImportedData(data);
|
||||
setShowData(true);
|
||||
console.log("导入的数据:", data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 space-y-8">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="text-2xl font-bold">Excel导入演示</h1>
|
||||
<p className="text-gray-500">
|
||||
本演示页面用于测试Excel文件导入功能,将解析Excel中的手机号码、来源和微信号字段。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">上传Excel文件</h2>
|
||||
<ExcelImporter onImport={handleImport} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">导入结果</h2>
|
||||
<Card className="p-4">
|
||||
{importedData.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<p>导入 {importedData.length} 条数据</p>
|
||||
<div className="bg-gray-100 p-4 rounded-md overflow-auto max-h-[400px]">
|
||||
<pre className="text-sm">
|
||||
{JSON.stringify(importedData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant={showData ? "default" : "outline"}
|
||||
onClick={() => setShowData(!showData)}
|
||||
>
|
||||
{showData ? "隐藏数据" : "显示数据"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>尚未导入数据</p>
|
||||
<p className="text-sm mt-2">请上传Excel文件并点击确认导入</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">使用说明</h2>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="p-4 space-y-2">
|
||||
<h3 className="font-medium">功能说明</h3>
|
||||
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||
<li>支持上传.xlsx和.xls格式的Excel文件</li>
|
||||
<li>自动识别"手机号码"、"来源"和"微信号"栏位</li>
|
||||
<li>可以自动跳过没有手机号的行</li>
|
||||
<li>点击"确认导入"按钮将解析后的数据传给回调函数</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 space-y-2">
|
||||
<h3 className="font-medium">数据结构</h3>
|
||||
<Code className="text-xs">
|
||||
{`{
|
||||
mobile: "手机号码", // 必填
|
||||
from: "来源", // 选填
|
||||
alias: "微信号" // 选填
|
||||
}`}
|
||||
</Code>
|
||||
<p className="text-sm text-gray-500">
|
||||
注意:手机号为空的行会被自动忽略,来源和微信号栏位如果没有数据,则为空字符串。
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71.4% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71.4% 4.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71.4% 4.1%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 220 14.3% 95.9%;
|
||||
--secondary-foreground: 220.9 39.3% 11%;
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
--accent: 220 14.3% 95.9%;
|
||||
--accent-foreground: 220.9 39.3% 11%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71.4% 4.1%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--ring: 224 71.4% 4.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* 修复微信号页面横向滚动问题 */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 优化微信账号列表项布局 */
|
||||
.Card[class*="wechat-accounts"] {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 确保进度条组件不会溢出 */
|
||||
.Progress {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 修复IMEI显示模态框点击事件问题 */
|
||||
.imei-display {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.imei-display-area {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* 增强模态框的点击隔离 */
|
||||
[role="dialog"] {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import type { Device } from "@/components/device-grid"
|
||||
|
||||
interface DeviceStatus {
|
||||
status: "online" | "offline"
|
||||
battery: number
|
||||
}
|
||||
|
||||
async function fetchDeviceStatuses(deviceIds: string[]): Promise<Record<string, DeviceStatus>> {
|
||||
// 模拟API调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
return deviceIds.reduce(
|
||||
(acc, id) => {
|
||||
acc[id] = {
|
||||
status: Math.random() > 0.3 ? "online" : "offline",
|
||||
battery: Math.floor(Math.random() * 100),
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, DeviceStatus>,
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeviceStatusPolling(devices: Device[]) {
|
||||
const [statuses, setStatuses] = useState<Record<string, DeviceStatus>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const newStatuses = await fetchDeviceStatuses(devices.map((d) => d.id))
|
||||
setStatuses((prevStatuses) => ({ ...prevStatuses, ...newStatuses }))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch device statuses:", error)
|
||||
}
|
||||
}
|
||||
|
||||
pollStatus() // 立即执行一次
|
||||
const intervalId = setInterval(pollStatus, 30000) // 每30秒更新一次
|
||||
|
||||
return () => clearInterval(intervalId)
|
||||
}, [devices])
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Metadata } from "next"
|
||||
import "./globals.css"
|
||||
import "regenerator-runtime/runtime"
|
||||
import type React from "react"
|
||||
import ErrorBoundary from "./components/ErrorBoundary"
|
||||
import { AuthProvider } from "@/app/components/AuthProvider"
|
||||
import LayoutWrapper from "./components/LayoutWrapper"
|
||||
import { AuthCheck } from "@/app/components/auth-check"
|
||||
import { Toaster } from "react-hot-toast"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "存客宝",
|
||||
description: "智能客户管理系统",
|
||||
generator: 'v0.dev'
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="font-sans">
|
||||
<AuthProvider>
|
||||
<AuthCheck>
|
||||
<ErrorBoundary>
|
||||
<LayoutWrapper>{children}</LayoutWrapper>
|
||||
</ErrorBoundary>
|
||||
</AuthCheck>
|
||||
</AuthProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Eye, EyeOff, Phone } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { WeChatIcon } from "@/components/icons/wechat-icon"
|
||||
import { AppleIcon } from "@/components/icons/apple-icon"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { useAuth } from "@/app/components/AuthProvider"
|
||||
import { loginApi } from "@/lib/api"
|
||||
|
||||
// 定义登录表单类型
|
||||
interface LoginForm {
|
||||
phone: string
|
||||
password: string
|
||||
verificationCode: string
|
||||
agreeToTerms: boolean
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<"password" | "verification">("password")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [form, setForm] = useState<LoginForm>({
|
||||
phone: "",
|
||||
password: "",
|
||||
verificationCode: "",
|
||||
agreeToTerms: false,
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { login, isAuthenticated } = useAuth()
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target
|
||||
setForm((prev) => ({ ...prev, [name]: value }))
|
||||
}
|
||||
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
setForm((prev) => ({ ...prev, agreeToTerms: checked }))
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入手机号",
|
||||
description: "手机号不能为空",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!form.agreeToTerms) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请同意用户协议",
|
||||
description: "需要同意用户协议和隐私政策才能继续",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (activeTab === "password" && !form.password) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入密码",
|
||||
description: "密码不能为空",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (activeTab === "verification" && !form.verificationCode) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入验证码",
|
||||
description: "验证码不能为空",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
if (activeTab === "password") {
|
||||
// 发送账号密码登录请求
|
||||
const response = await loginApi.login(form.phone, form.password)
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 保存登录信息
|
||||
localStorage.setItem('token', response.data.token)
|
||||
localStorage.setItem('token_expired', response.data.token_expired)
|
||||
localStorage.setItem('s2_accountId', response.data.member.s2_accountId)
|
||||
|
||||
// 保存用户信息
|
||||
localStorage.setItem('userInfo', JSON.stringify(response.data.member))
|
||||
|
||||
// 显示成功提示
|
||||
toast({
|
||||
title: "登录成功",
|
||||
description: "欢迎回来!",
|
||||
})
|
||||
|
||||
// 跳转到首页
|
||||
router.push("/")
|
||||
} else {
|
||||
throw new Error(response.msg || "登录失败")
|
||||
}
|
||||
} else {
|
||||
// 验证码登录逻辑保持原样,未来可以实现
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "功能未实现",
|
||||
description: "验证码登录功能尚未实现,请使用密码登录",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "登录失败",
|
||||
description: error instanceof Error ? error.message : "请稍后重试",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendVerificationCode = async () => {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "请输入手机号",
|
||||
description: "发送验证码需要手机号",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "功能未实现",
|
||||
description: "验证码发送功能尚未实现",
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// 检查是否已登录,如果已登录且不在登录页面,则跳转到首页
|
||||
if (isAuthenticated) {
|
||||
// 获取重定向URL
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const returnUrl = params.get('returnUrl')
|
||||
|
||||
// 如果有重定向URL,则跳转到该URL,否则跳转到首页
|
||||
if (returnUrl) {
|
||||
window.location.href = decodeURIComponent(returnUrl)
|
||||
} else {
|
||||
window.location.href = "/"
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-gray-900 flex flex-col px-4 py-8">
|
||||
<div className="max-w-md w-full mx-auto space-y-8">
|
||||
<Tabs
|
||||
defaultValue="password"
|
||||
className="w-full"
|
||||
onValueChange={(v) => setActiveTab(v as "password" | "verification")}
|
||||
>
|
||||
<TabsList className="w-full bg-transparent border-b border-gray-200">
|
||||
<TabsTrigger
|
||||
value="verification"
|
||||
className="flex-1 data-[state=active]:border-blue-500 data-[state=active]:text-blue-500 border-b-2 border-transparent"
|
||||
>
|
||||
验证码登录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="password"
|
||||
className="flex-1 data-[state=active]:border-blue-500 data-[state=active]:text-blue-500 border-b-2 border-transparent"
|
||||
>
|
||||
密码登录
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-gray-600 mb-6">你所在地区仅支持 手机号 / 微信 / Apple 登录</p>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="手机号"
|
||||
className="pl-16 border-gray-300 text-gray-900 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1">
|
||||
<Phone className="h-4 w-4" />
|
||||
+86
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TabsContent value="password" className="m-0">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="密码"
|
||||
className="pr-12 border-gray-300 text-gray-900 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="verification" className="m-0">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
name="verificationCode"
|
||||
value={form.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder="验证码"
|
||||
className="pr-32 border-gray-300 text-gray-900 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendVerificationCode}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 px-4 h-8 bg-blue-50 text-blue-500 rounded text-sm font-medium"
|
||||
disabled={isLoading}
|
||||
>
|
||||
获取验证码
|
||||
</button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
checked={form.agreeToTerms}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm text-gray-500 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
同意《存客宝用户协议》和《隐私政策》
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-12 bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-2 justify-center">
|
||||
<hr className="w-full border-gray-200" />
|
||||
<span className="px-2 text-gray-400 text-sm whitespace-nowrap">其他登录方式</span>
|
||||
<hr className="w-full border-gray-200" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center space-x-6">
|
||||
<button type="button" className="p-2 text-gray-500 hover:text-gray-700">
|
||||
<WeChatIcon className="h-8 w-8" />
|
||||
</button>
|
||||
<button type="button" className="p-2 text-gray-500 hover:text-gray-700">
|
||||
<AppleIcon className="h-8 w-8" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import type { OrderFormData } from "@/types/acquisition"
|
||||
|
||||
export default function OrderSubmitPage({ params }: { params: { planId: string } }) {
|
||||
const [formData, setFormData] = useState<OrderFormData>({
|
||||
customerName: "",
|
||||
phone: "",
|
||||
wechatId: "",
|
||||
source: "",
|
||||
amount: undefined,
|
||||
orderDate: new Date().toISOString().split("T")[0],
|
||||
remark: "",
|
||||
})
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/acquisition/${params.planId}/orders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast({
|
||||
title: "提交成功",
|
||||
description: "订单信息已成功提交",
|
||||
})
|
||||
// 重置表单
|
||||
setFormData({
|
||||
customerName: "",
|
||||
phone: "",
|
||||
wechatId: "",
|
||||
source: "",
|
||||
amount: undefined,
|
||||
orderDate: new Date().toISOString().split("T")[0],
|
||||
remark: "",
|
||||
})
|
||||
} else {
|
||||
throw new Error(data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "提交失败",
|
||||
description: "订单提交失败,请稍后重试",
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card className="p-6">
|
||||
<h1 className="text-2xl font-semibold mb-6">订单信息录入</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="customerName">客户姓名</Label>
|
||||
<Input
|
||||
id="customerName"
|
||||
value={formData.customerName}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, customerName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">手机号码</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, phone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="wechatId">微信号</Label>
|
||||
<Input
|
||||
id="wechatId"
|
||||
value={formData.wechatId}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, wechatId: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="source">来源</Label>
|
||||
<Input
|
||||
id="source"
|
||||
value={formData.source}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, source: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="amount">订单金额</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.amount || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, amount: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="orderDate">下单日期</Label>
|
||||
<Input
|
||||
id="orderDate"
|
||||
type="date"
|
||||
value={formData.orderDate}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, orderDate: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="remark">备注</Label>
|
||||
<Textarea
|
||||
id="remark"
|
||||
value={formData.remark}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, remark: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
提交订单
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user