This commit is contained in:
wong
2025-07-17 11:41:52 +08:00
754 changed files with 107566 additions and 71816 deletions

View File

@@ -1,9 +0,0 @@
Server/runtime/
*.png
*.jpg
*.jpeg
*.gif
*.bmp
*.webp
*.ico
*.svg

13
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,3 @@
{
"git.ignoreLimitWarning": true
}

View File

@@ -1,4 +0,0 @@
{
"presets": ["next/babel"]
}

28
Cunkebao/.gitignore vendored
View File

@@ -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

View File

@@ -1 +0,0 @@
# 请将伪静态规则或自定义Apache配置填写到此处

View File

@@ -1 +0,0 @@
nKNDFrnOs9-cf7FZzW7Cg4MpIxhtE8pZ1etiT_7NPWA.2wKeISBpPoGcCt72TdgVrBMxtnnieXof2OZkRdHKCJI

266
Cunkebao/README.md Normal file
View 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月

View File

@@ -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()
},
}

View File

@@ -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 },
)
}
}

View File

@@ -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
};
}
};

View File

@@ -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 })
}
}

View File

@@ -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
}
}

View File

@@ -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 },
)
}
}

View File

@@ -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 namephonetimestampsourceremarktags</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>"应用集成" &gt; "外部接口"</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>
)
}

View File

@@ -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 },
)
}
}

View File

@@ -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,
},
})
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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)
}

View File

@@ -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>
)
}

View File

@@ -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}</>
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,12 +0,0 @@
/**
* 存客宝UI组件模板库
*
* 这个文件导出所有可重用的UI组件模板方便在项目中快速引用
*/
export * from "./cards"
export * from "./forms"
export * from "./layouts"
export * from "./tables"
export * from "./stats"
export * from "./selectors"

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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="没有找到设备"
/>
)
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -1,7 +0,0 @@
"use client"
import * as PopoverPrimitive from "@radix-ui/react-popover"
const Popover = PopoverPrimitive.Root
const PopoverTrigger

View File

@@ -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>
</>
)
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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>
)
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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>
);
}

View File

@@ -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>
)
}

View File

@@ -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">MP420MB</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>
)
}

View File

@@ -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">MP420MB</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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -1,7 +0,0 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -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>
)
}

View File

@@ -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