feat: 本次提交更新内容如下
定版本转移2025年7月17日
This commit is contained in:
23
nkebao/.gitignore
vendored
23
nkebao/.gitignore
vendored
@@ -1,23 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
266
nkebao/README.md
266
nkebao/README.md
@@ -1,266 +0,0 @@
|
||||
# 内客宝 - 智能获客管理平台
|
||||
|
||||
## 📋 项目简介
|
||||
|
||||
内客宝是一个专业的微信获客和流量管理平台,基于 React 技术栈构建。平台提供智能化的客户获取、管理和运营解决方案,集成了多种自动化工具,帮助企业高效管理微信营销活动。
|
||||
|
||||
## 🚀 技术栈详解
|
||||
|
||||
### 核心框架
|
||||
- **React 18.2.0** - 现代化的用户界面库
|
||||
- **TypeScript 4.9.5** - 类型安全的 JavaScript 超集
|
||||
- **Create React App (CRA) 5.0.1** - React 应用脚手架
|
||||
- **React Router DOM 6.20.0** - 客户端路由管理
|
||||
|
||||
### 构建工具
|
||||
- **CRACO 7.1.0** - Create React App Configuration Override
|
||||
- 支持自定义 webpack 配置
|
||||
- 路径别名配置
|
||||
- 构建优化
|
||||
|
||||
### UI 组件库
|
||||
- **Radix UI** - 无样式的可访问组件库
|
||||
- 完整的组件生态系统(30+ 组件)
|
||||
- 优秀的无障碍访问支持
|
||||
- 高度可定制
|
||||
- **Tailwind CSS 3.4.17** - 实用优先的 CSS 框架
|
||||
- 响应式设计支持
|
||||
- 自定义主题配置
|
||||
- 原子化 CSS 类
|
||||
|
||||
### 图标和样式
|
||||
- **Lucide React 0.454.0** - 精美的图标库
|
||||
- **Tailwind CSS Animate** - CSS 动画库
|
||||
- **Class Variance Authority** - 组件变体管理
|
||||
- **Tailwind Merge** - Tailwind 类名合并工具
|
||||
|
||||
### 状态管理和表单
|
||||
- **React Hook Form 7.54.1** - 高性能表单库
|
||||
- **Zod 3.24.1** - TypeScript 优先的模式验证
|
||||
- **@hookform/resolvers 3.9.1** - 表单验证解析器
|
||||
|
||||
### 数据可视化
|
||||
- **Recharts** - 基于 React 的图表库
|
||||
- **Chart.js 4.5.0** - 灵活的图表库
|
||||
- **@ant-design/plots** - Ant Design 图表组件
|
||||
|
||||
### HTTP 请求和数据处理
|
||||
- **Axios 1.6.0** - HTTP 客户端
|
||||
- **Crypto-js 4.2.0** - 加密库
|
||||
- **Date-fns** - 日期处理库
|
||||
- **XLSX 0.18.5** - Excel 文件处理
|
||||
|
||||
### 通知和反馈
|
||||
- **React Hot Toast 2.5.2** - 轻量级通知库
|
||||
- **Sonner 1.7.4** - 现代化 Toast 组件
|
||||
|
||||
### 高级组件
|
||||
- **@tanstack/react-table** - 功能强大的表格组件
|
||||
- **Embla Carousel React 8.5.1** - 轮播组件
|
||||
- **React Resizable Panels 2.1.7** - 可调整大小的面板
|
||||
- **Vaul 0.9.6** - 抽屉组件
|
||||
- **Input OTP 1.4.1** - OTP 输入组件
|
||||
- **React Day Picker** - 日期选择器
|
||||
|
||||
### 开发工具
|
||||
- **PostCSS 8** - CSS 后处理器
|
||||
- **Autoprefixer 10.4.20** - CSS 前缀自动添加
|
||||
- **ESLint** - 代码质量检查
|
||||
- **Jest** - 单元测试框架
|
||||
- **Testing Library** - React 测试工具
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
nkebao/
|
||||
├── public/ # 静态资源
|
||||
├── src/ # 源代码
|
||||
│ ├── api/ # API 接口封装
|
||||
│ ├── components/ # 全局组件
|
||||
│ │ ├── ui/ # UI 基础组件
|
||||
│ │ └── icons/ # 图标组件
|
||||
│ ├── config/ # 配置文件
|
||||
│ ├── contexts/ # React Context
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── workspace/ # 工作台模块
|
||||
│ │ │ ├── auto-like/ # 自动点赞
|
||||
│ │ │ ├── auto-group/ # 自动建群
|
||||
│ │ │ ├── group-push/ # 群消息推送
|
||||
│ │ │ ├── moments-sync/ # 朋友圈同步
|
||||
│ │ │ ├── ai-assistant/ # AI 对话助手
|
||||
│ │ │ └── traffic-distribution/ # 流量分发
|
||||
│ │ ├── devices/ # 设备管理
|
||||
│ │ ├── scenarios/ # 场景管理
|
||||
│ │ ├── content/ # 内容管理
|
||||
│ │ └── ...
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── App.tsx # 应用根组件
|
||||
│ └── index.tsx # 应用入口
|
||||
├── craco.config.js # CRACO 配置
|
||||
├── tailwind.config.js # Tailwind CSS 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── package.json # 项目依赖
|
||||
```
|
||||
|
||||
## 🎯 核心功能模块
|
||||
|
||||
### 工作台 (Workspace)
|
||||
- **自动点赞** - 智能点赞管理和配置
|
||||
- **自动建群** - 群组自动化创建和管理
|
||||
- **群消息推送** - 群组消息批量发送
|
||||
- **朋友圈同步** - 内容同步和发布
|
||||
- **AI 对话助手** - 智能客服和对话管理
|
||||
- **流量分发** - 流量分配和策略管理
|
||||
|
||||
### 设备管理 (Devices)
|
||||
- 设备状态监控和配置
|
||||
- 设备性能分析
|
||||
- 设备权限管理
|
||||
|
||||
### 场景管理 (Scenarios)
|
||||
- 营销场景配置
|
||||
- 自动化流程设计
|
||||
- 场景效果分析
|
||||
|
||||
### 内容管理 (Content)
|
||||
- 内容创建与编辑
|
||||
- 内容模板管理
|
||||
- 内容发布调度
|
||||
|
||||
### 其他模块
|
||||
- 用户管理 (Users)
|
||||
- 订单管理 (Orders)
|
||||
- 流量池管理 (Traffic Pool)
|
||||
- 联系人导入 (Contact Import)
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 环境要求
|
||||
- **Node.js** 16+
|
||||
- **npm** 或 **yarn**
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm install
|
||||
|
||||
# 使用 yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
### 开发环境启动
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm start
|
||||
|
||||
# 使用 yarn
|
||||
yarn start
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm run build
|
||||
|
||||
# 使用 yarn
|
||||
yarn build
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm test
|
||||
|
||||
# 使用 yarn
|
||||
yarn test
|
||||
```
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 路径别名配置
|
||||
项目使用 CRACO 配置了路径别名:
|
||||
```javascript
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@/components': path.resolve(__dirname, 'src/components'),
|
||||
'@/api': path.resolve(__dirname, 'src/api'),
|
||||
'@/types': path.resolve(__dirname, 'src/types'),
|
||||
'@/hooks': path.resolve(__dirname, 'src/hooks'),
|
||||
'@/utils': path.resolve(__dirname, 'src/utils'),
|
||||
'@/styles': path.resolve(__dirname, 'src/styles'),
|
||||
'@/pages': path.resolve(__dirname, 'src/pages'),
|
||||
```
|
||||
|
||||
### Tailwind CSS 配置
|
||||
- 自定义字体大小和间距
|
||||
- 响应式断点配置
|
||||
- 主题颜色系统
|
||||
|
||||
### TypeScript 配置
|
||||
- 严格模式启用
|
||||
- 路径映射配置
|
||||
- JSX 支持
|
||||
|
||||
## 📱 响应式设计
|
||||
|
||||
项目采用移动优先的响应式设计:
|
||||
- 支持桌面端、平板端、移动端
|
||||
- 自适应布局组件
|
||||
- 触摸友好的交互设计
|
||||
|
||||
## 🎨 UI 设计系统
|
||||
|
||||
### 设计原则
|
||||
- 简洁现代的设计风格
|
||||
- 一致的用户体验
|
||||
- 无障碍访问支持
|
||||
|
||||
### 组件库特点
|
||||
- 基于 Radix UI 的高质量组件
|
||||
- 完整的表单组件系统
|
||||
- 数据展示组件
|
||||
- 导航和布局组件
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
- 身份验证和授权
|
||||
- API 请求拦截
|
||||
- 数据验证和清理
|
||||
- 加密功能支持
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
- 代码分割和懒加载
|
||||
- 组件优化
|
||||
- 缓存策略
|
||||
- 包大小优化
|
||||
|
||||
## 🧪 测试策略
|
||||
|
||||
- 单元测试 (Jest + Testing Library)
|
||||
- 组件测试
|
||||
- 集成测试支持
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证。
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
如有问题或建议,请联系开发团队。
|
||||
|
||||
---
|
||||
|
||||
**项目名称**: 内客宝 (nkebao2)
|
||||
**版本**: 0.1.0
|
||||
**技术栈**: React + TypeScript + CRA + Tailwind CSS
|
||||
**最后更新**: 2024年12月
|
||||
@@ -1,16 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
webpack: {
|
||||
alias: {
|
||||
'@': 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'),
|
||||
},
|
||||
},
|
||||
};
|
||||
21347
nkebao/package-lock.json
generated
21347
nkebao/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
{
|
||||
"name": "nkebao2",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/plots": "latest",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-accordion": "latest",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.1",
|
||||
"@radix-ui/react-avatar": "latest",
|
||||
"@radix-ui/react-checkbox": "latest",
|
||||
"@radix-ui/react-collapsible": "latest",
|
||||
"@radix-ui/react-context-menu": "^2.2.4",
|
||||
"@radix-ui/react-dialog": "latest",
|
||||
"@radix-ui/react-dropdown-menu": "latest",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
"@radix-ui/react-icons": "latest",
|
||||
"@radix-ui/react-label": "latest",
|
||||
"@radix-ui/react-menubar": "^1.1.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
||||
"@radix-ui/react-popover": "latest",
|
||||
"@radix-ui/react-progress": "latest",
|
||||
"@radix-ui/react-radio-group": "latest",
|
||||
"@radix-ui/react-scroll-area": "latest",
|
||||
"@radix-ui/react-select": "latest",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "latest",
|
||||
"@radix-ui/react-tabs": "latest",
|
||||
"@radix-ui/react-toast": "latest",
|
||||
"@radix-ui/react-toggle": "^1.1.1",
|
||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "latest",
|
||||
"@tanstack/react-table": "latest",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^18.19.34",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"input-otp": "1.4.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "latest",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "latest",
|
||||
"regenerator-runtime": "latest",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tdesign-mobile-react": "^0.16.0",
|
||||
"vaul": "^0.9.6",
|
||||
"web-vitals": "^2.1.4",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^7.1.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "craco start",
|
||||
"build": "craco build",
|
||||
"test": "craco test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,87 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 需要更新的页面文件列表
|
||||
const pagesToUpdate = [
|
||||
'src/pages/scenarios/ScenarioDetail.tsx',
|
||||
'src/pages/scenarios/NewPlan.tsx',
|
||||
'src/pages/plans/Plans.tsx',
|
||||
'src/pages/plans/PlanDetail.tsx',
|
||||
'src/pages/orders/Orders.tsx',
|
||||
'src/pages/profile/Profile.tsx',
|
||||
'src/pages/content/Content.tsx',
|
||||
'src/pages/contact-import/ContactImport.tsx',
|
||||
'src/pages/traffic-pool/TrafficPool.tsx',
|
||||
'src/pages/workspace/Workspace.tsx'
|
||||
];
|
||||
|
||||
// 更新规则
|
||||
const updateRules = [
|
||||
{
|
||||
// 替换旧的header结构
|
||||
pattern: /<header className="[^"]*fixed[^"]*">\s*<div className="[^"]*">\s*<div className="[^"]*">\s*<button[^>]*onClick=\{\(\) => navigate\(-1\)\}[^>]*>\s*<ChevronLeft[^>]*\/>\s*<\/button>\s*<h1[^>]*>([^<]*)<\/h1>\s*<\/div>\s*(?:<div[^>]*>([\s\S]*?)<\/div>)?\s*<\/div>\s*<\/header>/g,
|
||||
replacement: (match, title, rightContent) => {
|
||||
const rightContentStr = rightContent ? `\n rightContent={\n ${rightContent.trim()}\n }` : '';
|
||||
return `<PageHeader\n title="${title.trim()}"\n defaultBackPath="/"${rightContentStr}\n />`;
|
||||
}
|
||||
},
|
||||
{
|
||||
// 替换简单的header结构
|
||||
pattern: /<header className="[^"]*">\s*<div className="[^"]*">\s*<h1[^>]*>([^<]*)<\/h1>\s*<\/div>\s*<\/header>/g,
|
||||
replacement: (match, title) => {
|
||||
return `<PageHeader\n title="${title.trim()}"\n showBack={false}\n />`;
|
||||
}
|
||||
},
|
||||
{
|
||||
// 添加PageHeader导入
|
||||
pattern: /import React[^;]+;/,
|
||||
replacement: (match) => {
|
||||
return `${match}\nimport PageHeader from '@/components/PageHeader';`;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function updateFile(filePath) {
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), filePath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.log(`文件不存在: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(fullPath, 'utf8');
|
||||
let updated = false;
|
||||
|
||||
// 应用更新规则
|
||||
updateRules.forEach(rule => {
|
||||
const newContent = content.replace(rule.pattern, rule.replacement);
|
||||
if (newContent !== content) {
|
||||
content = newContent;
|
||||
updated = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
console.log(`✅ 已更新: ${filePath}`);
|
||||
} else {
|
||||
console.log(`⏭️ 无需更新: ${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 更新失败: ${filePath}`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行批量更新
|
||||
console.log('🚀 开始批量更新页面Header...\n');
|
||||
|
||||
pagesToUpdate.forEach(filePath => {
|
||||
updateFile(filePath);
|
||||
});
|
||||
|
||||
console.log('\n✨ 批量更新完成!');
|
||||
console.log('\n📝 注意事项:');
|
||||
console.log('1. 请检查更新后的文件是否正确');
|
||||
console.log('2. 可能需要手动调整一些特殊的header结构');
|
||||
console.log('3. 确保所有页面都正确导入了PageHeader组件');
|
||||
console.log('4. 运行 npm run build 检查是否有编译错误');
|
||||
@@ -1,38 +0,0 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { WechatAccountProvider } from "./contexts/WechatAccountContext";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import LayoutWrapper from "./components/LayoutWrapper";
|
||||
import { initInterceptors } from "./api";
|
||||
import Home from "./pages/Home";
|
||||
import Login from "./pages/login/Login";
|
||||
import Devices from "./pages/devices/Devices";
|
||||
import DeviceDetail from "./pages/devices/DeviceDetail";
|
||||
import WechatAccounts from "./pages/wechat-accounts/WechatAccounts";
|
||||
import WechatAccountDetail from "./pages/wechat-accounts/WechatAccountDetail";
|
||||
import Workspace from "./pages/workspace/Workspace";
|
||||
import AutoLike from "./pages/workspace/auto-like/AutoLike";
|
||||
import NewAutoLike from "./pages/workspace/auto-like/NewAutoLike";
|
||||
import AutoLikeDetail from "./pages/workspace/auto-like/AutoLikeDetail";
|
||||
import NewDistribution from "./pages/workspace/traffic-distribution/NewDistribution";
|
||||
import AutoGroup from "./pages/workspace/auto-group/AutoGroup";
|
||||
import AutoGroupDetail from "./pages/workspace/auto-group/Detail";
|
||||
import GroupPush from "./pages/workspace/group-push/GroupPush";
|
||||
import MomentsSync from "./pages/workspace/moments-sync/MomentsSync";
|
||||
import MomentsSyncDetail from "./pages/workspace/moments-sync/Detail";
|
||||
import NewMomentsSync from "./pages/workspace/moments-sync/new";
|
||||
import AIAssistant from "./pages/workspace/ai-assistant/AIAssistant";
|
||||
import TrafficDistribution from "./pages/workspace/traffic-distribution/TrafficDistribution";
|
||||
import TrafficDistributionDetail from "./pages/workspace/traffic-distribution/Detail";
|
||||
import Scenarios from "./pages/scenarios/Scenarios";
|
||||
import NewPlan from "./pages/scenarios/new/page";
|
||||
import ScenarioList from "./pages/scenarios/ScenarioList";
|
||||
import Profile from "./pages/profile/Profile";
|
||||
import Plans from "./pages/plans/Plans";
|
||||
import PlanDetail from "./pages/plans/PlanDetail";
|
||||
import Orders from "./pages/orders/Orders";
|
||||
import TrafficPool from "./pages/traffic-pool/TrafficPool";
|
||||
import ContactImport from "./pages/contact-import/ContactImport";
|
||||
import Content from "./pages/content/Content";
|
||||
import TrafficPoolDetail from "./pages/traffic-pool/TrafficPoolDetail";
|
||||
import NewContent from "./pages/content/NewContent";
|
||||
import Materials from "./pages/content/materials/List";
|
||||
import MaterialsNew from "./pages/content/materials/New";
|
||||
import NewGroupPush from './pages/workspace/group-push/new';
|
||||
// 占位导入(如未实现可后续补充)
|
||||
// import GroupPushDetail from './pages/workspace/group-push/GroupPushDetail';
|
||||
// import EditGroupPush from './pages/workspace/group-push/EditGroupPush';
|
||||
// import NewAutoGroup from './pages/workspace/auto-group/NewAutoGroup';
|
||||
// import EditAutoGroup from './pages/workspace/auto-group/EditAutoGroup';
|
||||
|
||||
function App() {
|
||||
// 初始化HTTP拦截器
|
||||
useEffect(() => {
|
||||
const cleanup = initInterceptors();
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter
|
||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||
>
|
||||
<AuthProvider>
|
||||
<WechatAccountProvider>
|
||||
<ProtectedRoute>
|
||||
<LayoutWrapper>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/devices" element={<Devices />} />
|
||||
<Route path="/devices/:id" element={<DeviceDetail />} />
|
||||
<Route path="/wechat-accounts" element={<WechatAccounts />} />
|
||||
<Route
|
||||
path="/wechat-accounts/:id"
|
||||
element={<WechatAccountDetail />}
|
||||
/>
|
||||
<Route path="/workspace" element={<Workspace />} />
|
||||
<Route path="/workspace/auto-like" element={<AutoLike />} />
|
||||
<Route
|
||||
path="/workspace/auto-like/new"
|
||||
element={<NewAutoLike />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/auto-like/:id"
|
||||
element={<AutoLikeDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/auto-like/:id/edit"
|
||||
element={<NewAutoLike />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/traffic-distribution"
|
||||
element={<TrafficDistribution />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/traffic-distribution/new"
|
||||
element={<NewDistribution />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/traffic-distribution/edit/:id"
|
||||
element={<NewDistribution />}
|
||||
/>
|
||||
<Route path="/workspace/auto-group" element={<AutoGroup />} />
|
||||
<Route
|
||||
path="/workspace/auto-group/:id"
|
||||
element={<AutoGroupDetail />}
|
||||
/>
|
||||
<Route path="/workspace/group-push" element={<GroupPush />} />
|
||||
<Route
|
||||
path="/workspace/group-push/new"
|
||||
element={<NewGroupPush />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/group-push/:id"
|
||||
element={<div>群发推送详情页(待实现GroupPushDetail组件)</div>}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/group-push/:id/edit"
|
||||
element={<div>编辑群发推送任务页(待实现EditGroupPush组件)</div>}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/moments-sync"
|
||||
element={<MomentsSync />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/moments-sync/new"
|
||||
element={<NewMomentsSync />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/moments-sync/:id"
|
||||
element={<MomentsSyncDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/moments-sync/edit/:id"
|
||||
element={<NewMomentsSync />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/ai-assistant"
|
||||
element={<AIAssistant />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/traffic-distribution"
|
||||
element={<TrafficDistribution />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/traffic-distribution/:id"
|
||||
element={<TrafficDistributionDetail />}
|
||||
/>
|
||||
{/* 场景计划开始 */}
|
||||
<Route path="/scenarios" element={<Scenarios />} />
|
||||
<Route path="/scenarios/new" element={<NewPlan />} />
|
||||
<Route
|
||||
path="/scenarios/new/:scenarioId"
|
||||
element={<NewPlan />}
|
||||
/>
|
||||
<Route path="/scenarios/edit/:planId" element={<NewPlan />} />
|
||||
<Route
|
||||
path="/scenarios/list/:scenarioId/:scenarioName"
|
||||
element={<ScenarioList />}
|
||||
/>
|
||||
{/* 场景计划结束 */}
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/plans" element={<Plans />} />
|
||||
<Route path="/plans/:planId" element={<PlanDetail />} />
|
||||
<Route path="/orders" element={<Orders />} />
|
||||
<Route path="/traffic-pool" element={<TrafficPool />} />
|
||||
<Route
|
||||
path="/traffic-pool/:id"
|
||||
element={<TrafficPoolDetail />}
|
||||
/>
|
||||
<Route path="/contact-import" element={<ContactImport />} />
|
||||
<Route path="/content" element={<Content />} />
|
||||
<Route path="/content/new" element={<NewContent />} />
|
||||
<Route path="/content/edit/:id" element={<NewContent />} />
|
||||
<Route path="/content/materials/:id" element={<Materials />} />
|
||||
<Route
|
||||
path="/content/materials/new/:id"
|
||||
element={<MaterialsNew />}
|
||||
/>
|
||||
<Route
|
||||
path="/content/materials/edit/:id/:materialId"
|
||||
element={<MaterialsNew />}
|
||||
/>
|
||||
{/* 你可以继续添加更多路由 */}
|
||||
</Routes>
|
||||
</LayoutWrapper>
|
||||
</ProtectedRoute>
|
||||
</WechatAccountProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,82 +0,0 @@
|
||||
import { request } from './request';
|
||||
import type { ApiResponse } from '@/types/common';
|
||||
|
||||
// 登录响应数据类型
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
token_expired: string;
|
||||
member: {
|
||||
id: number;
|
||||
username: string;
|
||||
account: string;
|
||||
avatar?: string;
|
||||
s2_accountId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 验证码响应类型
|
||||
export interface VerificationCodeResponse {
|
||||
code: string;
|
||||
expire_time: string;
|
||||
}
|
||||
|
||||
// 认证相关API
|
||||
export const authApi = {
|
||||
// 账号密码登录
|
||||
login: async (account: string, password: string) => {
|
||||
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login', {
|
||||
account,
|
||||
password,
|
||||
typeId: 1 // 默认使用用户类型1
|
||||
});
|
||||
return response as unknown as ApiResponse<LoginResponse>;
|
||||
},
|
||||
|
||||
// 验证码登录
|
||||
loginWithCode: async (account: string, code: string) => {
|
||||
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login/code', {
|
||||
account,
|
||||
code,
|
||||
typeId: 1
|
||||
});
|
||||
return response as unknown as ApiResponse<LoginResponse>;
|
||||
},
|
||||
|
||||
// 发送验证码
|
||||
sendVerificationCode: async (account: string) => {
|
||||
const response = await request.post<ApiResponse<VerificationCodeResponse>>('/v1/auth/send-code', {
|
||||
account,
|
||||
type: 'login' // 登录验证码
|
||||
});
|
||||
return response as unknown as ApiResponse<VerificationCodeResponse>;
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
getUserInfo: async () => {
|
||||
const response = await request.get<ApiResponse<any>>('/v1/auth/info');
|
||||
return response as unknown as ApiResponse<any>;
|
||||
},
|
||||
|
||||
// 刷新Token
|
||||
refreshToken: async () => {
|
||||
const response = await request.post<ApiResponse<{ token: string; token_expired: string }>>('/v1/auth/refresh', {});
|
||||
return response as unknown as ApiResponse<{ token: string; token_expired: string }>;
|
||||
},
|
||||
|
||||
// 微信登录
|
||||
wechatLogin: async (code: string) => {
|
||||
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/wechat', {
|
||||
code
|
||||
});
|
||||
return response as unknown as ApiResponse<LoginResponse>;
|
||||
},
|
||||
|
||||
// Apple登录
|
||||
appleLogin: async (identityToken: string, authorizationCode: string) => {
|
||||
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/apple', {
|
||||
identity_token: identityToken,
|
||||
authorization_code: authorizationCode
|
||||
});
|
||||
return response as unknown as ApiResponse<LoginResponse>;
|
||||
},
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
import { get, post, del } from './request';
|
||||
import {
|
||||
LikeTask,
|
||||
CreateLikeTaskData,
|
||||
UpdateLikeTaskData,
|
||||
LikeRecord,
|
||||
ApiResponse,
|
||||
PaginatedResponse
|
||||
} from '@/types/auto-like';
|
||||
|
||||
// 获取自动点赞任务列表
|
||||
export async function fetchAutoLikeTasks(): Promise<LikeTask[]> {
|
||||
try {
|
||||
const res = await get<ApiResponse<PaginatedResponse<LikeTask>>>('/v1/workbench/list?type=1&page=1&limit=100');
|
||||
|
||||
if (res.code === 200 && res.data) {
|
||||
return res.data.list || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('获取自动点赞任务失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export async function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
||||
try {
|
||||
console.log(`Fetching task detail for id: ${id}`);
|
||||
// 使用any类型来处理可能的不同响应结构
|
||||
const res = await get<any>(`/v1/workbench/detail?id=${id}`);
|
||||
console.log('Task detail API response:', res);
|
||||
|
||||
if (res.code === 200) {
|
||||
// 检查响应中的data字段
|
||||
if (res.data) {
|
||||
// 如果data是对象,直接返回
|
||||
if (typeof res.data === 'object') {
|
||||
return res.data;
|
||||
} else {
|
||||
console.error('Task detail API response data is not an object:', res.data);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
console.error('Task detail API response missing data field:', res);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Task detail API error:', res.msg || 'Unknown error');
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('获取任务详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建自动点赞任务
|
||||
export async function createAutoLikeTask(data: CreateLikeTaskData): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/create', {
|
||||
...data,
|
||||
type: 1 // 自动点赞类型
|
||||
});
|
||||
}
|
||||
|
||||
// 更新自动点赞任务
|
||||
export async function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/update', {
|
||||
...data,
|
||||
type: 1 // 自动点赞类型
|
||||
});
|
||||
}
|
||||
|
||||
// 删除自动点赞任务
|
||||
export async function deleteAutoLikeTask(id: string): Promise<ApiResponse> {
|
||||
return del('/v1/workbench/delete', { params: { id } });
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export async function toggleAutoLikeTask(id: string, status: string): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/update-status', { id, status });
|
||||
}
|
||||
|
||||
// 复制自动点赞任务
|
||||
export async function copyAutoLikeTask(id: string): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/copy', { id });
|
||||
}
|
||||
|
||||
// 获取点赞记录
|
||||
export async function fetchLikeRecords(
|
||||
workbenchId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string
|
||||
): Promise<PaginatedResponse<LikeRecord>> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
workbenchId,
|
||||
page: page.toString(),
|
||||
limit: limit.toString()
|
||||
});
|
||||
|
||||
if (keyword) {
|
||||
params.append('keyword', keyword);
|
||||
}
|
||||
|
||||
const res = await get<ApiResponse<PaginatedResponse<LikeRecord>>>(`/v1/workbench/like-records?${params.toString()}`);
|
||||
|
||||
if (res.code === 200 && res.data) {
|
||||
return res.data;
|
||||
}
|
||||
return { list: [], total: 0, page, limit };
|
||||
} catch (error) {
|
||||
console.error('获取点赞记录失败:', error);
|
||||
return { list: [], total: 0, page, limit };
|
||||
}
|
||||
}
|
||||
|
||||
export type { LikeTask, LikeRecord, CreateLikeTaskData };
|
||||
@@ -1,69 +0,0 @@
|
||||
import { get, post, put, del } from './request';
|
||||
import type { ApiResponse, PaginatedResponse } from '@/types/common';
|
||||
|
||||
// 内容库类型定义
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceType: number;
|
||||
creatorName: string;
|
||||
updateTime: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
// 内容库列表响应
|
||||
export interface ContentLibraryListResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: {
|
||||
list: ContentLibrary[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 获取内容库列表
|
||||
export const fetchContentLibraryList = async (
|
||||
page: number = 1,
|
||||
limit: number = 100,
|
||||
keyword?: string
|
||||
): Promise<ContentLibraryListResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', page.toString());
|
||||
params.append('limit', limit.toString());
|
||||
|
||||
if (keyword) {
|
||||
params.append('keyword', keyword);
|
||||
}
|
||||
|
||||
return get<ContentLibraryListResponse>(`/v1/content/library/list?${params.toString()}`);
|
||||
};
|
||||
|
||||
// 内容库API对象
|
||||
export const contentLibraryApi = {
|
||||
// 获取内容库列表
|
||||
async getList(page: number = 1, limit: number = 100, keyword?: string): Promise<ContentLibraryListResponse> {
|
||||
return fetchContentLibraryList(page, limit, keyword);
|
||||
},
|
||||
|
||||
// 创建内容库
|
||||
async create(params: { name: string; sourceType: number }): Promise<ApiResponse<ContentLibrary>> {
|
||||
return post<ApiResponse<ContentLibrary>>('/v1/content/library', params);
|
||||
},
|
||||
|
||||
// 更新内容库
|
||||
async update(id: string, params: Partial<ContentLibrary>): Promise<ApiResponse<ContentLibrary>> {
|
||||
return put<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`, params);
|
||||
},
|
||||
|
||||
// 删除内容库
|
||||
async delete(id: string): Promise<ApiResponse<void>> {
|
||||
return del<ApiResponse<void>>(`/v1/content/library/${id}`);
|
||||
},
|
||||
|
||||
// 获取内容库详情
|
||||
async getById(id: string): Promise<ApiResponse<ContentLibrary>> {
|
||||
return get<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
import { get, post, put, del } from './request';
|
||||
import type { ApiResponse, PaginatedResponse } from '@/types/common';
|
||||
import type {
|
||||
Device,
|
||||
DeviceStats,
|
||||
DeviceTaskRecord,
|
||||
QueryDeviceParams,
|
||||
CreateDeviceParams,
|
||||
UpdateDeviceParams,
|
||||
DeviceStatus,
|
||||
ServerDevicesResponse
|
||||
} from '@/types/device';
|
||||
|
||||
const API_BASE = "/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 get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
|
||||
};
|
||||
|
||||
// 获取设备详情 - 连接到服务器/v1/devices/:id接口
|
||||
export const fetchDeviceDetail = async (id: string | number): Promise<ApiResponse<any>> => {
|
||||
return get<ApiResponse<any>>(`/v1/devices/${id}`);
|
||||
};
|
||||
|
||||
// 获取设备关联的微信账号
|
||||
export const fetchDeviceRelatedAccounts = async (id: string | number): Promise<ApiResponse<any>> => {
|
||||
return 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 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 post<ApiResponse<any>>(`/v1/devices/task-config`, config);
|
||||
};
|
||||
|
||||
// 删除设备
|
||||
export const deleteDevice = async (id: number): Promise<ApiResponse<any>> => {
|
||||
return del<ApiResponse<any>>(`/v1/devices/${id}`);
|
||||
};
|
||||
|
||||
// 设备管理API
|
||||
export const devicesApi = {
|
||||
// 获取设备列表
|
||||
async getList(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 get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
|
||||
},
|
||||
|
||||
// 获取设备二维码
|
||||
async getQRCode(accountId: string): Promise<ApiResponse<{ qrCode: string }>> {
|
||||
return post<ApiResponse<{ qrCode: string }>>('/v1/api/device/add', { accountId });
|
||||
},
|
||||
|
||||
// 通过IMEI添加设备
|
||||
async addByImei(imei: string, name: string): Promise<ApiResponse<any>> {
|
||||
return post<ApiResponse<any>>('/v1/api/device/add-by-imei', { imei, name });
|
||||
},
|
||||
|
||||
// 创建设备
|
||||
async create(params: CreateDeviceParams): Promise<ApiResponse<Device>> {
|
||||
return post<ApiResponse<Device>>(`${API_BASE}`, params);
|
||||
},
|
||||
|
||||
// 更新设备
|
||||
async update(params: UpdateDeviceParams): Promise<ApiResponse<Device>> {
|
||||
return put<ApiResponse<Device>>(`${API_BASE}/${params.id}`, params);
|
||||
},
|
||||
|
||||
// 获取设备详情
|
||||
async getById(id: string): Promise<ApiResponse<Device>> {
|
||||
return get<ApiResponse<Device>>(`${API_BASE}/${id}`);
|
||||
},
|
||||
|
||||
// 查询设备列表
|
||||
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();
|
||||
return get<ApiResponse<PaginatedResponse<Device>>>(`${API_BASE}?${queryString}`);
|
||||
},
|
||||
|
||||
// 删除设备(旧版本)
|
||||
async deleteById(id: string): Promise<ApiResponse<void>> {
|
||||
return del<ApiResponse<void>>(`${API_BASE}/${id}`);
|
||||
},
|
||||
|
||||
// 删除设备(新版本)
|
||||
async delete(id: number): Promise<ApiResponse<any>> {
|
||||
return del<ApiResponse<any>>(`/v1/devices/${id}`);
|
||||
},
|
||||
|
||||
// 重启设备
|
||||
async restart(id: string): Promise<ApiResponse<void>> {
|
||||
return post<ApiResponse<void>>(`${API_BASE}/${id}/restart`);
|
||||
},
|
||||
|
||||
// 解绑设备
|
||||
async unbind(id: string): Promise<ApiResponse<void>> {
|
||||
return post<ApiResponse<void>>(`${API_BASE}/${id}/unbind`);
|
||||
},
|
||||
|
||||
// 获取设备统计数据
|
||||
async getStats(id: string): Promise<ApiResponse<DeviceStats>> {
|
||||
return get<ApiResponse<DeviceStats>>(`${API_BASE}/${id}/stats`);
|
||||
},
|
||||
|
||||
// 获取设备任务记录
|
||||
async getTaskRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<DeviceTaskRecord>>> {
|
||||
return get<ApiResponse<PaginatedResponse<DeviceTaskRecord>>>(`${API_BASE}/${id}/tasks?page=${page}&pageSize=${pageSize}`);
|
||||
},
|
||||
|
||||
// 批量更新设备标签
|
||||
async updateTags(ids: string[], tags: string[]): Promise<ApiResponse<void>> {
|
||||
return post<ApiResponse<void>>(`${API_BASE}/tags`, { deviceIds: ids, tags });
|
||||
},
|
||||
|
||||
// 批量导出设备数据
|
||||
async exportDevices(ids: string[]): Promise<Blob> {
|
||||
const response = await fetch(`${process.env.REACT_APP_API_BASE || 'http://localhost:3000/api'}${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>>> {
|
||||
return post<ApiResponse<Record<string, DeviceStatus>>>(`${API_BASE}/status`, { deviceIds: ids });
|
||||
},
|
||||
|
||||
// 获取设备关联的微信账号
|
||||
async getRelatedAccounts(id: string | number): Promise<ApiResponse<any>> {
|
||||
return get<ApiResponse<any>>(`/v1/wechats/related-device/${id}`);
|
||||
},
|
||||
|
||||
// 获取设备操作记录
|
||||
async getHandleLogs(id: string | number, page: number = 1, limit: number = 10): Promise<ApiResponse<any>> {
|
||||
return get<ApiResponse<any>>(`/v1/devices/${id}/handle-logs?page=${page}&limit=${limit}`);
|
||||
},
|
||||
|
||||
// 更新设备任务配置
|
||||
async updateTaskConfig(config: {
|
||||
deviceId: string | number;
|
||||
autoAddFriend?: boolean;
|
||||
autoReply?: boolean;
|
||||
momentsSync?: boolean;
|
||||
aiChat?: boolean;
|
||||
}): Promise<ApiResponse<any>> {
|
||||
return post<ApiResponse<any>>(`/v1/devices/task-config`, config);
|
||||
},
|
||||
|
||||
// 获取设备任务配置
|
||||
async getTaskConfig(id: string | number): Promise<ApiResponse<any>> {
|
||||
return get<ApiResponse<any>>(`/v1/devices/${id}/task-config`);
|
||||
},
|
||||
};
|
||||
@@ -1,201 +0,0 @@
|
||||
import { get, post, put, del } from './request';
|
||||
|
||||
// 群发推送任务类型定义
|
||||
export interface GroupPushTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: number; // 1: 运行中, 2: 已暂停
|
||||
deviceCount: number;
|
||||
targetGroups: string[];
|
||||
pushCount: number;
|
||||
successCount: number;
|
||||
lastPushTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
pushInterval: number;
|
||||
maxPushPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
messageType: 'text' | 'image' | 'video' | 'link';
|
||||
messageContent: string;
|
||||
targetTags: string[];
|
||||
pushMode: 'immediate' | 'scheduled';
|
||||
scheduledTime?: string;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群发推送任务列表
|
||||
*/
|
||||
export async function fetchGroupPushTasks(): Promise<GroupPushTask[]> {
|
||||
try {
|
||||
const response = await get<ApiResponse<GroupPushTask[]>>('/v1/workspace/group-push/tasks');
|
||||
|
||||
if (response.code === 200 && Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 如果API不可用,返回模拟数据
|
||||
return getMockGroupPushTasks();
|
||||
} catch (error) {
|
||||
console.error('获取群发推送任务失败:', error);
|
||||
// 返回模拟数据作为降级方案
|
||||
return getMockGroupPushTasks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除群发推送任务
|
||||
*/
|
||||
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
|
||||
try {
|
||||
const response = await del<ApiResponse>(`/v1/workspace/group-push/tasks/${id}`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('删除群发推送任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换群发推送任务状态
|
||||
*/
|
||||
export async function toggleGroupPushTask(id: string, status: string): Promise<ApiResponse> {
|
||||
try {
|
||||
const response = await post<ApiResponse>(`/v1/workspace/group-push/tasks/${id}/toggle`, {
|
||||
status
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('切换群发推送任务状态失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制群发推送任务
|
||||
*/
|
||||
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
|
||||
try {
|
||||
const response = await post<ApiResponse>(`/v1/workspace/group-push/tasks/${id}/copy`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('复制群发推送任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建群发推送任务
|
||||
*/
|
||||
export async function createGroupPushTask(taskData: Partial<GroupPushTask>): Promise<ApiResponse> {
|
||||
try {
|
||||
const response = await post<ApiResponse>('/v1/workspace/group-push/tasks', taskData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('创建群发推送任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新群发推送任务
|
||||
*/
|
||||
export async function updateGroupPushTask(id: string, taskData: Partial<GroupPushTask>): Promise<ApiResponse> {
|
||||
try {
|
||||
const response = await put<ApiResponse>(`/v1/workspace/group-push/tasks/${id}`, taskData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('更新群发推送任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群发推送任务详情
|
||||
*/
|
||||
export async function getGroupPushTaskDetail(id: string): Promise<GroupPushTask> {
|
||||
try {
|
||||
const response = await get<ApiResponse<GroupPushTask>>(`/v1/workspace/group-push/tasks/${id}`);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw new Error(response.message || '获取任务详情失败');
|
||||
} catch (error) {
|
||||
console.error('获取群发推送任务详情失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟数据 - 当API不可用时使用
|
||||
*/
|
||||
function getMockGroupPushTasks(): GroupPushTask[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
name: '产品推广群发',
|
||||
deviceCount: 2,
|
||||
targetGroups: ['VIP客户群', '潜在客户群'],
|
||||
pushCount: 156,
|
||||
successCount: 142,
|
||||
lastPushTime: '2025-02-06 13:12:35',
|
||||
createTime: '2024-11-20 19:04:14',
|
||||
creator: 'admin',
|
||||
status: 1, // 运行中
|
||||
pushInterval: 60,
|
||||
maxPushPerDay: 200,
|
||||
timeRange: { start: '09:00', end: '21:00' },
|
||||
messageType: 'text',
|
||||
messageContent: '新品上市,限时优惠!点击查看详情...',
|
||||
targetTags: ['VIP客户', '高意向'],
|
||||
pushMode: 'immediate',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '活动通知推送',
|
||||
deviceCount: 1,
|
||||
targetGroups: ['活动群', '推广群'],
|
||||
pushCount: 89,
|
||||
successCount: 78,
|
||||
lastPushTime: '2024-03-04 14:09:35',
|
||||
createTime: '2024-03-04 14:29:04',
|
||||
creator: 'manager',
|
||||
status: 2, // 已暂停
|
||||
pushInterval: 120,
|
||||
maxPushPerDay: 100,
|
||||
timeRange: { start: '10:00', end: '20:00' },
|
||||
messageType: 'image',
|
||||
messageContent: '活动海报.jpg',
|
||||
targetTags: ['活跃用户', '中意向'],
|
||||
pushMode: 'scheduled',
|
||||
scheduledTime: '2024-03-05 10:00:00',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '新客户欢迎消息',
|
||||
deviceCount: 3,
|
||||
targetGroups: ['新客户群', '体验群'],
|
||||
pushCount: 234,
|
||||
successCount: 218,
|
||||
lastPushTime: '2025-02-06 15:30:22',
|
||||
createTime: '2024-12-01 09:15:30',
|
||||
creator: 'admin',
|
||||
status: 1, // 运行中
|
||||
pushInterval: 30,
|
||||
maxPushPerDay: 300,
|
||||
timeRange: { start: '08:00', end: '22:00' },
|
||||
messageType: 'text',
|
||||
messageContent: '欢迎加入我们的大家庭!这里有最新的产品信息和优惠活动...',
|
||||
targetTags: ['新客户', '欢迎'],
|
||||
pushMode: 'immediate',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// 导出所有API相关的内容
|
||||
export * from './auth';
|
||||
export * from './utils';
|
||||
export * from './interceptors';
|
||||
export * from './request';
|
||||
|
||||
// 导出现有的API模块
|
||||
export * from './devices';
|
||||
export * from './scenarios';
|
||||
export * from './wechat-accounts';
|
||||
export * from './trafficDistribution';
|
||||
|
||||
// 默认导出request实例
|
||||
export { default as request } from './request';
|
||||
@@ -1,152 +0,0 @@
|
||||
import { refreshAuthToken, isTokenExpiringSoon, clearToken } from './utils';
|
||||
|
||||
// Token过期处理
|
||||
export const handleTokenExpired = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 清除本地存储
|
||||
clearToken();
|
||||
|
||||
// 跳转到登录页面
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// 显示API错误,但不会重定向
|
||||
export const showApiError = (error: any, defaultMessage: string = '请求失败') => {
|
||||
if (typeof window === 'undefined') return; // 服务端不处理
|
||||
|
||||
let errorMessage = defaultMessage;
|
||||
|
||||
// 尝试从各种可能的错误格式中获取消息
|
||||
if (error) {
|
||||
if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message || defaultMessage;
|
||||
} else if (typeof error === 'object') {
|
||||
// 尝试从API响应中获取错误消息
|
||||
errorMessage = error.msg || error.message || error.error || defaultMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误消息
|
||||
console.error('API错误:', errorMessage);
|
||||
|
||||
// 这里可以集成toast系统
|
||||
// 由于toast context在组件层级,这里暂时用console
|
||||
// 实际项目中可以通过事件系统或其他方式集成
|
||||
};
|
||||
|
||||
// 请求拦截器 - 检查token是否需要刷新
|
||||
export const requestInterceptor = async (): Promise<boolean> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查token是否即将过期
|
||||
if (isTokenExpiringSoon()) {
|
||||
try {
|
||||
console.log('Token即将过期,尝试刷新...');
|
||||
const success = await refreshAuthToken();
|
||||
if (!success) {
|
||||
console.log('Token刷新失败,需要重新登录');
|
||||
handleTokenExpired();
|
||||
return false;
|
||||
}
|
||||
console.log('Token刷新成功');
|
||||
} catch (error) {
|
||||
console.error('Token刷新过程中出错:', error);
|
||||
handleTokenExpired();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 响应拦截器 - 处理常见错误
|
||||
export const responseInterceptor = (response: any, result: any) => {
|
||||
// 处理401未授权
|
||||
if (response?.status === 401 || (result && result.code === 401)) {
|
||||
handleTokenExpired();
|
||||
throw new Error('登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
// 处理403禁止访问
|
||||
if (response?.status === 403 || (result && result.code === 403)) {
|
||||
throw new Error('没有权限访问此资源');
|
||||
}
|
||||
|
||||
// 处理404未找到
|
||||
if (response?.status === 404 || (result && result.code === 404)) {
|
||||
throw new Error('请求的资源不存在');
|
||||
}
|
||||
|
||||
// 处理500服务器错误
|
||||
if (response?.status >= 500 || (result && result.code >= 500)) {
|
||||
throw new Error('服务器内部错误,请稍后重试');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 错误拦截器 - 统一错误处理
|
||||
export const errorInterceptor = (error: any) => {
|
||||
console.error('API请求错误:', error);
|
||||
|
||||
let errorMessage = '网络请求失败,请稍后重试';
|
||||
|
||||
if (error) {
|
||||
if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
} else if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
errorMessage = '网络连接失败,请检查网络设置';
|
||||
} else if (error.name === 'AbortError') {
|
||||
errorMessage = '请求已取消';
|
||||
}
|
||||
}
|
||||
|
||||
showApiError(error, errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
};
|
||||
|
||||
// 网络状态监听
|
||||
export const setupNetworkListener = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleOnline = () => {
|
||||
console.log('网络已连接');
|
||||
// 可以在这里添加网络恢复后的处理逻辑
|
||||
};
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log('网络已断开');
|
||||
showApiError(null, '网络连接已断开,请检查网络设置');
|
||||
};
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
};
|
||||
|
||||
// 初始化拦截器
|
||||
export const initInterceptors = () => {
|
||||
// 设置网络监听
|
||||
const cleanupNetwork = setupNetworkListener();
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
if (cleanupNetwork) {
|
||||
cleanupNetwork();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
import { get, post, del } from './request';
|
||||
import {
|
||||
MomentsSyncTask,
|
||||
CreateMomentsSyncData,
|
||||
UpdateMomentsSyncData,
|
||||
SyncRecord,
|
||||
ApiResponse,
|
||||
PaginatedResponse
|
||||
} from '@/types/moments-sync';
|
||||
|
||||
// 获取朋友圈同步任务列表
|
||||
export async function fetchMomentsSyncTasks(): Promise<MomentsSyncTask[]> {
|
||||
try {
|
||||
const res = await get<ApiResponse<PaginatedResponse<MomentsSyncTask>>>('/v1/workbench/list?type=2&page=1&limit=100');
|
||||
|
||||
if (res.code === 200 && res.data) {
|
||||
return res.data.list || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('获取朋友圈同步任务失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个任务详情
|
||||
export async function fetchMomentsSyncTaskDetail(id: string): Promise<MomentsSyncTask | null> {
|
||||
try {
|
||||
const res = await get<ApiResponse<MomentsSyncTask>>(`/v1/workbench/detail?id=${id}`);
|
||||
if (res.code === 200 && res.data) {
|
||||
return res.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('获取任务详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建朋友圈同步任务
|
||||
export async function createMomentsSyncTask(data: CreateMomentsSyncData): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/create', {
|
||||
...data,
|
||||
type: 2 // 朋友圈同步类型
|
||||
});
|
||||
}
|
||||
|
||||
// 更新朋友圈同步任务
|
||||
export async function updateMomentsSyncTask(data: UpdateMomentsSyncData): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/update', {
|
||||
...data,
|
||||
type: 2 // 朋友圈同步类型
|
||||
});
|
||||
}
|
||||
|
||||
// 删除朋友圈同步任务
|
||||
export async function deleteMomentsSyncTask(id: string): Promise<ApiResponse> {
|
||||
return del('/v1/workbench/delete', { params: { id } });
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export async function toggleMomentsSyncTask(id: string, status: string): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/update-status', { id, status });
|
||||
}
|
||||
|
||||
// 复制朋友圈同步任务
|
||||
export async function copyMomentsSyncTask(id: string): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/copy', { id });
|
||||
}
|
||||
|
||||
// 获取同步记录
|
||||
export async function fetchSyncRecords(
|
||||
workbenchId: string,
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
keyword?: string
|
||||
): Promise<PaginatedResponse<SyncRecord>> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
workbenchId,
|
||||
page: page.toString(),
|
||||
limit: limit.toString()
|
||||
});
|
||||
|
||||
if (keyword) {
|
||||
params.append('keyword', keyword);
|
||||
}
|
||||
|
||||
const res = await get<ApiResponse<PaginatedResponse<SyncRecord>>>(`/v1/workbench/sync-records?${params.toString()}`);
|
||||
|
||||
if (res.code === 200 && res.data) {
|
||||
return res.data;
|
||||
}
|
||||
return { list: [], total: 0, page, limit };
|
||||
} catch (error) {
|
||||
console.error('获取同步记录失败:', error);
|
||||
return { list: [], total: 0, page, limit };
|
||||
}
|
||||
}
|
||||
|
||||
// 手动同步
|
||||
export async function syncMoments(id: string): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/sync', { id });
|
||||
}
|
||||
|
||||
// 同步所有任务
|
||||
export async function syncAllMoments(): Promise<ApiResponse> {
|
||||
return post('/v1/workbench/sync-all', { type: 2 });
|
||||
}
|
||||
|
||||
export type { MomentsSyncTask, SyncRecord, CreateMomentsSyncData };
|
||||
@@ -1,73 +0,0 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { requestInterceptor, responseInterceptor, errorInterceptor } from './interceptors';
|
||||
|
||||
// 创建axios实例
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: process.env.REACT_APP_API_BASE_URL || 'https://ckbapi.quwanzhi.com',
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
async (config) => {
|
||||
// 检查token是否需要刷新
|
||||
if (config.headers.Authorization) {
|
||||
const shouldContinue = await requestInterceptor();
|
||||
if (!shouldContinue) {
|
||||
throw new Error('请求被拦截,需要重新登录');
|
||||
}
|
||||
}
|
||||
|
||||
// 添加token到请求头
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// 处理响应数据
|
||||
const result = response.data;
|
||||
const processedResult = responseInterceptor(response, result);
|
||||
return processedResult;
|
||||
},
|
||||
(error) => {
|
||||
// 统一错误处理
|
||||
return errorInterceptor(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 封装GET请求
|
||||
export const get = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return request.get(url, config);
|
||||
};
|
||||
|
||||
// 封装POST请求
|
||||
export const post = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return request.post(url, data, config);
|
||||
};
|
||||
|
||||
// 封装PUT请求
|
||||
export const put = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return request.put(url, data, config);
|
||||
};
|
||||
|
||||
// 封装DELETE请求
|
||||
export const del = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return request.delete(url, config);
|
||||
};
|
||||
|
||||
// 导出request实例
|
||||
export { request };
|
||||
export default request;
|
||||
@@ -1,311 +0,0 @@
|
||||
import { get, del, post,put } from './request';
|
||||
import type { ApiResponse } from '@/types/common';
|
||||
|
||||
// 服务器返回的场景数据类型
|
||||
export interface SceneItem {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
status: number;
|
||||
createTime: number;
|
||||
updateTime: number | null;
|
||||
deleteTime: number | null;
|
||||
}
|
||||
|
||||
// 前端使用的场景数据类型
|
||||
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;
|
||||
}
|
||||
|
||||
// 任务类型
|
||||
export interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
status: number;
|
||||
stats: {
|
||||
devices: number;
|
||||
acquired: number;
|
||||
added: number;
|
||||
};
|
||||
lastUpdated: string;
|
||||
executionTime: string;
|
||||
nextExecutionTime: string;
|
||||
trend: { date: string; customers: number }[];
|
||||
}
|
||||
|
||||
// 消息内容类型
|
||||
export interface MessageContent {
|
||||
id: string;
|
||||
type: string; // "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group" 等
|
||||
content?: string;
|
||||
intervalUnit?: "seconds" | "minutes";
|
||||
sendInterval?: number;
|
||||
// 其他可选字段
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 每天的消息计划
|
||||
export interface MessagePlan {
|
||||
day: number;
|
||||
messages: MessageContent[];
|
||||
}
|
||||
|
||||
// 海报类型
|
||||
export interface Poster {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
// 标签类型
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// textUrl类型
|
||||
export interface TextUrl {
|
||||
apiKey: string;
|
||||
originalString?: string;
|
||||
sign?: string;
|
||||
fullUrl: string;
|
||||
}
|
||||
|
||||
// 计划详情类型
|
||||
export interface PlanDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
scenario: number;
|
||||
scenarioTags: Tag[];
|
||||
customTags: Tag[];
|
||||
posters: Poster[];
|
||||
device: string[];
|
||||
enabled: boolean;
|
||||
addInterval: number;
|
||||
remarkFormat: string;
|
||||
endTime: string;
|
||||
greeting: string;
|
||||
startTime: string;
|
||||
remarkType: string;
|
||||
addFriendInterval: number;
|
||||
messagePlans: MessagePlan[];
|
||||
sceneId: number | string;
|
||||
userId: number;
|
||||
companyId: number;
|
||||
status: number;
|
||||
apiKey: string;
|
||||
wxMinAppSrc?: any;
|
||||
textUrl: TextUrl;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取获客场景列表
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @returns 获客场景列表
|
||||
*/
|
||||
export const fetchScenes = async (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
} = {}): Promise<ApiResponse<SceneItem[]>> => {
|
||||
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 get<ApiResponse<SceneItem[]>>(`/v1/plan/scenes?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching scenes:", error);
|
||||
// 返回一个错误响应
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取场景列表失败",
|
||||
data: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取场景详情
|
||||
*
|
||||
* @param id 场景ID
|
||||
* @returns 场景详情
|
||||
*/
|
||||
export const fetchSceneDetail = async (id: string | number): Promise<ApiResponse<SceneItem>> => {
|
||||
try {
|
||||
return await get<ApiResponse<SceneItem>>(`/v1/plan/scenes/${id}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching scene detail:", error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取场景详情失败",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取场景名称
|
||||
*
|
||||
* @param channel 场景标识
|
||||
* @returns 场景名称
|
||||
*/
|
||||
export const fetchSceneName = async (channel: string): Promise<ApiResponse<{ name: string }>> => {
|
||||
try {
|
||||
return await get<ApiResponse<{ name: string }>>(`/v1/plan/scenes-detail?id=${channel}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching scene name:", error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取场景名称失败",
|
||||
data: { name: channel }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取计划列表
|
||||
*
|
||||
* @param channel 场景标识
|
||||
* @param page 页码
|
||||
* @param pageSize 每页数量
|
||||
* @returns 计划列表
|
||||
*/
|
||||
export const fetchPlanList = async (
|
||||
channel: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<ApiResponse<{ list: Task[]; total: number }>> => {
|
||||
try {
|
||||
return await get<ApiResponse<{ list: Task[]; total: number }>>(
|
||||
`/v1/plan/list?sceneId=${channel}&page=${page}&pageSize=${pageSize}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error fetching plan list:", error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取计划列表失败",
|
||||
data: { list: [], total: 0 }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 复制计划
|
||||
*
|
||||
* @param planId 计划ID
|
||||
* @returns 复制结果
|
||||
*/
|
||||
export const copyPlan = async (planId: string): Promise<ApiResponse<any>> => {
|
||||
try {
|
||||
return await get<ApiResponse<any>>(`/v1/plan/copy?planId=${planId}`);
|
||||
} catch (error) {
|
||||
console.error("Error copying plan:", error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: "复制计划失败",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除计划
|
||||
*
|
||||
* @param planId 计划ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export const deletePlan = async (planId: string): Promise<ApiResponse<any>> => {
|
||||
try {
|
||||
return await del<ApiResponse<any>>(`/v1/plan/delete?planId=${planId}`);
|
||||
} catch (error) {
|
||||
console.error("Error deleting plan:", error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: "删除计划失败",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取计划详情
|
||||
*
|
||||
* @param planId 计划ID
|
||||
* @returns 计划详情
|
||||
*/
|
||||
export const fetchPlanDetail = async (planId: string): Promise<ApiResponse<PlanDetail>> => {
|
||||
try {
|
||||
return await get<ApiResponse<PlanDetail>>(`/v1/plan/detail?planId=${planId}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching plan detail:", error);
|
||||
return {
|
||||
code: 500,
|
||||
msg: "获取计划详情失败",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将服务器返回的场景数据转换为前端展示需要的格式
|
||||
*
|
||||
* @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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const getPlanScenes = () => get<any>('/v1/plan/scenes');
|
||||
|
||||
export async function createScenarioPlan(data: any) {
|
||||
return post('/v1/plan/create', data);
|
||||
}
|
||||
|
||||
// 编辑计划
|
||||
export async function updateScenarioPlan(planId: number | string, data: any) {
|
||||
return await put(`/v1/plan/update?planId=${planId}`, data);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { get, post, put, del } from './request';
|
||||
import type { ApiResponse } from '@/types/common';
|
||||
|
||||
// 工作台任务类型
|
||||
export enum WorkbenchTaskType {
|
||||
MOMENTS_SYNC = 1, // 朋友圈同步
|
||||
GROUP_PUSH = 2, // 社群推送
|
||||
AUTO_LIKE = 3, // 自动点赞
|
||||
AUTO_GROUP = 4, // 自动建群
|
||||
TRAFFIC_DISTRIBUTION = 5, // 流量分发
|
||||
}
|
||||
|
||||
// 工作台任务状态
|
||||
export enum WorkbenchTaskStatus {
|
||||
PENDING = 0, // 待处理
|
||||
RUNNING = 1, // 运行中
|
||||
PAUSED = 2, // 已暂停
|
||||
COMPLETED = 3, // 已完成
|
||||
FAILED = 4, // 失败
|
||||
}
|
||||
|
||||
// 账号类型
|
||||
export interface Account {
|
||||
id: string;
|
||||
userName: string;
|
||||
realName: string;
|
||||
nickname: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// 账号列表响应类型
|
||||
export interface AccountListResponse {
|
||||
list: Account[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// 流量池类型
|
||||
export interface TrafficPool {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
description?: string;
|
||||
deviceIds: string[];
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
// 流量池列表响应类型
|
||||
export interface TrafficPoolListResponse {
|
||||
list: TrafficPool[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// 流量分发规则类型
|
||||
export interface DistributionRule {
|
||||
id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
status: number;
|
||||
autoStart: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
companyId: number;
|
||||
config?: {
|
||||
id: number;
|
||||
workbenchId: number;
|
||||
distributeType: number; // 1-均分配, 2-优先级分配, 3-比例分配
|
||||
maxPerDay: number; // 每日最大分配量
|
||||
timeType: number; // 1-全天, 2-自定义时间段
|
||||
startTime: string; // 开始时间
|
||||
endTime: string; // 结束时间
|
||||
account: string[]; // 账号列表
|
||||
devices: string[]; // 设备列表
|
||||
pools: string[]; // 流量池列表
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
lastUpdated: string;
|
||||
total: {
|
||||
dailyAverage: number; // 日均分发量
|
||||
totalAccounts: number; // 分发账户总数
|
||||
deviceCount: number; // 分发设备数量
|
||||
poolCount: number; // 流量池数量
|
||||
totalUsers: number; // 总用户数
|
||||
};
|
||||
};
|
||||
auto_like?: any;
|
||||
moments_sync?: any;
|
||||
group_push?: any;
|
||||
}
|
||||
|
||||
// 流量分发列表响应类型
|
||||
export interface TrafficDistributionListResponse {
|
||||
list: DistributionRule[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号列表
|
||||
* @param params 查询参数
|
||||
* @returns 账号列表
|
||||
*/
|
||||
export const fetchAccountList = async (params: {
|
||||
page?: number; // 页码
|
||||
limit?: number; // 每页数量
|
||||
keyword?: string; // 搜索关键词
|
||||
} = {}): Promise<ApiResponse<AccountListResponse>> => {
|
||||
const { page = 1, limit = 10, keyword = "" } = params;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('page', page.toString());
|
||||
queryParams.append('limit', limit.toString());
|
||||
|
||||
if (keyword) {
|
||||
queryParams.append('keyword', keyword);
|
||||
}
|
||||
|
||||
return get<ApiResponse<AccountListResponse>>(`/v1/workbench/account-list?${queryParams.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取设备标签(流量池)列表
|
||||
* @param params 查询参数
|
||||
* @returns 流量池列表
|
||||
*/
|
||||
export const fetchDeviceLabels = async (params: {
|
||||
deviceIds: string[]; // 设备ID列表
|
||||
page?: number; // 页码
|
||||
pageSize?: number; // 每页数量
|
||||
keyword?: string; // 搜索关键词
|
||||
}): Promise<ApiResponse<TrafficPoolListResponse>> => {
|
||||
const { deviceIds, page = 1, pageSize = 10, keyword = "" } = params;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('deviceIds', deviceIds.join(','));
|
||||
queryParams.append('page', page.toString());
|
||||
queryParams.append('pageSize', pageSize.toString());
|
||||
|
||||
if (keyword) {
|
||||
queryParams.append('keyword', keyword);
|
||||
}
|
||||
|
||||
return get<ApiResponse<TrafficPoolListResponse>>(`/v1/workbench/device-labels?${queryParams.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取流量分发规则列表
|
||||
* @param params 查询参数
|
||||
* @returns 流量分发规则列表
|
||||
*/
|
||||
export const fetchDistributionRules = async (params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
} = {}): Promise<ApiResponse<TrafficDistributionListResponse>> => {
|
||||
const { page = 1, limit = 10, keyword = "" } = params;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('type', WorkbenchTaskType.TRAFFIC_DISTRIBUTION.toString());
|
||||
queryParams.append('page', page.toString());
|
||||
queryParams.append('limit', limit.toString());
|
||||
|
||||
if (keyword) {
|
||||
queryParams.append('keyword', keyword);
|
||||
}
|
||||
|
||||
return get<ApiResponse<TrafficDistributionListResponse>>(`/v1/workbench/list?${queryParams.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取流量分发规则详情
|
||||
* @param id 规则ID
|
||||
* @returns 流量分发规则详情
|
||||
*/
|
||||
export const fetchDistributionRuleDetail = async (id: string): Promise<ApiResponse<DistributionRule>> => {
|
||||
return get<ApiResponse<DistributionRule>>(`/v1/workbench/detail?id=${id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建流量分发规则
|
||||
* @param params 创建参数
|
||||
* @returns 创建结果
|
||||
*/
|
||||
export const createDistributionRule = async (params: any): Promise<ApiResponse<{ id: string }>> => {
|
||||
return post<ApiResponse<{ id: string }>>('/v1/workbench/create', {
|
||||
...params,
|
||||
type: WorkbenchTaskType.TRAFFIC_DISTRIBUTION
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新流量分发规则
|
||||
* @param id 规则ID
|
||||
* @param params 更新参数
|
||||
* @returns 更新结果
|
||||
*/
|
||||
export const updateDistributionRule = async (id : string, params: any): Promise<ApiResponse<any>> => {
|
||||
return post<ApiResponse<any>>(`/v1/workbench/update`, {
|
||||
id: id,
|
||||
...params,
|
||||
type: WorkbenchTaskType.TRAFFIC_DISTRIBUTION
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除流量分发规则
|
||||
* @param id 规则ID
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export const deleteDistributionRule = async (id: string): Promise<ApiResponse<any>> => {
|
||||
return del<ApiResponse<any>>(`/v1/workbench/delete?id=${id}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 启动/暂停流量分发规则
|
||||
* @param id 规则ID
|
||||
* @param status 状态:1-启动,0-暂停
|
||||
* @returns 操作结果
|
||||
*/
|
||||
export const toggleDistributionRuleStatus = async (id: string, status: 0 | 1): Promise<ApiResponse<any>> => {
|
||||
return post<ApiResponse<any>>('/v1/workbench/update-status', { id, status });
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { request } from './request';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
// 上传图片,返回图片地址
|
||||
export async function uploadImage(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response: AxiosResponse<any> = await request.post('/v1/attachment/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
const res = response.data || response;
|
||||
if (res?.url) {
|
||||
return res.url;
|
||||
}
|
||||
throw new Error(res?.msg || '图片上传失败');
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { authApi } from './auth';
|
||||
import { get, post, put, del } from './request';
|
||||
import type { ApiResponse, PaginatedResponse } from '@/types/common';
|
||||
// 设置token到localStorage
|
||||
export const setToken = (token: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取token
|
||||
export const getToken = (): string | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 清除token
|
||||
export const clearToken = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userInfo');
|
||||
localStorage.removeItem('token_expired');
|
||||
localStorage.removeItem('s2_accountId');
|
||||
}
|
||||
};
|
||||
|
||||
// 验证token是否有效
|
||||
export const validateToken = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await authApi.getUserInfo();
|
||||
return response.code === 200;
|
||||
} catch (error) {
|
||||
console.error('Token验证失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新令牌
|
||||
export const refreshAuthToken = async (): Promise<boolean> => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authApi.refreshToken();
|
||||
if (response.code === 200 && response.data?.token) {
|
||||
setToken(response.data.token);
|
||||
// 更新过期时间
|
||||
if (response.data.token_expired) {
|
||||
localStorage.setItem('token_expired', response.data.token_expired);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('刷新Token失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查token是否即将过期
|
||||
export const isTokenExpiringSoon = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokenExpired = localStorage.getItem('token_expired');
|
||||
if (!tokenExpired) return true;
|
||||
|
||||
try {
|
||||
const expiredTime = new Date(tokenExpired).getTime();
|
||||
const currentTime = new Date().getTime();
|
||||
// 提前10分钟认为即将过期
|
||||
return currentTime >= (expiredTime - 10 * 60 * 1000);
|
||||
} catch (error) {
|
||||
console.error('解析token过期时间失败:', error);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查token是否已过期
|
||||
export const isTokenExpired = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokenExpired = localStorage.getItem('token_expired');
|
||||
if (!tokenExpired) return true;
|
||||
|
||||
try {
|
||||
const expiredTime = new Date(tokenExpired).getTime();
|
||||
const currentTime = new Date().getTime();
|
||||
// 提前5分钟认为过期,给刷新留出时间
|
||||
return currentTime >= (expiredTime - 5 * 60 * 1000);
|
||||
} catch (error) {
|
||||
console.error('解析token过期时间失败:', error);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 请求去重器
|
||||
class RequestDeduplicator {
|
||||
private pendingRequests = new Map<string, Promise<any>>();
|
||||
|
||||
async deduplicate<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
|
||||
if (this.pendingRequests.has(key)) {
|
||||
return this.pendingRequests.get(key)!;
|
||||
}
|
||||
|
||||
const promise = requestFn();
|
||||
this.pendingRequests.set(key, promise);
|
||||
|
||||
try {
|
||||
const result = await promise;
|
||||
return result;
|
||||
} finally {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
return this.pendingRequests.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 请求取消管理器
|
||||
class RequestCancelManager {
|
||||
private abortControllers = new Map<string, AbortController>();
|
||||
|
||||
createController(key: string): AbortController {
|
||||
// 取消之前的请求
|
||||
this.cancelRequest(key);
|
||||
|
||||
const controller = new AbortController();
|
||||
this.abortControllers.set(key, controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
cancelRequest(key: string): void {
|
||||
const controller = this.abortControllers.get(key);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
this.abortControllers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
cancelAllRequests(): void {
|
||||
this.abortControllers.forEach(controller => controller.abort());
|
||||
this.abortControllers.clear();
|
||||
}
|
||||
|
||||
getController(key: string): AbortController | undefined {
|
||||
return this.abortControllers.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const requestDeduplicator = new RequestDeduplicator();
|
||||
export const requestCancelManager = new RequestCancelManager();
|
||||
|
||||
/**
|
||||
* 通用文件上传方法(支持图片、文件)
|
||||
* @param {File} file - 要上传的文件对象
|
||||
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
|
||||
* @returns {Promise<string>} - 上传成功后返回文件url
|
||||
*/
|
||||
export async function uploadFile(file: File, uploadUrl: string = '/v1/attachment/upload'): Promise<string> {
|
||||
try {
|
||||
// 创建 FormData 对象用于文件上传
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// 使用 post 方法上传文件,设置正确的 Content-Type
|
||||
const res = await post(uploadUrl, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
// 检查响应结果
|
||||
if (res?.code === 200 && res?.data?.url) {
|
||||
return res.data.url;
|
||||
} else {
|
||||
throw new Error(res?.msg || '文件上传失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message || '文件上传失败');
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { get, post, put } from './request';
|
||||
import type { ApiResponse } from '@/types/common';
|
||||
|
||||
// 添加接口返回数据类型定义
|
||||
interface WechatAccountSummary {
|
||||
accountAge: string;
|
||||
activityLevel: {
|
||||
allTimes: number;
|
||||
dayTimes: number;
|
||||
};
|
||||
accountWeight: {
|
||||
scope: number;
|
||||
ageWeight: number;
|
||||
activityWeigth: number;
|
||||
restrictWeight: number;
|
||||
realNameWeight: number;
|
||||
};
|
||||
statistics: {
|
||||
todayAdded: number;
|
||||
addLimit: number;
|
||||
};
|
||||
restrictions: {
|
||||
id: number;
|
||||
level: string;
|
||||
reason: string;
|
||||
date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface QueryWechatAccountParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信账号列表
|
||||
* @param params 查询参数
|
||||
* @returns 微信账号列表响应
|
||||
*/
|
||||
export const fetchWechatAccountList = async (params: QueryWechatAccountParams = {}): Promise<ApiResponse<{
|
||||
list: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}>> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
// 添加查询参数
|
||||
if (params.page) queryParams.append('page', params.page.toString());
|
||||
if (params.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params.keyword) queryParams.append('nickname', params.keyword); // 使用nickname作为关键词搜索参数
|
||||
if (params.sort) queryParams.append('sort', params.sort);
|
||||
if (params.order) queryParams.append('order', params.order);
|
||||
|
||||
// 发起API请求
|
||||
return get<ApiResponse<{
|
||||
list: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}>>(`/v1/wechats?${queryParams.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新微信账号状态
|
||||
* @returns 刷新结果
|
||||
*/
|
||||
export const refreshWechatAccounts = async (): Promise<ApiResponse<any>> => {
|
||||
return put<ApiResponse<any>>('/v1/wechats/refresh', {});
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行微信好友转移
|
||||
* @param sourceId 源微信账号ID
|
||||
* @param targetId 目标微信账号ID
|
||||
* @returns 转移结果
|
||||
*/
|
||||
export const transferWechatFriends = async (sourceId: string | number, targetId: string | number): Promise<ApiResponse<any>> => {
|
||||
return post<ApiResponse<any>>('/v1/wechats/transfer-friends', {
|
||||
source_id: sourceId,
|
||||
target_id: targetId
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 将服务器返回的微信账号数据转换为前端使用的格式
|
||||
* @param serverAccount 服务器返回的微信账号数据
|
||||
* @returns 前端使用的微信账号数据
|
||||
*/
|
||||
export const transformWechatAccount = (serverAccount: any): any => {
|
||||
// 从deviceInfo中提取设备信息
|
||||
let deviceName = '';
|
||||
|
||||
if (serverAccount.deviceInfo) {
|
||||
// 尝试解析设备信息字符串
|
||||
const deviceInfo = serverAccount.deviceInfo.split(' ');
|
||||
if (deviceInfo.length > 0) {
|
||||
// 提取设备名称
|
||||
if (deviceInfo.length > 1) {
|
||||
deviceName = deviceInfo[1] ? deviceInfo[1].replace(/[()]/g, '').trim() : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果没有设备名称,使用备用名称
|
||||
if (!deviceName) {
|
||||
deviceName = serverAccount.deviceMemo || '未命名设备';
|
||||
}
|
||||
|
||||
// 假设每天最多可添加20个好友
|
||||
const maxDailyAdds = 20;
|
||||
const todayAdded = serverAccount.todayNewFriendCount || 0;
|
||||
|
||||
return {
|
||||
id: serverAccount.id.toString(),
|
||||
avatar: serverAccount.avatar || '',
|
||||
nickname: serverAccount.nickname || serverAccount.accountNickname || '未命名',
|
||||
wechatId: serverAccount.wechatId || '',
|
||||
deviceId: serverAccount.deviceId || '',
|
||||
deviceName,
|
||||
friendCount: serverAccount.totalFriend || 0,
|
||||
todayAdded,
|
||||
remainingAdds: serverAccount.canAddFriendCount || (maxDailyAdds - todayAdded),
|
||||
maxDailyAdds,
|
||||
status: serverAccount.wechatStatus === 1 ? "normal" : "abnormal" as "normal" | "abnormal",
|
||||
lastActive: new Date().toLocaleString() // 服务端未提供,使用当前时间
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取微信好友列表
|
||||
* @param wechatId 微信账号ID
|
||||
* @param page 页码
|
||||
* @param pageSize 每页数量
|
||||
* @param searchQuery 搜索关键词
|
||||
* @returns 好友列表数据
|
||||
*/
|
||||
export const fetchWechatFriends = async (wechatId: string, page: number = 1, pageSize: number = 20, searchQuery: string = ''): Promise<ApiResponse<{
|
||||
list: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}>> => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('page', page.toString());
|
||||
queryParams.append('limit', pageSize.toString());
|
||||
if (searchQuery) {
|
||||
queryParams.append('search', searchQuery);
|
||||
}
|
||||
|
||||
return get<ApiResponse<{
|
||||
list: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}>>(`/v1/wechats/${wechatId}/friends?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
console.error("获取好友列表失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取微信账号概览信息
|
||||
* @param id 微信账号ID
|
||||
* @returns 微信账号概览信息
|
||||
*/
|
||||
export const fetchWechatAccountSummary = async (wechatId: string): Promise<ApiResponse<WechatAccountSummary>> => {
|
||||
try {
|
||||
return get<ApiResponse<WechatAccountSummary>>(`/v1/wechats/${wechatId}/summary`);
|
||||
} catch (error) {
|
||||
console.error("获取账号概览失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取好友详情信息
|
||||
* @param wechatId 微信账号ID
|
||||
* @param friendId 好友ID
|
||||
* @returns 好友详情信息
|
||||
*/
|
||||
export interface WechatFriendDetail {
|
||||
id: number;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
region: string;
|
||||
wechatId: string;
|
||||
addDate: string;
|
||||
tags: string[];
|
||||
memo: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export const fetchWechatFriendDetail = async (wechatId: string): Promise<ApiResponse<WechatFriendDetail>> => {
|
||||
try {
|
||||
return get<ApiResponse<WechatFriendDetail>>(`/v1/wechats/${wechatId}/friend-detail`);
|
||||
} catch (error) {
|
||||
console.error("获取好友详情失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface BackButtonProps {
|
||||
/** 返回按钮的样式变体 */
|
||||
variant?: 'icon' | 'button' | 'text';
|
||||
/** 自定义返回逻辑,如果不提供则使用navigate(-1) */
|
||||
onBack?: () => void;
|
||||
/** 按钮文本,仅在button和text变体时使用 */
|
||||
text?: string;
|
||||
/** 自定义CSS类名 */
|
||||
className?: string;
|
||||
/** 图标大小 */
|
||||
iconSize?: number;
|
||||
/** 是否显示图标 */
|
||||
showIcon?: boolean;
|
||||
/** 自定义图标 */
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用返回上一页按钮组件
|
||||
* 使用React Router的navigate方法实现返回功能
|
||||
*/
|
||||
export const BackButton: React.FC<BackButtonProps> = ({
|
||||
variant = 'icon',
|
||||
onBack,
|
||||
text = '返回',
|
||||
className = '',
|
||||
iconSize = 6,
|
||||
showIcon = true,
|
||||
icon
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultIcon = variant === 'icon' ? (
|
||||
<ChevronLeft className={`h-${iconSize} w-${iconSize}`} />
|
||||
) : (
|
||||
<ArrowLeft className={`h-${iconSize} w-${iconSize}`} />
|
||||
);
|
||||
|
||||
const buttonIcon = icon || (showIcon ? defaultIcon : null);
|
||||
|
||||
switch (variant) {
|
||||
case 'icon':
|
||||
return (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`p-2 hover:bg-gray-100 rounded-lg transition-colors ${className}`}
|
||||
title="返回上一页"
|
||||
>
|
||||
{buttonIcon}
|
||||
</button>
|
||||
);
|
||||
|
||||
case 'button':
|
||||
return (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors ${className}`}
|
||||
>
|
||||
{buttonIcon}
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`flex items-center gap-2 text-blue-600 hover:text-blue-700 transition-colors ${className}`}
|
||||
>
|
||||
{buttonIcon}
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Home, Users, LayoutGrid, User } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: "home",
|
||||
name: "首页",
|
||||
href: "/",
|
||||
icon: Home,
|
||||
active: (pathname: string) => pathname === "/",
|
||||
},
|
||||
{
|
||||
id: "scenarios",
|
||||
name: "场景获客",
|
||||
href: "/scenarios",
|
||||
icon: Users,
|
||||
active: (pathname: string) => pathname.startsWith("/scenarios"),
|
||||
},
|
||||
{
|
||||
id: "workspace",
|
||||
name: "工作台",
|
||||
href: "/workspace",
|
||||
icon: LayoutGrid,
|
||||
active: (pathname: string) => pathname.startsWith("/workspace"),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
name: "我的",
|
||||
href: "/profile",
|
||||
icon: User,
|
||||
active: (pathname: string) => pathname.startsWith("/profile"),
|
||||
},
|
||||
];
|
||||
|
||||
interface BottomNavProps {
|
||||
activeTab?: string;
|
||||
}
|
||||
|
||||
export default function BottomNav({ activeTab }: BottomNavProps) {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-area-pb">
|
||||
<div className="flex justify-around items-center h-16 max-w-md mx-auto">
|
||||
{navItems.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const isActive = activeTab ? activeTab === item.id : item.active(location.pathname);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
|
||||
isActive ? "text-blue-500" : "text-gray-500 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="w-5 h-5" />
|
||||
<span className="text-xs mt-1">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { fetchContentLibraryList } from "@/api/content";
|
||||
import { ContentLibrary } from "@/api/content";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
interface ContentLibrarySelectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedLibraries: string[];
|
||||
onSelect: (libraries: string[]) => void;
|
||||
}
|
||||
|
||||
export function ContentLibrarySelectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedLibraries,
|
||||
onSelect,
|
||||
}: ContentLibrarySelectionDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
|
||||
const [tempSelected, setTempSelected] = useState<string[]>([]);
|
||||
|
||||
// 获取内容库列表
|
||||
const fetchLibraries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchContentLibraryList(1, 100, searchQuery);
|
||||
if (response.code === 200 && response.data) {
|
||||
setLibraries(response.data.list);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取内容库列表失败",
|
||||
description: response.msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
toast({
|
||||
title: "获取内容库列表失败",
|
||||
description: "请检查网络连接",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQuery, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchLibraries();
|
||||
setTempSelected(selectedLibraries);
|
||||
}
|
||||
}, [open, selectedLibraries, fetchLibraries]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (tempSelected.length === libraries.length) {
|
||||
setTempSelected([]);
|
||||
} else {
|
||||
setTempSelected(libraries.map((lib) => lib.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLibraryToggle = (libraryId: string) => {
|
||||
setTempSelected((prev) =>
|
||||
prev.includes(libraryId)
|
||||
? prev.filter((id) => id !== libraryId)
|
||||
: [...prev, libraryId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setTempSelected(selectedLibraries);
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onSelect(tempSelected);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent className="flex flex-col bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择内容库</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-2 my-4">
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {tempSelected.length} 个内容库
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
disabled={loading || libraries.length === 0}
|
||||
>
|
||||
{tempSelected.length === libraries.length ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-6 px-6 max-h-[400px]">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : libraries.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
libraries.map((library) => (
|
||||
<label
|
||||
key={library.id}
|
||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tempSelected.includes(library.id)}
|
||||
onChange={() => handleLibraryToggle(library.id)}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{library.name}</span>
|
||||
<Badge variant="outline">
|
||||
{library.sourceType === 1
|
||||
? "文本"
|
||||
: library.sourceType === 2
|
||||
? "图片"
|
||||
: "视频"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
<div>创建人: {library.creatorName || "-"}</div>
|
||||
<div>
|
||||
更新时间:{" "}
|
||||
{new Date(library.updateTime).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {tempSelected.length} 个内容库
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>
|
||||
确定{tempSelected.length > 0 ? ` (${tempSelected.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { fetchDeviceList } from "@/api/devices";
|
||||
|
||||
// 设备选择项接口
|
||||
interface DeviceSelectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
interface DeviceSelectionProps {
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DeviceSelection({
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
placeholder = "选择设备",
|
||||
className = "",
|
||||
}: DeviceSelectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取设备列表,支持keyword
|
||||
const fetchDevices = async (keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchDeviceList(1, 100, keyword.trim() || undefined);
|
||||
if (res && res.data && Array.isArray(res.data.list)) {
|
||||
setDevices(
|
||||
res.data.list.map((d) => ({
|
||||
id: d.id?.toString() || "",
|
||||
name: d.memo || d.imei || "",
|
||||
imei: d.imei || "",
|
||||
wechatId: d.wechatId || "",
|
||||
status: d.alive === 1 ? "online" : "offline",
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开弹窗时获取设备列表
|
||||
const openDialog = () => {
|
||||
setSearchQuery("");
|
||||
setDialogOpen(true);
|
||||
fetchDevices("");
|
||||
};
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
fetchDevices(searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, dialogOpen]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onSelect(selectedDevices.filter((id) => id !== deviceId));
|
||||
} else {
|
||||
onSelect([...selectedDevices, deviceId]);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedDevices.length === 0) return "";
|
||||
return `已选择 ${selectedDevices.length} 个设备`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`relative ${className}`}>
|
||||
<Search className="absolute left-3 top-4 h-5 w-5 text-gray-400" />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-10 h-14 rounded-xl border-gray-200 text-base"
|
||||
readOnly
|
||||
onClick={openDialog}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 设备选择弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-4 my-4">
|
||||
<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>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-32 h-10 rounded border border-gray-300 px-2 text-base"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-6 px-6 h-80 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredDevices.map((device) => (
|
||||
<label
|
||||
key={device.id}
|
||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onCheckedChange={() => handleDeviceToggle(device.id)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{device.name}</span>
|
||||
<div
|
||||
className={`w-16 h-6 flex items-center justify-center text-xs ${
|
||||
device.status === "online"
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selectedDevices.length} 个设备
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setDialogOpen(false)}>确定</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { fetchDeviceList } from "@/api/devices";
|
||||
import { ServerDevice } from "@/types/device";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wxid: string;
|
||||
status: "online" | "offline";
|
||||
usedInPlans: number;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface DeviceSelectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
}
|
||||
|
||||
export function DeviceSelectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
}: DeviceSelectionDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
|
||||
// 获取设备列表,支持keyword
|
||||
const fetchDevices = useCallback(
|
||||
async (keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchDeviceList(
|
||||
1,
|
||||
100,
|
||||
keyword.trim() || undefined
|
||||
);
|
||||
if (response.code === 200 && response.data) {
|
||||
// 转换服务端数据格式为组件需要的格式
|
||||
const convertedDevices: Device[] = response.data.list.map(
|
||||
(serverDevice: ServerDevice) => ({
|
||||
id: serverDevice.id.toString(),
|
||||
name: serverDevice.memo || `设备 ${serverDevice.id}`,
|
||||
imei: serverDevice.imei,
|
||||
wxid: serverDevice.wechatId || "",
|
||||
status: serverDevice.alive === 1 ? "online" : "offline",
|
||||
usedInPlans: 0, // 这个字段需要从其他API获取
|
||||
nickname: serverDevice.nickname || "",
|
||||
})
|
||||
);
|
||||
setDevices(convertedDevices);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: response.msg,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: "请检查网络连接",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// 打开弹窗时获取设备列表
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchDevices("");
|
||||
}
|
||||
}, [open, fetchDevices]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setTimeout(() => {
|
||||
fetchDevices(searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, open, fetchDevices]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onSelect(selectedDevices.filter((id) => id !== deviceId));
|
||||
} else {
|
||||
onSelect([...selectedDevices, deviceId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex flex-col bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择设备</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-4 my-4">
|
||||
<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>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => fetchDevices(searchQuery)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto -mx-6 px-6 max-h-[400px]">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
filteredDevices.map((device) => (
|
||||
<label
|
||||
key={device.id}
|
||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={() => handleDeviceSelect(device.id)}
|
||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{device.name}</span>
|
||||
<Badge
|
||||
variant={
|
||||
device.status === "online" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wxid || "-"}</div>
|
||||
<div>昵称: {device.nickname || "-"}</div>
|
||||
</div>
|
||||
{device.usedInPlans > 0 && (
|
||||
<div className="text-sm text-orange-500 mt-1">
|
||||
已用于 {device.usedInPlans} 个计划
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-4 pt-4 border-t">
|
||||
<div className="text-sm text-gray-500">
|
||||
已选择 {selectedDevices.length} 个设备
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
确定
|
||||
{selectedDevices.length > 0 ? ` (${selectedDevices.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { get } from "@/api/request";
|
||||
|
||||
// 微信好友接口类型
|
||||
interface WechatFriend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
customer: string;
|
||||
}
|
||||
|
||||
// 好友列表API响应类型
|
||||
interface FriendsResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: {
|
||||
list: Array<{
|
||||
id: number;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar?: string;
|
||||
customer?: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 获取好友列表API函数 - 添加 keyword 参数
|
||||
const fetchFriendsList = async (params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
deviceIds?: string[];
|
||||
keyword?: string;
|
||||
}): Promise<FriendsResponse> => {
|
||||
if (params.deviceIds && params.deviceIds.length === 0) {
|
||||
return {
|
||||
code: 200,
|
||||
msg: "success",
|
||||
data: {
|
||||
list: [],
|
||||
total: 0,
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const deviceIdsParam = params?.deviceIds?.join(",") || "";
|
||||
const keywordParam = params?.keyword
|
||||
? `&keyword=${encodeURIComponent(params.keyword)}`
|
||||
: "";
|
||||
|
||||
return get<FriendsResponse>(
|
||||
`/v1/friend?page=${params.page}&limit=${params.limit}&deviceIds=${deviceIdsParam}${keywordParam}`
|
||||
);
|
||||
};
|
||||
|
||||
// 组件属性接口
|
||||
interface FriendSelectionProps {
|
||||
selectedFriends: string[];
|
||||
onSelect: (friends: string[]) => void;
|
||||
onSelectDetail?: (friends: WechatFriend[]) => void; // 新增
|
||||
deviceIds?: string[];
|
||||
enableDeviceFilter?: boolean; // 新增开关,默认true
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function FriendSelection({
|
||||
selectedFriends,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
deviceIds = [],
|
||||
enableDeviceFilter = true,
|
||||
placeholder = "选择微信好友",
|
||||
className = "",
|
||||
}: FriendSelectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [friends, setFriends] = useState<WechatFriend[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalFriends, setTotalFriends] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 打开弹窗并请求第一页好友
|
||||
const openDialog = () => {
|
||||
setCurrentPage(1);
|
||||
setSearchQuery(""); // 重置搜索关键词
|
||||
setDialogOpen(true);
|
||||
fetchFriends(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (dialogOpen && currentPage !== 1) {
|
||||
fetchFriends(currentPage, searchQuery);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
fetchFriends(1, searchQuery);
|
||||
}, 500); // 500 防抖
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, dialogOpen]);
|
||||
|
||||
// 获取好友列表API - 添加 keyword 参数
|
||||
const fetchFriends = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let res;
|
||||
if (enableDeviceFilter) {
|
||||
if (deviceIds.length === 0) {
|
||||
setFriends([]);
|
||||
setTotalFriends(0);
|
||||
setTotalPages(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
res = await fetchFriendsList({
|
||||
page,
|
||||
limit: 20,
|
||||
deviceIds: deviceIds,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
res = await fetchFriendsList({
|
||||
page,
|
||||
limit: 20,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (res && res.code === 200 && res.data) {
|
||||
setFriends(
|
||||
res.data.list.map((friend) => ({
|
||||
id: friend.id?.toString() || "",
|
||||
nickname: friend.nickname || "",
|
||||
wechatId: friend.wechatId || "",
|
||||
avatar: friend.avatar || "",
|
||||
customer: friend.customer || "",
|
||||
}))
|
||||
);
|
||||
setTotalFriends(res.data.total || 0);
|
||||
setTotalPages(Math.ceil((res.data.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取好友列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理好友选择
|
||||
const handleFriendToggle = (friendId: string) => {
|
||||
let newIds: string[];
|
||||
if (selectedFriends.includes(friendId)) {
|
||||
newIds = selectedFriends.filter((id) => id !== friendId);
|
||||
} else {
|
||||
newIds = [...selectedFriends, friendId];
|
||||
}
|
||||
onSelect(newIds);
|
||||
if (onSelectDetail) {
|
||||
const selectedObjs = friends.filter((f) => newIds.includes(f.id));
|
||||
onSelectDetail(selectedObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedFriends.length === 0) return "";
|
||||
return `已选择 ${selectedFriends.length} 个好友`;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 清空搜索
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchFriends(1, "");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`relative ${className}`}>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
||||
readOnly
|
||||
onClick={openDialog}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 微信好友选择弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden bg-white">
|
||||
<div className="p-6">
|
||||
<DialogTitle className="text-center text-xl font-medium mb-6">
|
||||
选择微信好友
|
||||
</DialogTitle>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Input
|
||||
placeholder="搜索好友"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 py-2 rounded-full border-gray-200"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto h-[50vh]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : friends.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{friends.map((friend) => (
|
||||
<label
|
||||
key={friend.id}
|
||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handleFriendToggle(friend.id)}
|
||||
>
|
||||
<div className="mr-3 flex items-center justify-center">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border ${
|
||||
selectedFriends.includes(friend.id)
|
||||
? "border-blue-600"
|
||||
: "border-gray-300"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
{selectedFriends.includes(friend.id) && (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
|
||||
{friend.avatar ? (
|
||||
<img
|
||||
src={friend.avatar}
|
||||
alt={friend.nickname}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
friend.nickname.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{friend.nickname}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
微信ID: {friend.wechatId}
|
||||
</div>
|
||||
{friend.customer && (
|
||||
<div className="text-sm text-gray-400">
|
||||
归属客户: {friend.customer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">
|
||||
{deviceIds.length === 0
|
||||
? "请先选择设备"
|
||||
: searchQuery
|
||||
? `没有找到包含"${searchQuery}"的好友`
|
||||
: "没有找到好友"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<div className="text-sm text-gray-500">
|
||||
总计 {totalFriends} 个好友
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage(Math.min(totalPages, currentPage + 1))
|
||||
}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="px-6 rounded-full border-gray-300"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
|
||||
>
|
||||
确定 ({selectedFriends.length})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { get } from "@/api/request";
|
||||
|
||||
// 群组接口类型
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
chatroomId: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
ownerWechatId: string;
|
||||
ownerNickname: string;
|
||||
ownerAvatar: string;
|
||||
}
|
||||
|
||||
interface GroupsResponse {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: {
|
||||
list: Array<{
|
||||
id: number;
|
||||
chatroomId: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
ownerWechatId?: string;
|
||||
ownerNickname?: string;
|
||||
ownerAvatar?: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 修改:支持keyword参数
|
||||
const fetchGroupsList = async (params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}): Promise<GroupsResponse> => {
|
||||
const keywordParam = params.keyword
|
||||
? `&keyword=${encodeURIComponent(params.keyword)}`
|
||||
: "";
|
||||
return get<GroupsResponse>(
|
||||
`/v1/chatroom?page=${params.page}&limit=${params.limit}${keywordParam}`
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupSelectionProps {
|
||||
selectedGroups: string[];
|
||||
onSelect: (groups: string[]) => void;
|
||||
onSelectDetail?: (groups: WechatGroup[]) => void; // 新增
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function GroupSelection({
|
||||
selectedGroups,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
placeholder = "选择群聊",
|
||||
className = "",
|
||||
}: GroupSelectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [groups, setGroups] = useState<WechatGroup[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalGroups, setTotalGroups] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 打开弹窗并请求第一页群组
|
||||
const openDialog = () => {
|
||||
setCurrentPage(1);
|
||||
setSearchQuery(""); // 重置搜索关键词
|
||||
setDialogOpen(true);
|
||||
fetchGroups(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (dialogOpen && currentPage !== 1) {
|
||||
fetchGroups(currentPage, searchQuery);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, dialogOpen]);
|
||||
|
||||
// 获取群组列表API - 支持keyword
|
||||
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchGroupsList({
|
||||
page,
|
||||
limit: 20,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
if (res && res.code === 200 && res.data) {
|
||||
setGroups(
|
||||
res.data.list.map((group) => ({
|
||||
id: group.id?.toString() || "",
|
||||
chatroomId: group.chatroomId || "",
|
||||
name: group.name || "",
|
||||
avatar: group.avatar || "",
|
||||
ownerWechatId: group.ownerWechatId || "",
|
||||
ownerNickname: group.ownerNickname || "",
|
||||
ownerAvatar: group.ownerAvatar || "",
|
||||
}))
|
||||
);
|
||||
setTotalGroups(res.data.total || 0);
|
||||
setTotalPages(Math.ceil((res.data.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取群组列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理群组选择
|
||||
const handleGroupToggle = (groupId: string) => {
|
||||
let newIds: string[];
|
||||
if (selectedGroups.includes(groupId)) {
|
||||
newIds = selectedGroups.filter((id) => id !== groupId);
|
||||
} else {
|
||||
newIds = [...selectedGroups, groupId];
|
||||
}
|
||||
onSelect(newIds);
|
||||
if (onSelectDetail) {
|
||||
const selectedObjs = groups.filter((g) => newIds.includes(g.id));
|
||||
onSelectDetail(selectedObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedGroups.length === 0) return "";
|
||||
return `已选择 ${selectedGroups.length} 个群聊`;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 清空搜索
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, "");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`relative ${className}`}>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
||||
readOnly
|
||||
onClick={openDialog}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 群组选择弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden bg-white">
|
||||
<div className="p-6">
|
||||
<DialogTitle className="text-center text-xl font-medium mb-6">
|
||||
选择群聊
|
||||
</DialogTitle>
|
||||
<div className="relative mb-4">
|
||||
<Input
|
||||
placeholder="搜索群聊"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 py-2 rounded-full border-gray-200"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto h-[50vh]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : groups.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{groups.map((group) => (
|
||||
<label
|
||||
key={group.id}
|
||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handleGroupToggle(group.id)}
|
||||
>
|
||||
<div className="mr-3 flex items-center justify-center">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border ${
|
||||
selectedGroups.includes(group.id)
|
||||
? "border-blue-600"
|
||||
: "border-gray-300"
|
||||
} flex items-center justify-center`}
|
||||
>
|
||||
{selectedGroups.includes(group.id) && (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
|
||||
{group.avatar ? (
|
||||
<img
|
||||
src={group.avatar}
|
||||
alt={group.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
group.name.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
群ID: {group.chatroomId}
|
||||
</div>
|
||||
{group.ownerNickname && (
|
||||
<div className="text-sm text-gray-400">
|
||||
群主: {group.ownerNickname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的群聊`
|
||||
: "没有找到群聊"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<div className="text-sm text-gray-500">
|
||||
总计 {totalGroups} 个群聊
|
||||
{searchQuery && ` (搜索: "${searchQuery}")`}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setCurrentPage(Math.min(totalPages, currentPage + 1))
|
||||
}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="px-6 rounded-full border-gray-300"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
|
||||
>
|
||||
确定 ({selectedGroups.length})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface LayoutProps {
|
||||
loading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({
|
||||
loading,
|
||||
children,
|
||||
header,
|
||||
footer,
|
||||
}) => {
|
||||
return (
|
||||
<div className="container">
|
||||
{header && <header>{header}</header>}
|
||||
<main className="bg-gray-50">{children}</main>
|
||||
{footer && <footer>{footer}</footer>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BottomNav from './BottomNav';
|
||||
|
||||
// 配置需要底部导航的页面路径(白名单)
|
||||
const BOTTOM_NAV_CONFIG = [
|
||||
'/', // 首页
|
||||
'/scenarios', // 场景获客
|
||||
'/workspace', // 工作台
|
||||
'/profile', // 我的
|
||||
];
|
||||
|
||||
interface LayoutWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function LayoutWrapper({ children }: LayoutWrapperProps) {
|
||||
const location = useLocation();
|
||||
|
||||
// 检查当前路径是否需要底部导航
|
||||
const shouldShowBottomNav = BOTTOM_NAV_CONFIG.some(path => {
|
||||
// 特殊处理首页路由 '/'
|
||||
if (path === '/') {
|
||||
return location.pathname === '/';
|
||||
}
|
||||
return location.pathname === path;
|
||||
});
|
||||
|
||||
// 如果是登录页面,直接渲染内容(不显示底部导航)
|
||||
if (location.pathname === '/login') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 只有在配置列表中的页面才显示底部导航
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
{shouldShowBottomNav && <BottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import React from 'react';
|
||||
import BackButton from './BackButton';
|
||||
import { useSimpleBack } from '@/hooks/useBackNavigation';
|
||||
|
||||
interface PageHeaderProps {
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 返回按钮文本 */
|
||||
backText?: string;
|
||||
/** 自定义返回逻辑 */
|
||||
onBack?: () => void;
|
||||
/** 默认返回路径 */
|
||||
defaultBackPath?: string;
|
||||
/** 是否显示返回按钮 */
|
||||
showBack?: boolean;
|
||||
/** 右侧扩展内容 */
|
||||
rightContent?: React.ReactNode;
|
||||
/** 自定义CSS类名 */
|
||||
className?: string;
|
||||
/** 标题样式类名 */
|
||||
titleClassName?: string;
|
||||
/** 返回按钮样式变体 */
|
||||
backButtonVariant?: 'icon' | 'button' | 'text';
|
||||
/** 返回按钮自定义样式类名 */
|
||||
backButtonClassName?: string;
|
||||
/** 是否显示底部边框 */
|
||||
showBorder?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用页面Header组件
|
||||
* 支持返回按钮、标题和右侧扩展插槽
|
||||
*/
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
backText = '返回',
|
||||
onBack,
|
||||
defaultBackPath = '/',
|
||||
showBack = true,
|
||||
rightContent,
|
||||
className = '',
|
||||
titleClassName = '',
|
||||
backButtonVariant = 'icon',
|
||||
backButtonClassName = '',
|
||||
showBorder = true
|
||||
}) => {
|
||||
const { goBack } = useSimpleBack(defaultBackPath);
|
||||
|
||||
const handleBack = onBack || goBack;
|
||||
|
||||
const baseClasses = `bg-white ${showBorder ? 'border-b border-gray-200' : ''}`;
|
||||
const headerClasses = `${baseClasses} ${className}`;
|
||||
// 默认小号按钮样式
|
||||
const defaultBackBtnClass = 'text-sm px-2 py-1 h-8 min-h-0';
|
||||
|
||||
return (
|
||||
<header className={headerClasses}>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center ">
|
||||
{showBack && (
|
||||
<BackButton
|
||||
variant={backButtonVariant}
|
||||
text={backText}
|
||||
onBack={handleBack}
|
||||
className={`${defaultBackBtnClass} ${backButtonClassName}`.trim()}
|
||||
/>
|
||||
)}
|
||||
<h1 className={`text-lg font-semibold ${titleClassName}`}>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{rightContent && (
|
||||
<div className="flex items-center gap-2">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
@@ -1,71 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
// 不需要登录的公共页面路径
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/404',
|
||||
'/500'
|
||||
];
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 检查当前路径是否是公共页面
|
||||
const isPublicPath = PUBLIC_PATHS.some(path =>
|
||||
location.pathname.startsWith(path)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果正在加载,不进行任何跳转
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果未登录且不是公共页面,重定向到登录页面
|
||||
if (!isAuthenticated && !isPublicPath) {
|
||||
// 保存当前URL,登录后可以重定向回来
|
||||
const returnUrl = encodeURIComponent(window.location.href);
|
||||
navigate(`/login?returnUrl=${returnUrl}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已登录且在登录页面,重定向到首页
|
||||
if (isAuthenticated && location.pathname === '/login') {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, isLoading, location.pathname, navigate, isPublicPath]);
|
||||
|
||||
// 如果正在加载,显示加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果未登录且不是公共页面,不渲染内容(等待重定向)
|
||||
if (!isAuthenticated && !isPublicPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已登录且在登录页面,不渲染内容(等待重定向)
|
||||
if (isAuthenticated && location.pathname === '/login') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 其他情况正常渲染
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from 'lucide-react';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "running" | "paused" | "completed";
|
||||
stats: {
|
||||
devices: number;
|
||||
acquired: number;
|
||||
added: number;
|
||||
};
|
||||
lastUpdated: string;
|
||||
executionTime: string;
|
||||
nextExecutionTime: string;
|
||||
trend: { date: string; customers: number }[];
|
||||
reqConf?: {
|
||||
device?: string[];
|
||||
selectedDevices?: string[];
|
||||
};
|
||||
acquiredCount?: number;
|
||||
addedCount?: number;
|
||||
passRate?: number;
|
||||
}
|
||||
|
||||
interface ScenarioAcquisitionCardProps {
|
||||
task: Task;
|
||||
channel: string;
|
||||
onEdit: (taskId: string) => void;
|
||||
onCopy: (taskId: string) => void;
|
||||
onDelete: (taskId: string) => void;
|
||||
onOpenSettings?: (taskId: string) => void;
|
||||
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void;
|
||||
}
|
||||
|
||||
export function ScenarioAcquisitionCard({
|
||||
task,
|
||||
channel,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
onOpenSettings,
|
||||
onStatusChange,
|
||||
}: ScenarioAcquisitionCardProps) {
|
||||
// 兼容后端真实数据结构
|
||||
const deviceCount = Array.isArray(task.reqConf?.device)
|
||||
? task.reqConf!.device.length
|
||||
: Array.isArray(task.reqConf?.selectedDevices)
|
||||
? task.reqConf!.selectedDevices.length
|
||||
: 0;
|
||||
// 获客数和已添加数可根据 msgConf 或其它字段自定义
|
||||
const acquiredCount = task.acquiredCount ?? 0;
|
||||
const addedCount = task.addedCount ?? 0;
|
||||
const passRate = task.passRate ?? 0;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isActive = task.status === "running";
|
||||
|
||||
const handleStatusChange = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onStatusChange) {
|
||||
onStatusChange(task.id, task.status === "running" ? "paused" : "running");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
onEdit(task.id);
|
||||
};
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
onCopy(task.id);
|
||||
};
|
||||
|
||||
const handleOpenSettings = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
if (onOpenSettings) {
|
||||
onOpenSettings(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
onDelete(task.id);
|
||||
};
|
||||
|
||||
const toggleMenu = (e?: React.MouseEvent) => {
|
||||
if (e) e.stopPropagation();
|
||||
setMenuOpen(!menuOpen);
|
||||
};
|
||||
|
||||
// 点击外部关闭菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
|
||||
<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={isActive ? "success" : "secondary"}
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
onClick={handleStatusChange}
|
||||
>
|
||||
{isActive ? "进行中" : "已暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative z-20" ref={menuRef}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
编辑计划
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
复制计划
|
||||
</button>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleOpenSettings}
|
||||
>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
计划接口
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除计划
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<div className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">设备数</div>
|
||||
<div className="text-2xl font-semibold">{deviceCount}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已获客</div>
|
||||
<div className="text-2xl font-semibold">{acquiredCount}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="block">
|
||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
||||
<div className="text-sm text-gray-500 mb-1">已添加</div>
|
||||
<div className="text-2xl font-semibold">{addedCount}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-2">
|
||||
<div className="text-sm text-gray-500 mb-1">通过率</div>
|
||||
<div className="text-2xl font-semibold">{passRate}%</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm 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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils';
|
||||
|
||||
interface TestComponentProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TestComponent: React.FC<TestComponentProps> = ({ title, className }) => {
|
||||
return (
|
||||
<div className={cn('p-4 border rounded-lg', className)}>
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
<p className="text-gray-600">
|
||||
这是一个测试组件,用于验证路径别名 @/ 是否正常工作。
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-blue-600">
|
||||
✓ 成功使用 @/utils 导入工具函数
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestComponent;
|
||||
@@ -1,291 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
useThrottledRequestWithLoading,
|
||||
useThrottledRequestWithError,
|
||||
useRequestWithRetry,
|
||||
useCancellableRequest
|
||||
} from '../hooks/useThrottledRequest';
|
||||
|
||||
interface ThrottledButtonProps {
|
||||
onClick: () => Promise<any>;
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
variant?: 'throttle' | 'debounce' | 'retry' | 'cancellable';
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
showLoadingText?: boolean;
|
||||
loadingText?: string;
|
||||
errorText?: string;
|
||||
onSuccess?: (result: any) => void;
|
||||
onError?: (error: any) => void;
|
||||
}
|
||||
|
||||
export const ThrottledButton: React.FC<ThrottledButtonProps> = ({
|
||||
onClick,
|
||||
children,
|
||||
delay = 1000,
|
||||
disabled = false,
|
||||
className = '',
|
||||
variant = 'throttle',
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
showLoadingText = true,
|
||||
loadingText = '处理中...',
|
||||
errorText,
|
||||
onSuccess,
|
||||
onError
|
||||
}) => {
|
||||
// 处理请求结果
|
||||
const handleRequest = async () => {
|
||||
try {
|
||||
const result = await onClick();
|
||||
onSuccess?.(result);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 根据variant渲染不同的按钮
|
||||
const renderButton = () => {
|
||||
switch (variant) {
|
||||
case 'retry':
|
||||
return <RetryButtonContent
|
||||
onClick={handleRequest}
|
||||
maxRetries={maxRetries}
|
||||
retryDelay={retryDelay}
|
||||
loadingText={loadingText}
|
||||
showLoadingText={showLoadingText}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</RetryButtonContent>;
|
||||
|
||||
case 'cancellable':
|
||||
return <CancellableButtonContent
|
||||
onClick={handleRequest}
|
||||
loadingText={loadingText}
|
||||
showLoadingText={showLoadingText}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</CancellableButtonContent>;
|
||||
|
||||
case 'debounce':
|
||||
return <DebounceButtonContent
|
||||
onClick={handleRequest}
|
||||
delay={delay}
|
||||
loadingText={loadingText}
|
||||
showLoadingText={showLoadingText}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
errorText={errorText}
|
||||
>
|
||||
{children}
|
||||
</DebounceButtonContent>;
|
||||
|
||||
default:
|
||||
return <ThrottleButtonContent
|
||||
onClick={handleRequest}
|
||||
delay={delay}
|
||||
loadingText={loadingText}
|
||||
showLoadingText={showLoadingText}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</ThrottleButtonContent>;
|
||||
}
|
||||
};
|
||||
|
||||
return renderButton();
|
||||
};
|
||||
|
||||
// 节流按钮内容组件
|
||||
const ThrottleButtonContent: React.FC<{
|
||||
onClick: () => Promise<any>;
|
||||
delay: number;
|
||||
loadingText: string;
|
||||
showLoadingText: boolean;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, children }) => {
|
||||
const { throttledRequest, loading } = useThrottledRequestWithLoading(onClick, delay);
|
||||
|
||||
const getButtonText = () => {
|
||||
return loading && showLoadingText ? loadingText : children;
|
||||
};
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
const variantClasses = loading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
||||
|
||||
return `${baseClasses} ${variantClasses} ${className}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={throttledRequest}
|
||||
disabled={disabled || loading}
|
||||
className={getButtonClassName()}
|
||||
>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 防抖按钮内容组件
|
||||
const DebounceButtonContent: React.FC<{
|
||||
onClick: () => Promise<any>;
|
||||
delay: number;
|
||||
loadingText: string;
|
||||
showLoadingText: boolean;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
errorText?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, errorText, children }) => {
|
||||
const { throttledRequest, loading, error } = useThrottledRequestWithError(onClick, delay);
|
||||
|
||||
const getButtonText = () => {
|
||||
return loading && showLoadingText ? loadingText : children;
|
||||
};
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
let variantClasses = '';
|
||||
if (loading) {
|
||||
variantClasses = 'bg-gray-400 text-white cursor-not-allowed';
|
||||
} else if (error) {
|
||||
variantClasses = 'bg-red-500 text-white hover:bg-red-600';
|
||||
} else {
|
||||
variantClasses = 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
||||
}
|
||||
|
||||
return `${baseClasses} ${variantClasses} ${className}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={throttledRequest}
|
||||
disabled={disabled || loading}
|
||||
className={getButtonClassName()}
|
||||
>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
|
||||
{error && errorText && (
|
||||
<span className="text-red-500 text-sm">{errorText}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 重试按钮内容组件
|
||||
const RetryButtonContent: React.FC<{
|
||||
onClick: () => Promise<any>;
|
||||
maxRetries: number;
|
||||
retryDelay: number;
|
||||
loadingText: string;
|
||||
showLoadingText: boolean;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, maxRetries, retryDelay, loadingText, showLoadingText, disabled, className, children }) => {
|
||||
const { requestWithRetry, loading, retryCount } = useRequestWithRetry(onClick, maxRetries, retryDelay);
|
||||
|
||||
const getButtonText = () => {
|
||||
if (loading) {
|
||||
if (retryCount > 0) {
|
||||
return `${loadingText} (重试 ${retryCount}/${maxRetries})`;
|
||||
}
|
||||
return showLoadingText ? loadingText : children;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
const variantClasses = loading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
||||
|
||||
return `${baseClasses} ${variantClasses} ${className}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={requestWithRetry}
|
||||
disabled={disabled || loading}
|
||||
className={getButtonClassName()}
|
||||
>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 可取消按钮内容组件
|
||||
const CancellableButtonContent: React.FC<{
|
||||
onClick: () => Promise<any>;
|
||||
loadingText: string;
|
||||
showLoadingText: boolean;
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ onClick, loadingText, showLoadingText, disabled, className, children }) => {
|
||||
const { cancellableRequest, loading, cancelRequest } = useCancellableRequest(onClick);
|
||||
|
||||
const getButtonText = () => {
|
||||
return loading && showLoadingText ? loadingText : children;
|
||||
};
|
||||
|
||||
const getButtonClassName = () => {
|
||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
const variantClasses = loading
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
||||
|
||||
return `${baseClasses} ${variantClasses} ${className}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={cancellableRequest}
|
||||
disabled={disabled || loading}
|
||||
className={getButtonClassName()}
|
||||
>
|
||||
{getButtonText()}
|
||||
</button>
|
||||
|
||||
{loading && cancelRequest && (
|
||||
<button
|
||||
onClick={cancelRequest}
|
||||
className="px-3 py-2 rounded bg-red-500 text-white hover:bg-red-600 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 导出其他类型的按钮组件
|
||||
export const DebouncedButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { delay?: number }> = (props) => (
|
||||
<ThrottledButton {...props} variant="debounce" delay={props.delay || 300} />
|
||||
);
|
||||
|
||||
export const RetryButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { maxRetries?: number; retryDelay?: number }> = (props) => (
|
||||
<ThrottledButton {...props} variant="retry" maxRetries={props.maxRetries || 3} retryDelay={props.retryDelay || 1000} />
|
||||
);
|
||||
|
||||
export const CancellableButton: React.FC<Omit<ThrottledButtonProps, 'variant'>> = (props) => (
|
||||
<ThrottledButton {...props} variant="cancellable" />
|
||||
);
|
||||
@@ -1,297 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, Database } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { fetchDeviceLabels, type TrafficPool } from '@/api/trafficDistribution';
|
||||
|
||||
// 组件属性接口
|
||||
interface TrafficPoolSelectionProps {
|
||||
selectedPools: string[];
|
||||
onSelect: (pools: string[]) => void;
|
||||
deviceIds: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function TrafficPoolSelection({
|
||||
selectedPools,
|
||||
onSelect,
|
||||
deviceIds,
|
||||
placeholder = "选择流量池",
|
||||
className = ""
|
||||
}: TrafficPoolSelectionProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [pools, setPools] = useState<TrafficPool[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalPools, setTotalPools] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 获取流量池列表API
|
||||
const fetchPools = useCallback(async (page: number, keyword: string = '') => {
|
||||
if (deviceIds.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchDeviceLabels({
|
||||
deviceIds,
|
||||
page,
|
||||
pageSize: 10,
|
||||
keyword
|
||||
});
|
||||
|
||||
if (res && res.code === 200 && res.data) {
|
||||
setPools(res.data.list || []);
|
||||
setTotalPools(res.data.total || 0);
|
||||
setTotalPages(Math.ceil((res.data.total || 0) / 10));
|
||||
} else {
|
||||
toast({
|
||||
title: "获取流量池列表失败",
|
||||
description: res?.msg || "请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
// 使用模拟数据作为降级处理
|
||||
const mockData: TrafficPool[] = [
|
||||
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
|
||||
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
|
||||
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
|
||||
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
|
||||
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
|
||||
{ id: "6", name: "VIP客户池", count: 156, description: "VIP等级客户", deviceIds },
|
||||
{ id: "7", name: "潜在客户池", count: 3200, description: "有潜在购买可能的客户", deviceIds },
|
||||
{ id: "8", name: "游戏玩家池", count: 890, description: "游戏类产品感兴趣客户", deviceIds },
|
||||
];
|
||||
|
||||
// 根据关键词过滤模拟数据
|
||||
const filteredData = keyword
|
||||
? mockData.filter(pool =>
|
||||
pool.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
||||
(pool.description && pool.description.toLowerCase().includes(keyword.toLowerCase()))
|
||||
)
|
||||
: mockData;
|
||||
|
||||
// 分页处理模拟数据
|
||||
const startIndex = (page - 1) * 10;
|
||||
const endIndex = startIndex + 10;
|
||||
const paginatedData = filteredData.slice(startIndex, endIndex);
|
||||
|
||||
setPools(paginatedData);
|
||||
setTotalPools(filteredData.length);
|
||||
setTotalPages(Math.ceil(filteredData.length / 10));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取流量池列表失败:', error);
|
||||
toast({
|
||||
title: "网络错误",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
// 网络错误时使用模拟数据
|
||||
const mockData: TrafficPool[] = [
|
||||
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
|
||||
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
|
||||
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
|
||||
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
|
||||
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
|
||||
];
|
||||
|
||||
setPools(mockData);
|
||||
setTotalPools(mockData.length);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [deviceIds, toast]);
|
||||
|
||||
// 当弹窗打开时获取流量池列表
|
||||
useEffect(() => {
|
||||
if (dialogOpen && deviceIds.length > 0) {
|
||||
// 弹窗打开时重置搜索和页码,然后立即请求第一页数据
|
||||
setSearchQuery('');
|
||||
setCurrentPage(1);
|
||||
fetchPools(1, '');
|
||||
}
|
||||
}, [dialogOpen, deviceIds, fetchPools]);
|
||||
|
||||
// 监听页码变化,重新请求数据
|
||||
useEffect(() => {
|
||||
if (dialogOpen && deviceIds.length > 0 && currentPage > 1) {
|
||||
fetchPools(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, dialogOpen, deviceIds.length, fetchPools, searchQuery]);
|
||||
|
||||
// 当设备ID变化时,清空已选择的流量池(如果需要的话)
|
||||
useEffect(() => {
|
||||
if (deviceIds.length === 0) {
|
||||
setPools([]);
|
||||
setTotalPools(0);
|
||||
setTotalPages(1);
|
||||
}
|
||||
}, [deviceIds]);
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = (keyword: string) => {
|
||||
setSearchQuery(keyword);
|
||||
setCurrentPage(1);
|
||||
// 立即搜索,不管弹窗是否打开(因为这个函数只在弹窗内调用)
|
||||
if (deviceIds.length > 0) {
|
||||
fetchPools(1, keyword);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理流量池选择
|
||||
const handlePoolToggle = (poolId: string) => {
|
||||
if (selectedPools.includes(poolId)) {
|
||||
onSelect(selectedPools.filter(id => id !== poolId));
|
||||
} else {
|
||||
onSelect([...selectedPools, poolId]);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedPools.length === 0) return '';
|
||||
return `已选择 ${selectedPools.length} 个流量池`;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 处理输入框点击
|
||||
const handleInputClick = () => {
|
||||
if (deviceIds.length === 0) {
|
||||
toast({
|
||||
title: "请先选择设备",
|
||||
description: "需要先选择设备才能选择流量池",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`relative ${className}`}>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Database className="w-5 h-5" />
|
||||
</span>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
||||
readOnly
|
||||
onClick={handleInputClick}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 流量池选择弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-sm w-[90vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<div>
|
||||
<DialogTitle className="text-center text-xl font-medium mb-6">选择流量池</DialogTitle>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Input
|
||||
placeholder="搜索流量池"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-10 py-2 rounded-full border-gray-200"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : pools.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
{pools.map((pool) => (
|
||||
<label
|
||||
key={pool.id}
|
||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => handlePoolToggle(pool.id)}
|
||||
>
|
||||
<div className="mr-3 flex items-center justify-center">
|
||||
<div className={`w-5 h-5 rounded-full border ${selectedPools.includes(pool.id) ? 'border-blue-600' : 'border-gray-300'} flex items-center justify-center`}>
|
||||
{selectedPools.includes(pool.id) && (
|
||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{pool.name}</div>
|
||||
{pool.description && (
|
||||
<div className="text-sm text-gray-500">{pool.description}</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-400">{pool.count} 人</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">
|
||||
{deviceIds.length === 0 ? '请先选择设备' : '没有找到流量池'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<div className="text-sm text-gray-500">
|
||||
总计 {totalPools} 个流量池
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<span className="text-sm">{currentPage} / {totalPages}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className="px-2 py-0 h-8 min-w-0"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} className="px-6 rounded-full border-gray-300">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full">
|
||||
确定 ({selectedPools.length})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ChevronLeft, Settings, Bell, Search, RefreshCw, Filter, Plus, MoreVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
interface HeaderAction {
|
||||
type: 'button' | 'icon' | 'search' | 'custom';
|
||||
icon?: React.ComponentType<any>;
|
||||
label?: string;
|
||||
onClick?: () => void;
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'destructive' | 'secondary';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
className?: string;
|
||||
content?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface UnifiedHeaderProps {
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
/** 是否显示返回按钮 */
|
||||
showBack?: boolean;
|
||||
/** 返回按钮文本 */
|
||||
backText?: string;
|
||||
/** 自定义返回逻辑 */
|
||||
onBack?: () => void;
|
||||
/** 默认返回路径 */
|
||||
defaultBackPath?: string;
|
||||
/** 右侧操作按钮 */
|
||||
actions?: HeaderAction[];
|
||||
/** 自定义右侧内容 */
|
||||
rightContent?: React.ReactNode;
|
||||
/** 是否显示搜索框 */
|
||||
showSearch?: boolean;
|
||||
/** 搜索框占位符 */
|
||||
searchPlaceholder?: string;
|
||||
/** 搜索值 */
|
||||
searchValue?: string;
|
||||
/** 搜索回调 */
|
||||
onSearchChange?: (value: string) => void;
|
||||
/** 是否显示底部边框 */
|
||||
showBorder?: boolean;
|
||||
/** 背景样式 */
|
||||
background?: 'white' | 'transparent' | 'blur';
|
||||
/** 自定义CSS类名 */
|
||||
className?: string;
|
||||
/** 标题样式类名 */
|
||||
titleClassName?: string;
|
||||
/** 标题颜色 */
|
||||
titleColor?: 'default' | 'blue' | 'gray';
|
||||
/** 是否居中标题 */
|
||||
centerTitle?: boolean;
|
||||
/** 头部高度 */
|
||||
height?: 'default' | 'compact' | 'tall';
|
||||
}
|
||||
|
||||
const UnifiedHeader: React.FC<UnifiedHeaderProps> = ({
|
||||
title,
|
||||
showBack = true,
|
||||
backText = '返回',
|
||||
onBack,
|
||||
defaultBackPath = '/',
|
||||
actions = [],
|
||||
rightContent,
|
||||
showSearch = false,
|
||||
searchPlaceholder = '搜索...',
|
||||
searchValue = '',
|
||||
onSearchChange,
|
||||
showBorder = true,
|
||||
background = 'white',
|
||||
className = '',
|
||||
titleClassName = '',
|
||||
titleColor = 'default',
|
||||
centerTitle = false,
|
||||
height = 'default',
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
} else if (defaultBackPath) {
|
||||
navigate(defaultBackPath);
|
||||
} else {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 背景样式
|
||||
const backgroundClasses = {
|
||||
white: 'bg-white',
|
||||
transparent: 'bg-transparent',
|
||||
blur: 'bg-white/80 backdrop-blur-sm',
|
||||
};
|
||||
|
||||
// 高度样式
|
||||
const heightClasses = {
|
||||
default: 'h-14',
|
||||
compact: 'h-12',
|
||||
tall: 'h-16',
|
||||
};
|
||||
|
||||
// 标题颜色样式
|
||||
const titleColorClasses = {
|
||||
default: 'text-gray-900',
|
||||
blue: 'text-blue-600',
|
||||
gray: 'text-gray-600',
|
||||
};
|
||||
|
||||
const headerClasses = [
|
||||
backgroundClasses[background],
|
||||
heightClasses[height],
|
||||
showBorder ? 'border-b border-gray-200' : '',
|
||||
'sticky top-0 z-50',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const titleClasses = [
|
||||
'text-lg font-semibold',
|
||||
titleColorClasses[titleColor],
|
||||
centerTitle ? 'text-center' : '',
|
||||
titleClassName,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// 渲染操作按钮
|
||||
const renderAction = (action: HeaderAction, index: number) => {
|
||||
if (action.type === 'custom' && action.content) {
|
||||
return <div key={index}>{action.content}</div>;
|
||||
}
|
||||
|
||||
if (action.type === 'search') {
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
className="pl-9 w-48"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const IconComponent = action.icon || MoreVertical;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'ghost'}
|
||||
size={action.size || 'icon'}
|
||||
onClick={action.onClick}
|
||||
className={action.className}
|
||||
>
|
||||
<IconComponent className="h-5 w-5" />
|
||||
{action.label && action.size !== 'icon' && (
|
||||
<span className="ml-2">{action.label}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className={headerClasses}>
|
||||
<div className="flex items-center justify-between px-4 h-full">
|
||||
{/* 左侧:返回按钮和标题 */}
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
{showBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
className="h-8 w-8 hover:bg-gray-100"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
{!centerTitle && (
|
||||
<h1 className={titleClasses}>
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:居中标题 */}
|
||||
{centerTitle && (
|
||||
<div className="flex-1 flex justify-center">
|
||||
<h1 className={titleClasses}>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右侧:搜索框、操作按钮、自定义内容 */}
|
||||
<div className="flex items-center space-x-2 flex-1 justify-end">
|
||||
{showSearch && !actions.some(a => a.type === 'search') && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
className="pl-9 w-48"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) => renderAction(action, index))}
|
||||
|
||||
{rightContent && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
// 预设的常用Header配置
|
||||
export const HeaderPresets = {
|
||||
// 基础页面Header(有返回按钮)
|
||||
basic: (title: string, onBack?: () => void): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: true,
|
||||
onBack,
|
||||
titleColor: 'blue',
|
||||
}),
|
||||
|
||||
// 主页Header(无返回按钮)
|
||||
main: (title: string, actions?: HeaderAction[]): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: false,
|
||||
titleColor: 'blue',
|
||||
actions: actions || [
|
||||
{
|
||||
type: 'icon',
|
||||
icon: Bell,
|
||||
onClick: () => console.log('Notifications'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// 搜索页面Header
|
||||
search: (title: string, searchValue: string, onSearchChange: (value: string) => void): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: true,
|
||||
showSearch: true,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
titleColor: 'blue',
|
||||
}),
|
||||
|
||||
// 列表页面Header(带刷新和添加)
|
||||
list: (title: string, onRefresh?: () => void, onAdd?: () => void): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: true,
|
||||
titleColor: 'blue',
|
||||
actions: [
|
||||
...(onRefresh ? [{
|
||||
type: 'icon' as const,
|
||||
icon: RefreshCw,
|
||||
onClick: onRefresh,
|
||||
}] : []),
|
||||
...(onAdd ? [{
|
||||
type: 'button' as const,
|
||||
icon: Plus,
|
||||
label: '新建',
|
||||
size: 'sm' as const,
|
||||
onClick: onAdd,
|
||||
}] : []),
|
||||
],
|
||||
}),
|
||||
|
||||
// 设置页面Header
|
||||
settings: (title: string): UnifiedHeaderProps => ({
|
||||
title,
|
||||
showBack: true,
|
||||
titleColor: 'blue',
|
||||
actions: [
|
||||
{
|
||||
type: 'icon',
|
||||
icon: Settings,
|
||||
onClick: () => console.log('Settings'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
export default UnifiedHeader;
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Upload } from 'tdesign-mobile-react';
|
||||
import type { UploadFile as TDesignUploadFile } from 'tdesign-mobile-react/es/upload/type';
|
||||
import { uploadImage } from '@/api/upload';
|
||||
|
||||
interface UploadImageProps {
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
max?: number;
|
||||
accept?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const UploadImage: React.FC<UploadImageProps> = ({ value = [], onChange, ...props }) => {
|
||||
// 处理上传
|
||||
const requestMethod = async (file: TDesignUploadFile) => {
|
||||
try {
|
||||
const url = await uploadImage(file.raw as File);
|
||||
return {
|
||||
status: 'success' as const,
|
||||
response: {
|
||||
url
|
||||
},
|
||||
url,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
status: 'fail' as const,
|
||||
error: e.message || '上传失败',
|
||||
response: {},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 处理文件变更
|
||||
const handleChange = (newFiles: TDesignUploadFile[]) => {
|
||||
const urls = newFiles.map(f => f.url).filter((url): url is string => Boolean(url));
|
||||
onChange?.(urls);
|
||||
};
|
||||
|
||||
return (
|
||||
<Upload
|
||||
files={value.map(url => ({ url }))}
|
||||
requestMethod={requestMethod}
|
||||
onChange={handleChange}
|
||||
multiple
|
||||
accept={props.accept}
|
||||
max={props.max}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadImage;
|
||||
@@ -1,94 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Button } from 'tdesign-mobile-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { uploadImage } from '@/api/upload';
|
||||
|
||||
interface UploadVideoProps {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
accept?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const VIDEO_BOX_CLASS =
|
||||
'relative flex items-center justify-center w-full aspect-[16/9] rounded-2xl border-2 border-dashed border-blue-300 bg-gray-50 overflow-hidden';
|
||||
|
||||
const UploadVideo: React.FC<UploadVideoProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
accept = 'video/mp4,video/webm,video/ogg,video/quicktime,video/x-msvideo,video/x-ms-wmv,video/x-flv,video/x-matroska',
|
||||
disabled,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 选择文件并上传
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
onChange?.(url);
|
||||
} catch (err: any) {
|
||||
alert(err?.message || '上传失败');
|
||||
} finally {
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 触发文件选择
|
||||
const handleClick = () => {
|
||||
if (!disabled) inputRef.current?.click();
|
||||
};
|
||||
|
||||
// 删除视频
|
||||
const handleDelete = () => {
|
||||
onChange?.('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
{!value ? (
|
||||
<div className={VIDEO_BOX_CLASS}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-col items-center justify-center w-full h-full bg-transparent border-none outline-none cursor-pointer"
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="text-3xl mb-2">🎬</span>
|
||||
<span className="text-base text-gray-500 font-medium">点击上传视频</span>
|
||||
<span className="text-xs text-gray-400 mt-1">支持MP4、WebM、MOV等格式</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={VIDEO_BOX_CLASS}>
|
||||
<video
|
||||
src={value}
|
||||
controls
|
||||
className="w-full h-full object-cover rounded-2xl bg-black"
|
||||
style={{ background: '#000' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 z-10 bg-white/80 hover:bg-white rounded-full p-1 shadow"
|
||||
onClick={handleDelete}
|
||||
disabled={disabled}
|
||||
aria-label="删除视频"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadVideo;
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
|
||||
<path d="M14.94 5.19A4.38 4.38 0 0016 2a4.44 4.44 0 00-3 1.52 4.17 4.17 0 00-1 3.09 3.69 3.69 0 002.94-1.42zm2.52 7.44a4.51 4.51 0 012.16-3.81 4.66 4.66 0 00-3.66-2c-1.56-.16-3 .91-3.83.91s-2-.89-3.3-.87a4.92 4.92 0 00-4.14 2.53C2.93 12.45 4.24 17 6 19.47c.8 1.21 1.8 2.58 3.12 2.53s1.75-.82 3.28-.82 2 .82 3.3.79 2.22-1.24 3.06-2.45a11 11 0 001.38-2.85 4.41 4.41 0 01-2.68-4.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppleIcon;
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export function EyeIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
height="24"
|
||||
width="24"
|
||||
{...props}
|
||||
>
|
||||
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default EyeIcon;
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export function WeChatIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" height="24" width="24" {...props}>
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.81-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.595-6.348zM5.959 5.48c.609 0 1.104.498 1.104 1.112 0 .612-.495 1.11-1.104 1.11-.612 0-1.108-.498-1.108-1.11 0-.614.496-1.112 1.108-1.112zm5.315 0c.61 0 1.107.498 1.107 1.112 0 .612-.497 1.11-1.107 1.11-.611 0-1.105-.498-1.105-1.11 0-.614.494-1.112 1.105-1.112z" />
|
||||
<path d="M23.002 15.816c0-3.309-3.136-6-7-6-3.863 0-7 2.691-7 6 0 3.31 3.137 6 7 6 .814 0 1.601-.099 2.338-.285a.7.7 0 0 1 .579.08l1.5.87a.267.267 0 0 0 .135.044c.13 0 .236-.108.236-.241 0-.06-.023-.118-.038-.17l-.309-1.167a.476.476 0 0 1 .172-.534c1.645-1.17 2.387-2.835 2.387-4.597zm-9.498-1.19c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908zm4.998 0c-.497 0-.9-.407-.9-.908a.905.905 0 0 1 .9-.91c.498 0 .9.408.9.91 0 .5-.402.908-.9.908z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeChatIcon;
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
children ? (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
) : null
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { UserCircle } from 'lucide-react';
|
||||
|
||||
interface AvatarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Avatar({ children, className = '' }: AvatarProps) {
|
||||
return (
|
||||
<div className={`relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarImageProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AvatarImage({ src, alt, className = '' }: AvatarImageProps) {
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || '头像'}
|
||||
className={`aspect-square h-full w-full object-cover ${className}`}
|
||||
onError={(e) => {
|
||||
// 图片加载失败时隐藏图片,显示fallback
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarFallbackProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'default' | 'gradient' | 'solid' | 'outline';
|
||||
showUserIcon?: boolean;
|
||||
}
|
||||
|
||||
export function AvatarFallback({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
showUserIcon = true
|
||||
}: AvatarFallbackProps) {
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case 'gradient':
|
||||
return 'bg-gradient-to-br from-blue-500 to-purple-600 text-white shadow-lg';
|
||||
case 'solid':
|
||||
return 'bg-blue-500 text-white';
|
||||
case 'outline':
|
||||
return 'bg-white border-2 border-blue-500 text-blue-500';
|
||||
default:
|
||||
return 'bg-gradient-to-br from-blue-100 to-blue-200 text-blue-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full items-center justify-center rounded-full ${getVariantClasses()} ${className}`}>
|
||||
{children ? (
|
||||
<span className="text-sm font-medium">{children}</span>
|
||||
) : showUserIcon ? (
|
||||
<UserCircle className="h-1/2 w-1/2" />
|
||||
) : (
|
||||
<span className="text-sm font-medium">用户</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'secondary' | 'success' | 'destructive' | 'outline';
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-blue-100 text-blue-800',
|
||||
secondary: 'bg-gray-100 text-gray-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
destructive: 'bg-red-100 text-red-800',
|
||||
outline: 'border border-gray-300 bg-white text-gray-700'
|
||||
};
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${className}`;
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
className?: string;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
className = '',
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
loading = false
|
||||
}: ButtonProps) {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none';
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-700',
|
||||
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
|
||||
ghost: 'hover:bg-gray-100 text-gray-700',
|
||||
link: 'text-blue-600 underline-offset-4 hover:underline'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 px-3',
|
||||
lg: 'h-11 px-8',
|
||||
icon: 'h-10 w-10'
|
||||
};
|
||||
|
||||
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
type={type}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = '' }: CardHeaderProps) {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardTitle({ children, className = '' }: CardTitleProps) {
|
||||
return (
|
||||
<h3 className={`text-lg font-semibold text-gray-900 ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = '' }: CardContentProps) {
|
||||
return (
|
||||
<div className={`px-6 py-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardFooterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = '' }: CardFooterProps) {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-t border-gray-200 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CheckboxProps {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
onChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked = false,
|
||||
onCheckedChange,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
id,
|
||||
onClick
|
||||
}: CheckboxProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newChecked = e.target.checked;
|
||||
onCheckedChange?.(newChecked);
|
||||
onChange?.(newChecked);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-white fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface DropdownMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DropdownMenu({ children }: DropdownMenuProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface DropdownMenuTriggerProps {
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function DropdownMenuTrigger({ children }: DropdownMenuTriggerProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface DropdownMenuContentProps {
|
||||
children: React.ReactNode;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
export function DropdownMenuContent({ children, align = 'end' }: DropdownMenuContentProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const handleClick = () => setIsOpen(!isOpen);
|
||||
trigger.addEventListener('click', handleClick);
|
||||
|
||||
return () => {
|
||||
trigger.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
contentRef.current &&
|
||||
!contentRef.current.contains(event.target as Node) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={triggerRef}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child, {
|
||||
...child.props,
|
||||
children: (
|
||||
<>
|
||||
{child.props.children}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`absolute z-50 mt-2 min-w-[8rem] overflow-hidden rounded-md border bg-white p-1 shadow-md ${
|
||||
align === 'start' ? 'left-0' : align === 'center' ? 'left-1/2 transform -translate-x-1/2' : 'right-0'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownMenuItemProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DropdownMenuItem({ children, onClick, disabled = false }: DropdownMenuItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={`relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-gray-100 focus:bg-gray-100 disabled:pointer-events-none disabled:opacity-50 ${
|
||||
disabled ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
readOnly?: boolean;
|
||||
readonly?: boolean;
|
||||
id?: string;
|
||||
type?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
autoComplete?: string;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
placeholder,
|
||||
className = '',
|
||||
readOnly = false,
|
||||
readonly = false,
|
||||
id,
|
||||
type = 'text',
|
||||
min,
|
||||
max,
|
||||
name,
|
||||
required = false,
|
||||
disabled = false,
|
||||
autoComplete,
|
||||
step
|
||||
}: InputProps) {
|
||||
const isReadOnly = readOnly || readonly;
|
||||
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={onClick}
|
||||
placeholder={placeholder}
|
||||
readOnly={isReadOnly}
|
||||
min={min}
|
||||
max={max}
|
||||
name={name}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
step={step}
|
||||
className={`flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LabelProps {
|
||||
children: React.ReactNode;
|
||||
htmlFor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Label({ children, htmlFor, className = '' }: LabelProps) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={`text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ProgressProps {
|
||||
value: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Progress({ value, className = '' }: ProgressProps) {
|
||||
return (
|
||||
<div className={`w-full bg-gray-200 rounded-full h-2 ${className}`}>
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -1,184 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface SelectProps {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface SelectTriggerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SelectContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SelectItemProps {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SelectValueProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
placeholder,
|
||||
children
|
||||
}: SelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState(value || '');
|
||||
const [selectedLabel, setSelectedLabel] = useState('');
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedValue(value || '');
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (value: string, label: string) => {
|
||||
setSelectedValue(value);
|
||||
setSelectedLabel(label);
|
||||
onValueChange?.(value);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={selectRef} className={`relative ${className}`}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
if (child.type === SelectTrigger) {
|
||||
return React.cloneElement(child as any, {
|
||||
onClick: () => !disabled && setIsOpen(!isOpen),
|
||||
disabled,
|
||||
selectedValue: selectedValue,
|
||||
selectedLabel: selectedLabel,
|
||||
placeholder,
|
||||
isOpen
|
||||
});
|
||||
}
|
||||
if (child.type === SelectContent && isOpen) {
|
||||
return React.cloneElement(child as any, {
|
||||
onSelect: handleSelect
|
||||
});
|
||||
}
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectTrigger({
|
||||
children,
|
||||
className = '',
|
||||
onClick,
|
||||
disabled,
|
||||
selectedValue,
|
||||
selectedLabel,
|
||||
placeholder,
|
||||
isOpen
|
||||
}: SelectTriggerProps & {
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
selectedValue?: string;
|
||||
selectedLabel?: string;
|
||||
placeholder?: string;
|
||||
isOpen?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-left border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${className}`}
|
||||
>
|
||||
<span className="flex items-center justify-between">
|
||||
<span className={selectedValue ? 'text-gray-900' : 'text-gray-500'}>
|
||||
{selectedLabel || placeholder || '请选择...'}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectContent({
|
||||
children,
|
||||
className = '',
|
||||
onSelect
|
||||
}: SelectContentProps & {
|
||||
onSelect?: (value: string, label: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto ${className}`}>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child) && child.type === SelectItem) {
|
||||
return React.cloneElement(child as any, {
|
||||
onSelect
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectItem({
|
||||
value,
|
||||
children,
|
||||
className = '',
|
||||
onSelect
|
||||
}: SelectItemProps & {
|
||||
onSelect?: (value: string, label: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect?.(value, children as string)}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-gray-100 focus:bg-gray-100 focus:outline-none ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectValue({
|
||||
placeholder,
|
||||
className = ''
|
||||
}: SelectValueProps) {
|
||||
return (
|
||||
<span className={className}>
|
||||
{placeholder}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { cn } from "../../utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "../../utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Switch({ checked, onCheckedChange, disabled = false, className = '', id }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50
|
||||
${checked ? 'bg-blue-600' : 'bg-gray-200'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
inline-block h-4 w-4 transform rounded-full bg-white transition-transform
|
||||
${checked ? 'translate-x-6' : 'translate-x-1'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/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("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", 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-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell }
|
||||
@@ -1,52 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/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-9 items-center justify-center rounded-lg 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-md px-3 py-1 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TextareaProps {
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
className = '',
|
||||
disabled = false,
|
||||
rows = 3
|
||||
}: TextareaProps) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
className={`w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
"use client"
|
||||
|
||||
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 "@/utils"
|
||||
|
||||
export type { ToastActionElement, ToastProps };
|
||||
|
||||
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 text-foreground",
|
||||
destructive: "destructive 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>
|
||||
|
||||
// === 以下为 use-toast.ts 的内容迁移 ===
|
||||
|
||||
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 });
|
||||
}, 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: boolean) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
return { id, dismiss, update };
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) listeners.splice(index, 1);
|
||||
};
|
||||
}, []);
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -1,76 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface TooltipProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TooltipProvider({ children }: TooltipProviderProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tooltip({ children }: TooltipProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface TooltipTriggerProps {
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export function TooltipTrigger({ children, asChild }: TooltipTriggerProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface TooltipContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TooltipContent({ children, className = '' }: TooltipContentProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const showTooltip = () => setIsVisible(true);
|
||||
const hideTooltip = () => setIsVisible(false);
|
||||
|
||||
trigger.addEventListener('mouseenter', showTooltip);
|
||||
trigger.addEventListener('mouseleave', hideTooltip);
|
||||
|
||||
return () => {
|
||||
trigger.removeEventListener('mouseenter', showTooltip);
|
||||
trigger.removeEventListener('mouseleave', hideTooltip);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="relative inline-block">
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return React.cloneElement(child, {
|
||||
...child.props,
|
||||
children: (
|
||||
<>
|
||||
{child.props.children}
|
||||
{isVisible && (
|
||||
<div className={`absolute z-50 px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg whitespace-nowrap ${className}`} style={{ top: '-30px', left: '50%', transform: 'translateX(-50%)' }}>
|
||||
{children}
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900"></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +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
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -1,244 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { validateToken, refreshAuthToken } from '@/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;
|
||||
s2_accountId?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
login: (token: string, userData: User) => void;
|
||||
logout: () => void;
|
||||
updateToken: (newToken: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// 创建默认上下文
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
user: null,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
updateToken: () => {},
|
||||
isLoading: true
|
||||
});
|
||||
|
||||
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);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 先声明handleLogout,避免useEffect依赖提前引用
|
||||
const handleLogout = useCallback(() => {
|
||||
// 先清除所有认证相关的状态
|
||||
safeLocalStorage.removeItem("token");
|
||||
safeLocalStorage.removeItem("token_expired");
|
||||
safeLocalStorage.removeItem("s2_accountId");
|
||||
safeLocalStorage.removeItem("userInfo");
|
||||
safeLocalStorage.removeItem("user");
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
// 跳转到登录页面
|
||||
navigate('/login');
|
||||
}, [navigate]);
|
||||
|
||||
// 检查token是否过期
|
||||
const isTokenExpired = (): boolean => {
|
||||
const tokenExpired = safeLocalStorage.getItem("token_expired");
|
||||
if (!tokenExpired) return true;
|
||||
|
||||
try {
|
||||
const expiredTime = new Date(tokenExpired).getTime();
|
||||
const currentTime = new Date().getTime();
|
||||
// 提前5分钟认为过期,给刷新留出时间
|
||||
return currentTime >= (expiredTime - 5 * 60 * 1000);
|
||||
} catch (error) {
|
||||
console.error('解析token过期时间失败:', error);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 验证token有效性
|
||||
const verifyToken = async (): Promise<boolean> => {
|
||||
try {
|
||||
const isValid = await validateToken();
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
console.error('Token验证失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 尝试刷新token
|
||||
const tryRefreshToken = async (): Promise<boolean> => {
|
||||
try {
|
||||
const success = await refreshAuthToken();
|
||||
if (success) {
|
||||
const newToken = safeLocalStorage.getItem("token");
|
||||
if (newToken) {
|
||||
setToken(newToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('刷新token失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化认证状态
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const storedToken = safeLocalStorage.getItem("token");
|
||||
|
||||
if (storedToken) {
|
||||
// 检查token是否过期
|
||||
if (isTokenExpired()) {
|
||||
console.log('Token已过期,尝试刷新...');
|
||||
const refreshSuccess = await tryRefreshToken();
|
||||
if (!refreshSuccess) {
|
||||
console.log('Token刷新失败,需要重新登录');
|
||||
handleLogout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证token有效性
|
||||
const isValid = await verifyToken();
|
||||
if (!isValid) {
|
||||
console.log('Token无效,需要重新登录');
|
||||
handleLogout();
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const userDataStr = safeLocalStorage.getItem("userInfo");
|
||||
if (userDataStr) {
|
||||
try {
|
||||
const userData = JSON.parse(userDataStr) as User;
|
||||
setToken(storedToken);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
} catch (parseError) {
|
||||
console.error('解析用户数据失败:', parseError);
|
||||
handleLogout();
|
||||
}
|
||||
} else {
|
||||
console.warn('找到token但没有用户信息,尝试保持登录状态');
|
||||
setToken(storedToken);
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("初始化认证状态时出错:", error);
|
||||
handleLogout();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
// 定期检查token状态
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const checkTokenInterval = setInterval(async () => {
|
||||
if (isTokenExpired()) {
|
||||
console.log('检测到token即将过期,尝试刷新...');
|
||||
const refreshSuccess = await tryRefreshToken();
|
||||
if (!refreshSuccess) {
|
||||
console.log('Token刷新失败,登出用户');
|
||||
handleLogout();
|
||||
}
|
||||
}
|
||||
}, 60000); // 每分钟检查一次
|
||||
|
||||
return () => clearInterval(checkTokenInterval);
|
||||
}, [isAuthenticated, handleLogout]);
|
||||
|
||||
const login = (newToken: string, userData: User) => {
|
||||
safeLocalStorage.setItem("token", newToken);
|
||||
safeLocalStorage.setItem("userInfo", JSON.stringify(userData));
|
||||
if (userData.s2_accountId) {
|
||||
safeLocalStorage.setItem("s2_accountId", userData.s2_accountId);
|
||||
}
|
||||
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
|
||||
}}>
|
||||
{isLoading && isInitialized ? (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
export interface WechatAccountData {
|
||||
id: string;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
status: "normal" | "abnormal";
|
||||
wechatId: string;
|
||||
wechatAccount: string;
|
||||
deviceName: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
interface WechatAccountContextType {
|
||||
currentAccount: WechatAccountData | null;
|
||||
setCurrentAccount: (account: WechatAccountData) => void;
|
||||
clearCurrentAccount: () => void;
|
||||
}
|
||||
|
||||
const WechatAccountContext = createContext<WechatAccountContextType>({
|
||||
currentAccount: null,
|
||||
setCurrentAccount: () => {},
|
||||
clearCurrentAccount: () => {},
|
||||
});
|
||||
|
||||
export const useWechatAccount = () => useContext(WechatAccountContext);
|
||||
|
||||
interface WechatAccountProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function WechatAccountProvider({ children }: WechatAccountProviderProps) {
|
||||
const [currentAccount, setCurrentAccountState] = useState<WechatAccountData | null>(null);
|
||||
|
||||
const setCurrentAccount = (account: WechatAccountData) => {
|
||||
setCurrentAccountState(account);
|
||||
};
|
||||
|
||||
const clearCurrentAccount = () => {
|
||||
setCurrentAccountState(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<WechatAccountContext.Provider
|
||||
value={{
|
||||
currentAccount,
|
||||
setCurrentAccount,
|
||||
clearCurrentAccount,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WechatAccountContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
// 不需要登录的公共页面路径
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/404',
|
||||
'/500'
|
||||
];
|
||||
|
||||
/**
|
||||
* 认证守卫Hook
|
||||
* 用于在组件中检查用户是否已登录
|
||||
* @param requireAuth 是否需要认证,默认为true
|
||||
* @param redirectTo 未认证时重定向的路径,默认为'/login'
|
||||
*/
|
||||
export function useAuthGuard(requireAuth: boolean = true, redirectTo: string = '/login') {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 检查当前路径是否是公共页面
|
||||
const isPublicPath = PUBLIC_PATHS.some(path =>
|
||||
location.pathname.startsWith(path)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 如果正在加载,不进行任何跳转
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果需要认证但未登录且不是公共页面
|
||||
if (requireAuth && !isAuthenticated && !isPublicPath) {
|
||||
// 保存当前URL,登录后可以重定向回来
|
||||
const returnUrl = encodeURIComponent(window.location.href);
|
||||
navigate(`${redirectTo}?returnUrl=${returnUrl}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已登录但在登录页面,重定向到首页
|
||||
if (isAuthenticated && location.pathname === '/login') {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, isLoading, location.pathname, navigate, requireAuth, redirectTo, isPublicPath]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isPublicPath,
|
||||
// 是否应该显示内容
|
||||
shouldRender: !isLoading && (isAuthenticated || isPublicPath || !requireAuth)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的认证检查Hook
|
||||
* 只返回认证状态,不进行自动重定向
|
||||
*/
|
||||
export function useAuthCheck() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const isPublicPath = PUBLIC_PATHS.some(path =>
|
||||
location.pathname.startsWith(path)
|
||||
);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isPublicPath,
|
||||
// 是否需要认证
|
||||
requiresAuth: !isPublicPath
|
||||
};
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
interface BackNavigationOptions {
|
||||
/** 默认返回路径,当没有历史记录时使用 */
|
||||
defaultPath?: string;
|
||||
/** 是否在组件卸载时保存当前路径到历史记录 */
|
||||
saveOnUnmount?: boolean;
|
||||
/** 最大历史记录数量 */
|
||||
maxHistoryLength?: number;
|
||||
/** 自定义返回逻辑 */
|
||||
customBackLogic?: (history: string[], currentPath: string) => string | null;
|
||||
}
|
||||
|
||||
interface BackNavigationReturn {
|
||||
/** 返回上一页 */
|
||||
goBack: () => void;
|
||||
/** 返回到指定路径 */
|
||||
goTo: (path: string) => void;
|
||||
/** 返回到首页 */
|
||||
goHome: () => void;
|
||||
/** 检查是否可以返回 */
|
||||
canGoBack: () => boolean;
|
||||
/** 获取历史记录 */
|
||||
getHistory: () => string[];
|
||||
/** 清除历史记录 */
|
||||
clearHistory: () => void;
|
||||
/** 当前路径 */
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 高级返回导航Hook
|
||||
* 提供更智能的返回逻辑和历史记录管理
|
||||
*/
|
||||
export const useBackNavigation = (options: BackNavigationOptions = {}): BackNavigationReturn => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const historyRef = useRef<string[]>([]);
|
||||
const {
|
||||
defaultPath = '/',
|
||||
saveOnUnmount = true,
|
||||
maxHistoryLength = 10,
|
||||
customBackLogic
|
||||
} = options;
|
||||
|
||||
// 保存路径到历史记录
|
||||
const saveToHistory = useCallback((path: string) => {
|
||||
const history = historyRef.current;
|
||||
|
||||
// 如果路径已经存在,移除它
|
||||
const filteredHistory = history.filter(p => p !== path);
|
||||
|
||||
// 添加到开头
|
||||
filteredHistory.unshift(path);
|
||||
|
||||
// 限制历史记录长度
|
||||
if (filteredHistory.length > maxHistoryLength) {
|
||||
filteredHistory.splice(maxHistoryLength);
|
||||
}
|
||||
|
||||
historyRef.current = filteredHistory;
|
||||
}, [maxHistoryLength]);
|
||||
|
||||
// 获取历史记录
|
||||
const getHistory = useCallback(() => {
|
||||
return [...historyRef.current];
|
||||
}, []);
|
||||
|
||||
// 清除历史记录
|
||||
const clearHistory = useCallback(() => {
|
||||
historyRef.current = [];
|
||||
}, []);
|
||||
|
||||
// 检查是否可以返回
|
||||
const canGoBack = useCallback(() => {
|
||||
return historyRef.current.length > 1 || window.history.length > 1;
|
||||
}, []);
|
||||
|
||||
// 返回上一页
|
||||
const goBack = useCallback(() => {
|
||||
const history = getHistory();
|
||||
|
||||
// 如果有自定义返回逻辑,使用它
|
||||
if (customBackLogic) {
|
||||
const targetPath = customBackLogic(history, location.pathname);
|
||||
if (targetPath) {
|
||||
navigate(targetPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有历史记录,返回到上一个路径
|
||||
if (history.length > 1) {
|
||||
const previousPath = history[1]; // 当前路径在索引0,上一个在索引1
|
||||
navigate(previousPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果浏览器历史记录有上一页,使用浏览器返回
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 最后回退到默认路径
|
||||
navigate(defaultPath);
|
||||
}, [navigate, location.pathname, getHistory, customBackLogic, defaultPath]);
|
||||
|
||||
// 返回到指定路径
|
||||
const goTo = useCallback((path: string) => {
|
||||
navigate(path);
|
||||
}, [navigate]);
|
||||
|
||||
// 返回到首页
|
||||
const goHome = useCallback(() => {
|
||||
navigate('/');
|
||||
}, [navigate]);
|
||||
|
||||
// 组件挂载时保存当前路径
|
||||
useEffect(() => {
|
||||
saveToHistory(location.pathname);
|
||||
}, [location.pathname, saveToHistory]);
|
||||
|
||||
// 组件卸载时保存路径(可选)
|
||||
useEffect(() => {
|
||||
if (!saveOnUnmount) return;
|
||||
|
||||
return () => {
|
||||
saveToHistory(location.pathname);
|
||||
};
|
||||
}, [location.pathname, saveToHistory, saveOnUnmount]);
|
||||
|
||||
return {
|
||||
goBack,
|
||||
goTo,
|
||||
goHome,
|
||||
canGoBack,
|
||||
getHistory,
|
||||
clearHistory,
|
||||
currentPath: location.pathname
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 简化的返回Hook,只提供基本的返回功能
|
||||
*/
|
||||
export const useSimpleBack = (defaultPath: string = '/') => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate(defaultPath);
|
||||
}
|
||||
}, [navigate, defaultPath]);
|
||||
|
||||
return { goBack };
|
||||
};
|
||||
|
||||
/**
|
||||
* 带确认的返回Hook
|
||||
*/
|
||||
export const useConfirmBack = (
|
||||
message: string = '确定要离开当前页面吗?',
|
||||
defaultPath: string = '/'
|
||||
) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (window.confirm(message)) {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate(defaultPath);
|
||||
}
|
||||
}
|
||||
}, [navigate, message, defaultPath]);
|
||||
|
||||
return { goBack };
|
||||
};
|
||||
@@ -1,265 +0,0 @@
|
||||
import { useCallback, useRef, useState, useEffect } from 'react';
|
||||
import { requestDeduplicator, requestCancelManager } from '../api/utils';
|
||||
|
||||
// 节流请求Hook
|
||||
export const useThrottledRequest = <T extends (...args: any[]) => any>(
|
||||
requestFn: T,
|
||||
delay: number = 1000
|
||||
) => {
|
||||
const lastCallRef = useRef(0);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const throttledRequest = useCallback(
|
||||
((...args: any[]) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastCallRef.current < delay) {
|
||||
// 如果在节流时间内,取消之前的定时器并设置新的
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
lastCallRef.current = now;
|
||||
requestFn(...args);
|
||||
}, delay - (now - lastCallRef.current));
|
||||
} else {
|
||||
// 如果超过节流时间,直接执行
|
||||
lastCallRef.current = now;
|
||||
requestFn(...args);
|
||||
}
|
||||
}) as T,
|
||||
[requestFn, delay]
|
||||
);
|
||||
|
||||
return throttledRequest;
|
||||
};
|
||||
|
||||
// 防抖请求Hook
|
||||
export const useDebouncedRequest = <T extends (...args: any[]) => any>(
|
||||
requestFn: T,
|
||||
delay: number = 300
|
||||
) => {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const debouncedRequest = useCallback(
|
||||
((...args: any[]) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
requestFn(...args);
|
||||
}, delay);
|
||||
}) as T,
|
||||
[requestFn, delay]
|
||||
);
|
||||
|
||||
return debouncedRequest;
|
||||
};
|
||||
|
||||
// 带加载状态的请求Hook
|
||||
export const useRequestWithLoading = <T extends (...args: any[]) => Promise<any>>(
|
||||
requestFn: T
|
||||
) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const requestWithLoading = useCallback(
|
||||
(async (...args: any[]) => {
|
||||
if (loading) {
|
||||
console.log('请求正在进行中,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await requestFn(...args);
|
||||
return result;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}) as T,
|
||||
[requestFn, loading]
|
||||
);
|
||||
|
||||
return { requestWithLoading, loading };
|
||||
};
|
||||
|
||||
// 组合Hook:节流 + 加载状态
|
||||
export const useThrottledRequestWithLoading = <T extends (...args: any[]) => Promise<any>>(
|
||||
requestFn: T,
|
||||
delay: number = 1000
|
||||
) => {
|
||||
const { requestWithLoading, loading } = useRequestWithLoading(requestFn);
|
||||
const throttledRequest = useThrottledRequest(requestWithLoading, delay);
|
||||
|
||||
return { throttledRequest, loading };
|
||||
};
|
||||
|
||||
// 带错误处理的请求Hook
|
||||
export const useRequestWithError = <T extends (...args: any[]) => Promise<any>>(
|
||||
requestFn: T
|
||||
) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const requestWithError = useCallback(
|
||||
(async (...args: any[]) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await requestFn(...args);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '请求失败';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}) as T,
|
||||
[requestFn]
|
||||
);
|
||||
|
||||
return { requestWithError, loading, error, clearError: () => setError(null) };
|
||||
};
|
||||
|
||||
// 带重试的请求Hook
|
||||
export const useRequestWithRetry = <T extends (...args: any[]) => Promise<any>>(
|
||||
requestFn: T,
|
||||
maxRetries: number = 3,
|
||||
retryDelay: number = 1000
|
||||
) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const requestWithRetry = useCallback(
|
||||
(async (...args: any[]) => {
|
||||
setLoading(true);
|
||||
setRetryCount(0);
|
||||
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
setRetryCount(attempt);
|
||||
const result = await requestFn(...args);
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}) as T,
|
||||
[requestFn, maxRetries, retryDelay]
|
||||
);
|
||||
|
||||
return { requestWithRetry, loading, retryCount };
|
||||
};
|
||||
|
||||
// 可取消的请求Hook
|
||||
export const useCancellableRequest = <T extends (...args: any[]) => Promise<any>>(
|
||||
requestFn: T
|
||||
) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const cancellableRequest = useCallback(
|
||||
(async (...args: any[]) => {
|
||||
// 取消之前的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// 创建新的AbortController
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await requestFn(...args);
|
||||
return result;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}) as T,
|
||||
[requestFn]
|
||||
);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
setLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件卸载时取消请求
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { cancellableRequest, loading, cancelRequest };
|
||||
};
|
||||
|
||||
// 组合Hook:节流 + 加载状态 + 错误处理
|
||||
export const useThrottledRequestWithError = <T extends (...args: any[]) => Promise<any>>(
|
||||
requestFn: T,
|
||||
delay: number = 1000
|
||||
) => {
|
||||
const { requestWithError, loading, error, clearError } = useRequestWithError(requestFn);
|
||||
const throttledRequest = useThrottledRequest(requestWithError, delay);
|
||||
|
||||
return { throttledRequest, loading, error, clearError };
|
||||
};
|
||||
|
||||
// 组合Hook:防抖 + 加载状态 + 错误处理
|
||||
export const useDebouncedRequestWithError = <T extends (...args: any[]) => Promise<any>>(
|
||||
requestFn: T,
|
||||
delay: number = 300
|
||||
) => {
|
||||
const { requestWithError, loading, error, clearError } = useRequestWithError(requestFn);
|
||||
const debouncedRequest = useDebouncedRequest(requestWithError, delay);
|
||||
|
||||
return { debouncedRequest, loading, error, clearError };
|
||||
};
|
||||
|
||||
// 请求状态监控Hook
|
||||
export const useRequestMonitor = () => {
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const updatePendingCount = () => {
|
||||
setPendingCount(requestDeduplicator.getPendingCount());
|
||||
};
|
||||
|
||||
// 初始更新
|
||||
updatePendingCount();
|
||||
|
||||
// 定期检查待处理请求数量
|
||||
const interval = setInterval(updatePendingCount, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const cancelAllRequests = useCallback(() => {
|
||||
requestCancelManager.cancelAllRequests();
|
||||
requestDeduplicator.clear();
|
||||
}, []);
|
||||
|
||||
return { pendingCount, cancelAllRequests };
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
font-size: 16px; /* 基础字体大小,1rem = 16px */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 1rem; /* 16px */
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media screen and (max-width: 768px) {
|
||||
html {
|
||||
font-size: 16px; /* 移动端保持16px基础字体大小 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕设备适配 */
|
||||
@media screen and (max-width: 375px) {
|
||||
html {
|
||||
font-size: 14px; /* 小屏幕设备稍微减小字体 */
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
// 全局错误处理 - 过滤浏览器扩展错误
|
||||
window.addEventListener('error', (event) => {
|
||||
// 过滤掉扩展相关的错误
|
||||
if (event.filename && (
|
||||
event.filename.includes('content_scripts') ||
|
||||
event.filename.includes('extension') ||
|
||||
event.filename.includes('chrome-extension') ||
|
||||
event.filename.includes('moz-extension')
|
||||
)) {
|
||||
event.preventDefault();
|
||||
console.warn('浏览器扩展错误已忽略:', event.message);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理未捕获的 Promise 错误
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const errorMessage = event.reason?.message || event.reason?.toString() || '';
|
||||
if (errorMessage.includes('shadowRoot') ||
|
||||
errorMessage.includes('content_scripts') ||
|
||||
errorMessage.includes('extension')) {
|
||||
event.preventDefault();
|
||||
console.warn('浏览器扩展 Promise 错误已忽略:', event.reason);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,490 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Smartphone, Users, Activity, MessageSquare, TrendingUp } from 'lucide-react';
|
||||
import Chart from 'chart.js/auto';
|
||||
import Layout from '@/components/Layout';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import UnifiedHeader, { HeaderPresets } from '@/components/UnifiedHeader';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
// API接口定义
|
||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "https://ckbapi.quwanzhi.com";
|
||||
|
||||
// 统一的API请求客户端
|
||||
async function apiRequest<T>(url: string): Promise<T> {
|
||||
try {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
console.log("发送API请求:", url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
console.log("API响应状态:", response.status, response.statusText);
|
||||
|
||||
// 检查响应头的Content-Type
|
||||
const contentType = response.headers.get("content-type");
|
||||
console.log("响应Content-Type:", contentType);
|
||||
|
||||
if (!response.ok) {
|
||||
// 如果是401未授权,清除本地存储
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("userInfo");
|
||||
}
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 检查是否是JSON响应
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text();
|
||||
console.log("非JSON响应内容:", text.substring(0, 200));
|
||||
throw new Error("服务器返回了非JSON格式的数据,可能是HTML错误页面");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("API响应数据:", data);
|
||||
|
||||
// 检查业务状态码
|
||||
if (data.code && data.code !== 200 && data.code !== 0) {
|
||||
throw new Error(data.message || "请求失败");
|
||||
}
|
||||
|
||||
return data.data || data;
|
||||
} catch (error) {
|
||||
console.error("API请求失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
const chartRef = useRef<HTMLCanvasElement>(null);
|
||||
const chartInstance = useRef<any>(null);
|
||||
|
||||
// 统一设备数据
|
||||
const [stats, setStats] = useState({
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
totalWechatAccounts: 0,
|
||||
onlineWechatAccounts: 0,
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState("");
|
||||
|
||||
// 场景获客数据
|
||||
const scenarioFeatures = [
|
||||
{
|
||||
id: "douyin",
|
||||
name: "抖音获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-QR8ManuDplYTySUJsY4mymiZkDYnQ9.png",
|
||||
color: "bg-blue-100 text-blue-600",
|
||||
value: 156,
|
||||
growth: 12,
|
||||
},
|
||||
{
|
||||
id: "xiaohongshu",
|
||||
name: "小红书获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png",
|
||||
color: "bg-red-100 text-red-600",
|
||||
value: 89,
|
||||
growth: 8,
|
||||
},
|
||||
{
|
||||
id: "gongzhonghao",
|
||||
name: "公众号获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-Gsg0CMf5tsZb41mioszdjqU1WmsRxW.png",
|
||||
color: "bg-green-100 text-green-600",
|
||||
value: 234,
|
||||
growth: 15,
|
||||
},
|
||||
{
|
||||
id: "haibao",
|
||||
name: "海报获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-x92XJgXy4MI7moNYlA1EAes2FqDxMH.png",
|
||||
color: "bg-orange-100 text-orange-600",
|
||||
value: 167,
|
||||
growth: 10,
|
||||
},
|
||||
];
|
||||
|
||||
// 今日数据统计
|
||||
const todayStats = [
|
||||
{
|
||||
title: "朋友圈同步",
|
||||
value: "12",
|
||||
icon: <MessageSquare className="h-4 w-4" />,
|
||||
color: "text-purple-600",
|
||||
path: "/workspace/moments-sync",
|
||||
},
|
||||
{
|
||||
title: "群发任务",
|
||||
value: "8",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
color: "text-orange-600",
|
||||
path: "/workspace/group-push",
|
||||
},
|
||||
{
|
||||
title: "获客转化",
|
||||
value: "85%",
|
||||
icon: <TrendingUp className="h-4 w-4" />,
|
||||
color: "text-green-600",
|
||||
path: "/scenarios",
|
||||
},
|
||||
{
|
||||
title: "系统活跃度",
|
||||
value: "98%",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
color: "text-blue-600",
|
||||
path: "/workspace",
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setApiError("");
|
||||
|
||||
// 检查是否有token
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
console.log("未找到登录token,使用默认数据");
|
||||
setStats({
|
||||
totalDevices: 42,
|
||||
onlineDevices: 35,
|
||||
totalWechatAccounts: 42,
|
||||
onlineWechatAccounts: 35,
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试请求API数据
|
||||
try {
|
||||
// 并行请求多个接口
|
||||
const [deviceStatsResult, wechatStatsResult] = await Promise.allSettled([
|
||||
apiRequest(`${API_BASE_URL}/v1/dashboard/device-stats`),
|
||||
apiRequest(`${API_BASE_URL}/v1/dashboard/wechat-stats`),
|
||||
]);
|
||||
|
||||
const newStats = {
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
totalWechatAccounts: 0,
|
||||
onlineWechatAccounts: 0,
|
||||
};
|
||||
|
||||
// 处理设备统计数据
|
||||
if (deviceStatsResult.status === "fulfilled") {
|
||||
const deviceData = deviceStatsResult.value as any;
|
||||
newStats.totalDevices = deviceData.total || 0;
|
||||
newStats.onlineDevices = deviceData.online || 0;
|
||||
} else {
|
||||
console.warn("设备统计API失败:", deviceStatsResult.reason);
|
||||
}
|
||||
|
||||
// 处理微信号统计数据
|
||||
if (wechatStatsResult.status === "fulfilled") {
|
||||
const wechatData = wechatStatsResult.value as any;
|
||||
newStats.totalWechatAccounts = wechatData.total || 0;
|
||||
newStats.onlineWechatAccounts = wechatData.active || 0;
|
||||
} else {
|
||||
console.warn("微信号统计API失败:", wechatStatsResult.reason);
|
||||
}
|
||||
|
||||
setStats(newStats);
|
||||
} catch (apiError) {
|
||||
console.warn("API请求失败,使用默认数据:", apiError);
|
||||
setApiError(apiError instanceof Error ? apiError.message : "API连接失败");
|
||||
|
||||
// 使用默认数据
|
||||
setStats({
|
||||
totalDevices: 42,
|
||||
onlineDevices: 35,
|
||||
totalWechatAccounts: 42,
|
||||
onlineWechatAccounts: 35,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取统计数据失败:", error);
|
||||
setApiError(error instanceof Error ? error.message : "数据加载失败");
|
||||
|
||||
// 使用默认数据
|
||||
setStats({
|
||||
totalDevices: 42,
|
||||
onlineDevices: 35,
|
||||
totalWechatAccounts: 42,
|
||||
onlineWechatAccounts: 35,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
|
||||
// 定时刷新数据(每30秒)
|
||||
const interval = setInterval(fetchStats, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []); // 移除stats依赖
|
||||
|
||||
const handleDevicesClick = () => {
|
||||
navigate('/profile/devices');
|
||||
};
|
||||
|
||||
const handleWechatClick = () => {
|
||||
navigate('/wechat-accounts');
|
||||
};
|
||||
|
||||
// 使用Chart.js创建图表
|
||||
useEffect(() => {
|
||||
if (chartRef.current && !isLoading) {
|
||||
// 如果已经有图表实例,先销毁它
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
|
||||
const ctx = chartRef.current.getContext("2d");
|
||||
|
||||
// 添加null检查
|
||||
if (!ctx) return;
|
||||
|
||||
// 创建新的图表实例
|
||||
chartInstance.current = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
|
||||
datasets: [
|
||||
{
|
||||
label: "获客数量",
|
||||
data: [120, 150, 180, 200, 230, 210, 190],
|
||||
backgroundColor: "rgba(59, 130, 246, 0.2)",
|
||||
borderColor: "rgba(59, 130, 246, 1)",
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: "rgba(59, 130, 246, 1)",
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
titleColor: "#333",
|
||||
bodyColor: "#666",
|
||||
borderColor: "#ddd",
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (context) => `获客数量: ${context.parsed.y}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0, 0, 0, 0.05)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 组件卸载时清理图表实例
|
||||
return () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [isLoading]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div className="bg-white border-b">
|
||||
<div className="flex justify-between items-center p-4">
|
||||
<h1 className="text-xl font-semibold text-blue-600">存客宝</h1>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i} className="p-3 bg-white animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-6 bg-gray-200 rounded"></div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title="存客宝"
|
||||
showBack={false}
|
||||
titleColor="blue"
|
||||
rightContent={
|
||||
<>
|
||||
{apiError && (
|
||||
<div className="text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded mr-2">
|
||||
API连接异常,显示默认数据
|
||||
</div>
|
||||
)}
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="cursor-pointer" onClick={handleDevicesClick}>
|
||||
<Card className="p-3 bg-white hover:shadow-md transition-all">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">设备数量</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-blue-600">{stats.totalDevices}</span>
|
||||
<Smartphone className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={handleWechatClick}>
|
||||
<Card className="p-3 bg-white hover:shadow-md transition-all">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">微信号数量</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-blue-600">{stats.totalWechatAccounts}</span>
|
||||
<Users className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="p-3 bg-white">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">在线微信号</span>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-lg font-bold text-blue-600">{stats.onlineWechatAccounts}</span>
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
stats.totalWechatAccounts > 0 ? (stats.onlineWechatAccounts / stats.totalWechatAccounts) * 100 : 0
|
||||
}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 场景获客统计 */}
|
||||
<Card className="p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-base font-semibold">场景获客统计</h2>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
{scenarioFeatures
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 4) // 只显示前4个
|
||||
.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className="block flex-1 cursor-pointer"
|
||||
onClick={() => navigate(`/scenarios/${scenario.id}?name=${encodeURIComponent(scenario.name)}`)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-1">
|
||||
<div className={`w-10 h-10 rounded-full ${scenario.color} flex items-center justify-center`}>
|
||||
<img src={scenario.icon || "/placeholder.svg"} alt={scenario.name} className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-sm font-medium">{scenario.value}</div>
|
||||
<div className="text-xs text-gray-500 whitespace-nowrap overflow-hidden text-ellipsis w-full">
|
||||
{scenario.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 今日数据统计 */}
|
||||
<Card className="p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-base font-semibold">今日数据</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{todayStats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => stat.path && navigate(stat.path)}
|
||||
>
|
||||
<div className={`p-2 rounded-full bg-white ${stat.color}`}>{stat.icon}</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{stat.value}</div>
|
||||
<div className="text-xs text-gray-500">{stat.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 每日获客趋势 */}
|
||||
<Card className="p-4 bg-white">
|
||||
<h2 className="text-base font-semibold mb-3">每日获客趋势</h2>
|
||||
<div className="w-full h-48 relative">
|
||||
<canvas ref={chartRef} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ContactImport() {
|
||||
return <div>导入通讯录页</div>;
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Filter, Search, RefreshCw, Plus, Edit, Trash2, Eye, MoreVertical, Copy } 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { get, del } from '@/api/request';
|
||||
import Layout from '@/components/Layout';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
function CardMenu({ onView, onEdit, onDelete, onViewMaterials }: {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewMaterials: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
zIndex: 100,
|
||||
minWidth: 120,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Edit className="h-4 w-4 mr-2" />编辑
|
||||
</div>
|
||||
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />删除
|
||||
</div>
|
||||
<div onClick={() => { onViewMaterials(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Eye className="h-4 w-4 mr-2" />查看素材
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Content() {
|
||||
const navigate = useNavigate();
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 获取内容库列表
|
||||
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 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 {
|
||||
toast({ title: '获取失败', description: response.msg || '获取内容库列表失败' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取内容库列表失败:', error);
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQuery, activeTab, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, [fetchLibraries]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/content/new');
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
navigate(`/content/edit/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const response = await del<ApiResponse>(`/v1/content/library/delete?id=${id}`);
|
||||
if (response.code === 200) {
|
||||
toast({ title: '删除成功', description: '内容库已删除' });
|
||||
fetchLibraries();
|
||||
} else {
|
||||
toast({ title: '删除失败', description: response.msg || '删除失败' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('删除内容库失败:', error);
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMaterials = (id: string) => {
|
||||
navigate(`/content/materials/${id}`);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const filteredLibraries = libraries.filter(
|
||||
(library) =>
|
||||
library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
library.targetAudience.some((target) => target.nickname.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<UnifiedHeader title="内容库" showBack />
|
||||
<div className="bg-white shadow-sm rounded-b-xl px-4 pt-4 pb-2">
|
||||
<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 rounded-full bg-gray-50 border-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="rounded-full border-gray-200"
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button onClick={handleCreateNew} className="rounded-full px-4 py-2" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />新建
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3 rounded-full bg-gray-100">
|
||||
<TabsTrigger value="all" className="rounded-full">全部</TabsTrigger>
|
||||
<TabsTrigger value="friends" className="rounded-full">微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="rounded-full">聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="space-y-4 p-4">
|
||||
<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 flex-col items-center justify-center py-16 text-gray-400">
|
||||
<img src="/empty-state-content.svg" alt="暂无内容库" className="w-32 h-32 mb-4 opacity-80" />
|
||||
<div className="mb-2">暂无内容库,快去新建一个吧!</div>
|
||||
<Button onClick={handleCreateNew} size="sm" className="rounded-full px-6">新建内容库</Button>
|
||||
</div>
|
||||
) : (
|
||||
filteredLibraries.map((library, idx) => (
|
||||
<Card
|
||||
key={library.id}
|
||||
className={`p-4 rounded-xl shadow-sm border border-gray-100 transition hover:shadow-md bg-white ${idx !== filteredLibraries.length - 1 ? 'mb-2' : ''}`}
|
||||
>
|
||||
<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 text-gray-900">{library.name}</h3>
|
||||
<Badge variant={library.isEnabled === 1 ? 'default' : 'secondary'} className="text-xs rounded-full px-2">
|
||||
{library.isEnabled === 1 ? '已启用' : '未启用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs 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>
|
||||
<CardMenu
|
||||
onView={() => navigate(`/content/${library.id}`)}
|
||||
onEdit={() => handleEdit(library.id)}
|
||||
onDelete={() => handleDelete(library.id)}
|
||||
onViewMaterials={() => handleViewMaterials(library.id)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Layout from "@/components/Layout";
|
||||
import UnifiedHeader from "@/components/UnifiedHeader";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Collapse, CollapsePanel, Button } from "tdesign-mobile-react";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import { get, post } from "@/api/request";
|
||||
// TODO: 引入微信好友/群组选择器、日期选择器等组件
|
||||
|
||||
interface WechatFriend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface ContentLibraryForm {
|
||||
name: string;
|
||||
sourceType: "friends" | "groups";
|
||||
keywordsInclude: string;
|
||||
keywordsExclude: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
selectedFriends: WechatFriend[];
|
||||
selectedGroups: WechatGroup[];
|
||||
useAI: boolean;
|
||||
aiPrompt: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function NewContentLibraryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = !!id;
|
||||
const [form, setForm] = useState<ContentLibraryForm>({
|
||||
name: "",
|
||||
sourceType: "friends",
|
||||
keywordsInclude: "",
|
||||
keywordsExclude: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
selectedFriends: [],
|
||||
selectedGroups: [],
|
||||
useAI: false,
|
||||
aiPrompt: "",
|
||||
enabled: true,
|
||||
});
|
||||
const [selectedFriendObjs, setSelectedFriendObjs] = useState<WechatFriend[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedGroupObjs, setSelectedGroupObjs] = useState<WechatGroup[]>([]);
|
||||
const [isFriendSelectorOpen, setIsFriendSelectorOpen] = useState(false);
|
||||
const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
(async () => {
|
||||
const res = await get(`/v1/content/library/detail?id=${id}`);
|
||||
if (res && res.code === 200 && res.data) {
|
||||
const data = res.data;
|
||||
// 时间戳转YYYY-MM-DD
|
||||
const formatDate = (val: number) => {
|
||||
if (
|
||||
!val ||
|
||||
val === 0 ||
|
||||
typeof val !== "number" ||
|
||||
isNaN(val) ||
|
||||
val < 1000000000
|
||||
)
|
||||
return "";
|
||||
try {
|
||||
const d = new Date(val * 1000);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name: data.name || "",
|
||||
sourceType: data.sourceType === 1 ? "friends" : "groups",
|
||||
keywordsInclude: (data.keywordInclude || []).join(","),
|
||||
keywordsExclude: (data.keywordExclude || []).join(","),
|
||||
startDate: formatDate(data.timeStart),
|
||||
endDate: formatDate(data.timeEnd),
|
||||
selectedFriends: (
|
||||
data.selectedFriends ||
|
||||
data.sourceFriends ||
|
||||
[]
|
||||
).map((fid: number | string) => ({
|
||||
id: String(fid),
|
||||
nickname: String(fid),
|
||||
avatar: "",
|
||||
})),
|
||||
selectedGroups: (data.sourceGroups || []).map(
|
||||
(gid: number | string) => ({
|
||||
id: String(gid),
|
||||
name: String(gid),
|
||||
avatar: "",
|
||||
})
|
||||
),
|
||||
useAI: data.aiEnabled === 1,
|
||||
aiPrompt: data.aiPrompt || "",
|
||||
enabled: data.status === 1,
|
||||
}));
|
||||
setSelectedFriendObjs(
|
||||
(data.selectedFriends || data.sourceFriends || []).map(
|
||||
(fid: number | string) => ({
|
||||
id: String(fid),
|
||||
nickname: String(fid),
|
||||
avatar: "",
|
||||
})
|
||||
)
|
||||
);
|
||||
setSelectedGroupObjs(
|
||||
(data.sourceGroups || []).map((gid: number | string) => ({
|
||||
id: String(gid),
|
||||
name: String(gid),
|
||||
avatar: "",
|
||||
}))
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
// TODO: 选择器、日期选择器等逻辑
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
id: isEdit ? id : undefined,
|
||||
name: form.name,
|
||||
sourceType: form.sourceType === "friends" ? 1 : 2,
|
||||
friends: form.selectedFriends.map((f) => Number(f.id)),
|
||||
groups: form.selectedGroups.map((g) => Number(g.id)),
|
||||
groupMembers: {},
|
||||
keywordInclude: form.keywordsInclude
|
||||
? form.keywordsInclude
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
keywordExclude: form.keywordsExclude
|
||||
? form.keywordsExclude
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
aiPrompt: form.aiPrompt,
|
||||
timeEnabled: form.startDate || form.endDate ? 1 : 0,
|
||||
startTime: form.startDate || "",
|
||||
endTime: form.endDate || "",
|
||||
status: form.enabled ? 1 : 0,
|
||||
};
|
||||
if (isEdit) {
|
||||
await post("/v1/content/library/update", payload);
|
||||
} else {
|
||||
await post("/v1/content/library/create", payload);
|
||||
}
|
||||
toast({
|
||||
title: isEdit ? "保存成功" : "创建成功",
|
||||
description: "内容库已保存",
|
||||
});
|
||||
navigate("/content");
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: isEdit ? "保存失败" : "创建失败",
|
||||
description: "保存内容库失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title={isEdit ? "编辑内容库" : "新建内容库"}
|
||||
showBack
|
||||
onBack={() => navigate(-1)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<div className="p-4">
|
||||
<Button
|
||||
theme="primary"
|
||||
block
|
||||
onClick={handleSave}
|
||||
disabled={isSubmitting || !form.name}
|
||||
>
|
||||
{isSubmitting
|
||||
? isEdit
|
||||
? "保存中..."
|
||||
: "创建中..."
|
||||
: isEdit
|
||||
? "保存"
|
||||
: "创建内容库"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex-1 bg-gray-50 ">
|
||||
<div className="p-4 space-y-4 max-w-lg mx-auto">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-medium mb-1">
|
||||
内容库名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, name: e.target.value }))
|
||||
}
|
||||
placeholder="请输入内容库名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">数据来源配置</label>
|
||||
<Tabs
|
||||
value={form.sourceType}
|
||||
onValueChange={(val) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
sourceType: val as "friends" | "groups",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="friends">选择微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">选择聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="friends">
|
||||
<FriendSelection
|
||||
selectedFriends={form.selectedFriends.map((f) => f.id)}
|
||||
onSelect={(ids) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
selectedFriends: ids.map((id) => ({
|
||||
id,
|
||||
nickname: id,
|
||||
avatar: "",
|
||||
})),
|
||||
}))
|
||||
}
|
||||
onSelectDetail={setSelectedFriendObjs}
|
||||
enableDeviceFilter={false}
|
||||
placeholder="选择微信好友"
|
||||
/>
|
||||
{selectedFriendObjs.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{selectedFriendObjs.map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="flex items-center justify-between bg-gray-100 p-2 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{friend.avatar ? (
|
||||
<img
|
||||
src={friend.avatar}
|
||||
alt={friend.nickname}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white text-sm">
|
||||
{friend.nickname?.charAt(0) || "友"}
|
||||
</div>
|
||||
)}
|
||||
<span>{friend.nickname}</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
onClick={() => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
selectedFriends: f.selectedFriends.filter(
|
||||
(frd) => frd.id !== friend.id
|
||||
),
|
||||
}));
|
||||
setSelectedFriendObjs((objs) =>
|
||||
objs.filter((frd) => frd.id !== friend.id)
|
||||
);
|
||||
}}
|
||||
title="移除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="groups">
|
||||
<GroupSelection
|
||||
selectedGroups={form.selectedGroups.map((g) => g.id)}
|
||||
onSelect={(ids) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
selectedGroups: ids.map((id) => {
|
||||
const old = f.selectedGroups.find(
|
||||
(g) => g.id === id
|
||||
);
|
||||
return old || { id, name: id, avatar: "" };
|
||||
}),
|
||||
}))
|
||||
}
|
||||
onSelectDetail={setSelectedGroupObjs}
|
||||
placeholder="选择群聊"
|
||||
/>
|
||||
{selectedGroupObjs.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{selectedGroupObjs.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="flex items-center justify-between bg-gray-100 p-2 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{group.avatar ? (
|
||||
<img
|
||||
src={group.avatar}
|
||||
alt={group.name}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white text-sm">
|
||||
{group.name?.charAt(0) || "群"}
|
||||
</div>
|
||||
)}
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
onClick={() => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
selectedGroups: f.selectedGroups.filter(
|
||||
(grp) => grp.id !== group.id
|
||||
),
|
||||
}));
|
||||
setSelectedGroupObjs((objs) =>
|
||||
objs.filter((grp) => grp.id !== group.id)
|
||||
);
|
||||
}}
|
||||
title="移除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Collapse>
|
||||
<CollapsePanel header="关键字设置" value="keywords">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-medium mb-1">
|
||||
关键字匹配
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.keywordsInclude}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
keywordsInclude: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">
|
||||
关键字排除
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.keywordsExclude}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
keywordsExclude: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="排除含有这些关键字的内容。多个关键字,用半角的','隔开。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block font-medium">是否启用AI</label>
|
||||
</div>
|
||||
<div className="w-10">
|
||||
<Switch
|
||||
checked={form.useAI}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((f) => ({ ...f, useAI: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 ">
|
||||
当启用AI之后,该内容库下的所有内容,都会通过AI重新生成内容。
|
||||
</p>
|
||||
{form.useAI && (
|
||||
<div>
|
||||
<label className="block font-medium mb-1">AI 提示词</label>
|
||||
<Textarea
|
||||
value={form.aiPrompt}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, aiPrompt: e.target.value }))
|
||||
}
|
||||
placeholder="请输入 AI 提示词"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block font-medium mb-2">时间限制</label>
|
||||
{/* TODO: 替换为TDesign日期范围选择器 */}
|
||||
<div
|
||||
className="flex mb-2"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<label className="text-sm w-20 ">开始时间</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, startDate: e.target.value }))
|
||||
}
|
||||
className="inline-block w-1/2 "
|
||||
/>
|
||||
</div>
|
||||
<div className="flex ">
|
||||
<label className="text-sm w-20">结束时间</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.endDate}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, endDate: e.target.value }))
|
||||
}
|
||||
className="inline-block w-1/2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block font-medium mb-1">是否启用</label>
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((f) => ({ ...f, enabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/* TODO: 微信好友/群组选择器弹窗、日期选择器弹窗 */}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Layout from '@/components/Layout';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { get, del } from '@/api/request';
|
||||
import { Plus, Search, Edit, Trash2, UserCircle2, Tag, BarChart } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
interface MaterialItem {
|
||||
id: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
type?: string; // 可选: text/image/video/link
|
||||
images?: string[];
|
||||
video?: string;
|
||||
createTime?: string;
|
||||
status?: string;
|
||||
title?: string; // Added for new card structure
|
||||
creatorName?: string; // Added for new card structure
|
||||
aiAnalysis?: string; // Added for AI analysis result
|
||||
resUrls?: string[]; // Added for image URLs
|
||||
}
|
||||
|
||||
export default function Materials() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [materials, setMaterials] = useState<MaterialItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [aiDialogOpen, setAiDialogOpen] = useState(false);
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<MaterialItem | null>(null);
|
||||
|
||||
// 拉取素材列表
|
||||
const fetchMaterials = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await get(`/v1/content/library/item-list?page=1&limit=100&libraryId=${id}${searchQuery ? `&keyword=${encodeURIComponent(searchQuery)}` : ''}`);
|
||||
if (res && res.code === 200 && Array.isArray(res.data?.list)) {
|
||||
setMaterials(res.data.list);
|
||||
} else {
|
||||
setMaterials([]);
|
||||
toast({ title: '获取失败', description: res?.msg || '获取素材列表失败' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
setMaterials([]);
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
// eslint-disable-next-line
|
||||
}, [id]);
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
const handleDelete = async (materialId: string) => {
|
||||
if (!window.confirm('确定要删除该素材吗?')) return;
|
||||
try {
|
||||
const res = await del(`/v1/content/library/material/delete?id=${materialId}`);
|
||||
if (res && res.code === 200) {
|
||||
toast({ title: '删除成功', description: '素材已删除' });
|
||||
fetchMaterials();
|
||||
} else {
|
||||
toast({ title: '删除失败', description: res?.msg || '删除素材失败' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewMaterial = () => {
|
||||
navigate(`/content/materials/new/${id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (materialId: string) => {
|
||||
navigate(`/content/materials/edit/${id}/${materialId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<UnifiedHeader title="素材列表" showBack onBack={() => navigate(-1)}
|
||||
|
||||
rightContent={
|
||||
<>
|
||||
<Button onClick={handleNewMaterial} variant="default">
|
||||
<Plus className="h-4 w-4 mr-1" />新建素材
|
||||
</Button>
|
||||
</>
|
||||
}/>
|
||||
<div className="flex items-center gap-2 m-4">
|
||||
<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 => { if (e.key === 'Enter') handleSearch(); }}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} variant="outline">搜索</Button>
|
||||
|
||||
</div>
|
||||
</>
|
||||
|
||||
}
|
||||
>
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
|
||||
<div className="p-4 space-y-4 max-w-2xl mx-auto">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">暂无素材</div>
|
||||
) : (
|
||||
materials.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5 mb-4 flex flex-col"
|
||||
style={{ boxShadow: '0 2px 8px 0 rgba(0,0,0,0.04)' }}
|
||||
>
|
||||
{/* 顶部头像+系统创建+ID */}
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-2xl mr-3">
|
||||
<UserCircle2 className="w-10 h-10 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-base text-gray-800 leading-tight">系统创建</span>
|
||||
<span className="mt-1">
|
||||
<span className="bg-blue-50 text-blue-700 text-xs font-bold rounded-full px-3 py-0.5 align-middle">ID: {item.id}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 标题 */}
|
||||
<div className="font-bold text-lg text-gray-900 mb-2 mt-1">{item.title ? `【${item.title}】` : (item.content.length > 20 ? `【${item.content.slice(0, 20)}...】` : `【${item.content}】`)}</div>
|
||||
{/* 内容 */}
|
||||
<div className="text-base text-gray-800 whitespace-pre-line mb-3" style={{ lineHeight: '1.8' }}>{item.content}</div>
|
||||
{/* 图片展示 */}
|
||||
{item.resUrls && item.resUrls.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{item.resUrls.map((url: string, idx: number) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={url}
|
||||
alt="素材图片"
|
||||
className="w-full max-w-full rounded-lg border"
|
||||
style={{ height: 'auto', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 标签 */}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{item.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Tag className="h-3 w-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 操作按钮区 */}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => handleEdit(item.id)}>
|
||||
<Edit className="h-4 w-4 mr-1" />编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setSelectedMaterial(item); setAiDialogOpen(true); }}>
|
||||
<BarChart className="h-4 w-4 mr-1" />AI分析
|
||||
</Button>
|
||||
<Dialog open={aiDialogOpen && selectedMaterial?.id === item.id} onOpenChange={setAiDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 分析结果</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<p>{selectedMaterial?.aiAnalysis || '正在分析中...'}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDelete(item.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from 'tdesign-mobile-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import { get, post } from '@/api/request';
|
||||
import UploadImage from '@/components/UploadImage';
|
||||
import UploadVideo from '@/components/UploadVideo';
|
||||
|
||||
export default function NewMaterial() {
|
||||
const navigate = useNavigate();
|
||||
const { id, materialId } = useParams(); // materialId 作为编辑标识
|
||||
const [content, setContent] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [contentType, setContentType] = useState<number>(1);
|
||||
const [desc, setDesc] = useState('');
|
||||
const [coverImage, setCoverImage] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [sendTime, setSendTime] = useState('');
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
// 优化图片上传逻辑,确保每次选择图片后立即上传并回显
|
||||
|
||||
|
||||
// 判断模式并拉取详情
|
||||
useEffect(() => {
|
||||
if (materialId) {
|
||||
setIsEdit(true);
|
||||
get(`/v1/content/library/get-item-detail?id=${materialId}`)
|
||||
.then(res => {
|
||||
if (res && res.code === 200 && res.data) {
|
||||
setContent(res.data.content || '');
|
||||
setComment(res.data.comment || '');
|
||||
setSendTime(res.data.sendTime || '');
|
||||
if (isFirstLoad && res.data.contentType) {
|
||||
setContentType(Number(res.data.contentType));
|
||||
setIsFirstLoad(false);
|
||||
}
|
||||
setDesc(res.data.desc || '');
|
||||
setCoverImage(res.data.coverImage || '');
|
||||
setUrl(res.data.url || '');
|
||||
setVideoUrl(res.data.videoUrl || '');
|
||||
setImages(res.data.resUrls || []); // 图片回显
|
||||
} else {
|
||||
toast({ title: '获取失败', description: res?.msg || '获取素材详情失败', variant: 'destructive' });
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接', variant: 'destructive' });
|
||||
});
|
||||
} else {
|
||||
setIsEdit(false);
|
||||
setContent('');
|
||||
setComment('');
|
||||
setSendTime('');
|
||||
setContentType(1);
|
||||
setImages([]);
|
||||
setIsFirstLoad(true);
|
||||
}
|
||||
}, [materialId]);
|
||||
|
||||
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!content) {
|
||||
toast({
|
||||
title: '错误',
|
||||
description: '请输入素材内容',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let res;
|
||||
if (isEdit) {
|
||||
// 编辑模式,调用新接口,所有字段取表单值
|
||||
const payload = {
|
||||
id: materialId,
|
||||
contentType,
|
||||
content,
|
||||
comment,
|
||||
sendTime,
|
||||
resUrls: images,
|
||||
};
|
||||
res = await post('/v1/content/library/update-item', payload);
|
||||
} else {
|
||||
// 新建模式,所有字段取表单值
|
||||
const payload = {
|
||||
libraryId: id,
|
||||
type: contentType,
|
||||
content,
|
||||
comment,
|
||||
sendTime,
|
||||
resUrls: images,
|
||||
};
|
||||
res = await post('/v1/content/library/create-item', payload);
|
||||
}
|
||||
if (res && res.code === 200) {
|
||||
toast({ title: '成功', description: isEdit ? '素材已更新' : '新素材已创建' });
|
||||
navigate(-1);
|
||||
} else {
|
||||
toast({ title: isEdit ? '保存失败' : '创建失败', description: res?.msg || (isEdit ? '保存素材失败' : '创建新素材失败'), variant: 'destructive' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接', variant: 'destructive' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 移除未用的 handleUploadImage 及 uploadImage 相关代码
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<UnifiedHeader title={isEdit ? '编辑素材' : '新建素材'} showBack onBack={() => navigate(-1)} />}
|
||||
footer={
|
||||
<div className='m-2'>
|
||||
{/* 2. 按钮onClick绑定handleSave */}
|
||||
<Button theme="primary" block onClick={handleSave} disabled={isSubmitting}>
|
||||
{isSubmitting ? (isEdit ? '保存中...' : '创建中...') : (isEdit ? '保存修改' : '保存素材')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<div className="p-4 max-w-lg mx-auto">
|
||||
<Card className="p-8 rounded-3xl shadow-xl bg-white">
|
||||
<form className="space-y-8">
|
||||
{/* 基础信息分组 */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">基础信息</div>
|
||||
<Label className="font-bold flex items-center mb-2">发布时间</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
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="请选择发布时间"
|
||||
/>
|
||||
<Label className="font-bold flex items-center mb-2 mt-4"><span className="text-red-500 mr-1">*</span>类型</Label>
|
||||
<select
|
||||
value={contentType}
|
||||
onChange={e => setContentType(Number(e.target.value))}
|
||||
className="w-full h-12 border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base bg-white appearance-none"
|
||||
>
|
||||
<option value="" disabled>请选择类型</option>
|
||||
<option value={1}>图片</option>
|
||||
<option value={2}>链接</option>
|
||||
<option value={3}>视频</option>
|
||||
<option value={4}>文本</option>
|
||||
<option value={5}>小程序</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* 内容信息分组 */}
|
||||
<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
|
||||
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={8}
|
||||
/>
|
||||
{(contentType === 2 || contentType === 6) && (
|
||||
<>
|
||||
<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"
|
||||
/>
|
||||
<Label className="font-bold mb-2 mt-4">封面图</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<UploadImage
|
||||
value={images}
|
||||
onChange={urls => {
|
||||
setCoverImage(urls[0]);
|
||||
}}
|
||||
max={1}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor="url" className="font-bold flex items-center mb-2 mt-4"><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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{contentType === 3 && (
|
||||
<>
|
||||
<Label className="font-bold mb-2">上传视频</Label>
|
||||
<div className="pt-4">
|
||||
<UploadVideo
|
||||
value={videoUrl}
|
||||
onChange={setVideoUrl}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 素材上传分组(仅图片类型和小程序类型) */}
|
||||
{([1,5].includes(contentType)) && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">素材上传(最多上传9张)</div>
|
||||
{contentType === 1 && (
|
||||
<div className="mb-6">
|
||||
<UploadImage
|
||||
value={images}
|
||||
onChange={urls => {
|
||||
setImages(urls);
|
||||
}}
|
||||
max={9}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{contentType === 5 && (
|
||||
<div className="space-y-6">
|
||||
<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" />
|
||||
<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" />
|
||||
<Label className="font-bold mb-2">小程序封面图</Label>
|
||||
<UploadImage
|
||||
value={images}
|
||||
onChange={urls => {
|
||||
setImages(urls);
|
||||
}}
|
||||
max={9}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 评论/备注分组 */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">评论/备注</div>
|
||||
<Textarea
|
||||
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>
|
||||
|
||||
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,869 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import BackButton from '@/components/BackButton';
|
||||
import { useSimpleBack } from '@/hooks/useBackNavigation';
|
||||
import { Smartphone, Battery, Wifi, MessageCircle, Users, Settings, History, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { devicesApi, fetchDeviceDetail, fetchDeviceRelatedAccounts, fetchDeviceHandleLogs, updateDeviceTaskConfig } from '@/api/devices';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface HandleLog {
|
||||
id: string | number;
|
||||
content: string;
|
||||
username: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { goBack } = useSimpleBack('/devices');
|
||||
const { toast } = useToast();
|
||||
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 [accountPage, setAccountPage] = useState(1);
|
||||
const [hasMoreAccounts, setHasMoreAccounts] = useState(true);
|
||||
const accountsPerPage = 10;
|
||||
const accountsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 获取设备详情
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchDevice = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchDeviceDetail(id);
|
||||
|
||||
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);
|
||||
|
||||
// 获取设备任务配置
|
||||
await fetchTaskConfig();
|
||||
|
||||
// 如果当前激活标签是"accounts",则立即加载关联微信账号
|
||||
if (activeTab === "accounts") {
|
||||
fetchRelatedAccounts();
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "获取设备信息失败",
|
||||
description: response.msg || "未知错误",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备信息失败:", error);
|
||||
toast({
|
||||
title: "获取设备信息失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDevice();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
// 获取设备关联微信账号
|
||||
const fetchRelatedAccounts = useCallback(async (page = 1) => {
|
||||
if (!id || accountsLoading) return;
|
||||
|
||||
try {
|
||||
setAccountsLoading(true);
|
||||
const response = await fetchDeviceRelatedAccounts(id);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const accounts = response.data.accounts || [];
|
||||
|
||||
if (page === 1) {
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
wechatAccounts: accounts
|
||||
} : null);
|
||||
} else {
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
wechatAccounts: [...prev.wechatAccounts, ...accounts]
|
||||
} : null);
|
||||
}
|
||||
|
||||
setHasMoreAccounts(accounts.length === accountsPerPage);
|
||||
setAccountPage(page);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取关联账号失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取关联账号失败:", error);
|
||||
toast({
|
||||
title: "获取关联账号失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, [id, accountsLoading, accountsPerPage, toast]);
|
||||
|
||||
// 获取操作记录
|
||||
const fetchHandleLogs = useCallback(async () => {
|
||||
if (!id || logsLoading) return;
|
||||
|
||||
try {
|
||||
setLogsLoading(true);
|
||||
const response = await fetchDeviceHandleLogs(id, 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);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取操作记录失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取操作记录失败:", error);
|
||||
toast({
|
||||
title: "获取操作记录失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [id, logsLoading, logPage, logsPerPage, toast]);
|
||||
|
||||
// 加载更多操作记录
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (logsLoading || !hasMoreLogs) return;
|
||||
setLogPage(prev => prev + 1);
|
||||
fetchHandleLogs();
|
||||
}, [logsLoading, hasMoreLogs, fetchHandleLogs]);
|
||||
|
||||
// 无限滚动加载操作记录
|
||||
useEffect(() => {
|
||||
if (!hasMoreLogs || logsLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMoreLogs && !logsLoading) {
|
||||
loadMoreLogs();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (logsEndRef.current) {
|
||||
observer.observe(logsEndRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hasMoreLogs, logsLoading, loadMoreLogs]);
|
||||
|
||||
// 获取任务配置
|
||||
const fetchTaskConfig = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await devicesApi.getTaskConfig(id);
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const config = response.data;
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
autoAddFriend: Boolean(config.autoAddFriend),
|
||||
autoReply: Boolean(config.autoReply),
|
||||
momentsSync: Boolean(config.momentsSync),
|
||||
aiChat: Boolean(config.aiChat)
|
||||
}
|
||||
} : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取任务配置失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 标签页切换处理
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
|
||||
setTimeout(() => {
|
||||
if (value === "accounts" && device && (!device.wechatAccounts || device.wechatAccounts.length === 0)) {
|
||||
fetchRelatedAccounts(1);
|
||||
} else if (value === "history" && handleLogs.length === 0) {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 功能开关处理 - 只更新开关状态,不重新加载页面
|
||||
const handleFeatureChange = async (feature: keyof Device['features'], checked: boolean) => {
|
||||
if (!id) return;
|
||||
|
||||
// 立即更新UI状态,提供即时反馈
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
setSavingFeatures(prev => ({ ...prev, [feature]: true }));
|
||||
|
||||
try {
|
||||
const response = await updateDeviceTaskConfig({
|
||||
deviceId: id,
|
||||
[feature]: checked
|
||||
});
|
||||
|
||||
if (response && response.code === 200) {
|
||||
// 请求成功,显示成功提示
|
||||
toast({
|
||||
title: "设置成功",
|
||||
description: `${getFeatureName(feature)}已${checked ? '启用' : '禁用'}`,
|
||||
});
|
||||
} else {
|
||||
// 请求失败,回滚UI状态
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: !checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
toast({
|
||||
title: "设置失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("设置功能失败:", error);
|
||||
|
||||
// 网络错误,回滚UI状态
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: !checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
toast({
|
||||
title: "设置失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSavingFeatures(prev => ({ ...prev, [feature]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 获取功能名称
|
||||
const getFeatureName = (feature: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
autoAddFriend: "自动加好友",
|
||||
autoReply: "自动回复",
|
||||
momentsSync: "朋友圈同步",
|
||||
aiChat: "AI会话"
|
||||
};
|
||||
return names[feature] || feature;
|
||||
};
|
||||
|
||||
// 加载更多账号
|
||||
const loadMoreAccounts = useCallback(() => {
|
||||
if (accountsLoading || !hasMoreAccounts) return;
|
||||
fetchRelatedAccounts(accountPage + 1);
|
||||
}, [accountsLoading, hasMoreAccounts, accountPage, fetchRelatedAccounts]);
|
||||
|
||||
// 无限滚动加载账号
|
||||
useEffect(() => {
|
||||
if (!hasMoreAccounts || accountsLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMoreAccounts && !accountsLoading) {
|
||||
loadMoreAccounts();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (accountsEndRef.current) {
|
||||
observer.observe(accountsEndRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hasMoreAccounts, accountsLoading, loadMoreAccounts]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载设备信息中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4 p-6 bg-white rounded-xl 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为 "{id}" 的设备信息,请检查设备是否存在。
|
||||
</div>
|
||||
<BackButton
|
||||
variant="button"
|
||||
text="返回上一页"
|
||||
onBack={goBack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="pb-20">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 设备基本信息卡片 */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="flex items-center gap-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-semibold truncate">{device.name}</h2>
|
||||
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
|
||||
device.status === "online"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span className="mr-1">IMEI:</span>
|
||||
{device.imei}
|
||||
</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 gap-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 gap-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>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "info"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("info")}
|
||||
>
|
||||
基本信息
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "accounts"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("accounts")}
|
||||
>
|
||||
关联账号
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "history"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("history")}
|
||||
>
|
||||
操作记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 基本信息标签页 */}
|
||||
{activeTab === "info" && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 功能配置 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">自动加好友</div>
|
||||
<div className="text-xs text-gray-500">自动通过好友验证</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.autoAddFriend && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.autoAddFriend)}
|
||||
onChange={(e) => handleFeatureChange('autoAddFriend', e.target.checked)}
|
||||
disabled={savingFeatures.autoAddFriend}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">自动回复</div>
|
||||
<div className="text-xs text-gray-500">自动回复好友消息</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.autoReply && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.autoReply)}
|
||||
onChange={(e) => handleFeatureChange('autoReply', e.target.checked)}
|
||||
disabled={savingFeatures.autoReply}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">朋友圈同步</div>
|
||||
<div className="text-xs text-gray-500">自动同步朋友圈内容</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.momentsSync && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.momentsSync)}
|
||||
onChange={(e) => handleFeatureChange('momentsSync', e.target.checked)}
|
||||
disabled={savingFeatures.momentsSync}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">AI会话</div>
|
||||
<div className="text-xs text-gray-500">启用AI智能对话</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.aiChat && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.aiChat)}
|
||||
onChange={(e) => handleFeatureChange('aiChat', e.target.checked)}
|
||||
disabled={savingFeatures.aiChat}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-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>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 关联账号标签页 */}
|
||||
{activeTab === "accounts" && (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-md font-medium">微信账号列表</h3>
|
||||
<button
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setAccountPage(1);
|
||||
setHasMoreAccounts(true);
|
||||
fetchRelatedAccounts(1);
|
||||
}}
|
||||
disabled={accountsLoading}
|
||||
>
|
||||
{accountsLoading ? (
|
||||
<React.Fragment key="loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
刷新中
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="refresh">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</React.Fragment>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[120px] max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||
{accountsLoading && !device?.wechatAccounts?.length ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-2" />
|
||||
<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 gap-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>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
account.wechatAlive === 1
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{account.wechatAliveText}
|
||||
</span>
|
||||
</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>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
account.status === 1
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{account.statusText}
|
||||
</span>
|
||||
</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 gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
<span className="text-sm text-gray-500">加载更多...</span>
|
||||
</div>
|
||||
) : hasMoreAccounts ? (
|
||||
<button
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={loadMoreAccounts}
|
||||
>
|
||||
加载更多
|
||||
</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
|
||||
className="mt-2 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 mx-auto"
|
||||
onClick={() => fetchRelatedAccounts(1)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作记录标签页 */}
|
||||
{activeTab === "history" && (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-md font-medium">操作记录</h3>
|
||||
<button
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}}
|
||||
disabled={logsLoading}
|
||||
>
|
||||
{logsLoading ? (
|
||||
<React.Fragment key="logs-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="logs-refresh">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</React.Fragment>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-[calc(min(80vh, 500px))] overflow-y-auto">
|
||||
{logsLoading && handleLogs.length === 0 ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-2" />
|
||||
<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 gap-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 gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
<span className="text-sm text-gray-500">加载更多...</span>
|
||||
</div>
|
||||
) : hasMoreLogs ? (
|
||||
<button
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={loadMoreLogs}
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">- 已加载全部记录 -</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>暂无操作记录</p>
|
||||
<button
|
||||
className="mt-2 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 mx-auto"
|
||||
onClick={() => {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,719 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Search, RefreshCw, QrCode, Loader2, AlertTriangle, X } from 'lucide-react';
|
||||
import { devicesApi } from '@/api';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import Layout from '@/components/Layout';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
// 设备接口
|
||||
interface Device {
|
||||
id: number;
|
||||
imei: string;
|
||||
memo: string;
|
||||
wechatId: string;
|
||||
totalFriend: number;
|
||||
alive: number;
|
||||
status: "online" | "offline";
|
||||
}
|
||||
|
||||
export default function Devices() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
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 [, 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 devicesApi.getList(page, devicesPerPage, searchQuery);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
const serverDevices = response.data.list.map((device: any) => ({
|
||||
...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: any) => 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, isLoading, toast]);
|
||||
|
||||
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 devicesApi.getQRCode(accountId);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
setQrCodeImage(response.data.qrCode);
|
||||
// 开始轮询检测设备添加结果
|
||||
setTimeout(() => {
|
||||
startPolling();
|
||||
}, 5000);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取二维码失败:", error);
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingQRCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
setPollingStatus({
|
||||
isPolling: true,
|
||||
message: "正在检测添加结果...",
|
||||
messageType: 'default',
|
||||
showAnimation: true
|
||||
});
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await devicesApi.getList(1, 1);
|
||||
if (response.code === 200 && response.data) {
|
||||
const currentCount = response.data.total;
|
||||
if (currentCount > totalCount) {
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: "设备添加成功!",
|
||||
messageType: 'success',
|
||||
showAnimation: false
|
||||
});
|
||||
setIsAddDeviceOpen(false);
|
||||
loadDevices(1, true);
|
||||
if (pollingTimerRef.current) {
|
||||
clearTimeout(pollingTimerRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("轮询检测失败:", error);
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
pollingTimerRef.current = setTimeout(poll, 2000);
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const handleOpenAddDeviceModal = () => {
|
||||
setIsAddDeviceOpen(true);
|
||||
setActiveTab("scan");
|
||||
setQrCodeImage("");
|
||||
setDeviceImei("");
|
||||
setDeviceName("");
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
|
||||
// 自动获取二维码
|
||||
setTimeout(() => {
|
||||
fetchDeviceQRCode();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleCloseAddDeviceModal = () => {
|
||||
setIsAddDeviceOpen(false);
|
||||
if (pollingTimerRef.current) {
|
||||
clearTimeout(pollingTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDeviceByImei = async () => {
|
||||
if (!deviceImei.trim() || !deviceName.trim()) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: "设备名称和IMEI不能为空",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingImei(true);
|
||||
const response = await devicesApi.addByImei(deviceImei, deviceName);
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: "设备已成功添加",
|
||||
});
|
||||
setIsAddDeviceOpen(false);
|
||||
loadDevices(1, true);
|
||||
} else {
|
||||
toast({
|
||||
title: "添加失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error);
|
||||
toast({
|
||||
title: '添加设备失败,请稍后重试',
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingImei(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setCurrentPage(1);
|
||||
pageRef.current = 1;
|
||||
loadDevices(1, true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (!selectedDeviceId) {
|
||||
toast({
|
||||
title: "请选择要删除的设备",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDeviceToDelete(selectedDeviceId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deviceToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await devicesApi.delete(deviceToDelete);
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "设备已成功删除",
|
||||
});
|
||||
setSelectedDeviceId(null);
|
||||
loadDevices(1, true);
|
||||
} else {
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除设备失败:', error);
|
||||
toast({
|
||||
title: '删除设备失败,请稍后重试',
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeviceToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeviceToDelete(null);
|
||||
};
|
||||
|
||||
const handleDeviceClick = (deviceId: number, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
navigate(`/devices/${deviceId}`);
|
||||
};
|
||||
|
||||
const handleAddDevice = async () => {
|
||||
if (activeTab === "manual") {
|
||||
await handleAddDeviceByImei();
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤设备列表
|
||||
const filteredDevices = devices.filter(device => {
|
||||
if (statusFilter === "online") return device.status === "online";
|
||||
if (statusFilter === "offline") return device.status === "offline";
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="设备管理"
|
||||
defaultBackPath="/"
|
||||
rightContent={
|
||||
<button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
onClick={handleOpenAddDeviceModal}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="text-sm text-gray-500 mb-1">总设备数</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.totalDevices}</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="text-sm text-gray-500 mb-1">在线设备</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.onlineDevices}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索设备IMEI/备注"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-2.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={!selectedDeviceId}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备列表 */}
|
||||
<div className="space-y-3">
|
||||
{filteredDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition-all cursor-pointer"
|
||||
onClick={(e) => handleDeviceClick(device.id, e)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeviceId === device.id}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDeviceId(device.id);
|
||||
} else {
|
||||
setSelectedDeviceId(null);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-gray-900 truncate">{device.memo || "未命名设备"}</div>
|
||||
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
|
||||
device.status === "online"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId || "未绑定或微信离线"}</div>
|
||||
<div>好友数: {device.totalFriend}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={observerTarget} className="py-4 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>
|
||||
</div>
|
||||
{/* 添加设备弹窗 */}
|
||||
{isAddDeviceOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">添加设备</h2>
|
||||
<button
|
||||
onClick={handleCloseAddDeviceModal}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "scan"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveTab("scan");
|
||||
// 切换到扫码添加时自动获取二维码
|
||||
setTimeout(() => {
|
||||
fetchDeviceQRCode();
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
扫码添加
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "manual"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => setActiveTab("manual")}
|
||||
>
|
||||
手动添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "scan" && (
|
||||
<div className="py-3">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{/* 状态提示 */}
|
||||
<div className="text-center">
|
||||
{pollingStatus.isPolling || pollingStatus.showAnimation ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-gray-700">正在检测添加结果</span>
|
||||
<div className="flex justify-center 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>
|
||||
) : (
|
||||
<span className="text-sm text-gray-600">5秒后将开始检测添加结果</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 二维码区域 */}
|
||||
<div className="bg-gray-50 p-3 rounded-xl w-full max-w-[220px] min-h-[220px] flex flex-col items-center justify-center">
|
||||
{isLoadingQRCode ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-500">正在获取二维码...</p>
|
||||
</div>
|
||||
) : qrCodeImage ? (
|
||||
<div id="qrcode-container" className="flex flex-col items-center space-y-2">
|
||||
<div className="relative w-44 h-44 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 rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 mb-1" />
|
||||
<p className="text-xs">未能加载二维码,请点击刷新按钮重试</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-center text-gray-600">
|
||||
请使用手机扫描此二维码添加设备
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500">
|
||||
<QrCode className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">点击下方按钮获取二维码</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchDeviceQRCode}
|
||||
disabled={isLoadingQRCode}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2.5 rounded-xl disabled:bg-gray-300 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoadingQRCode ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
获取中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新二维码
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "manual" && (
|
||||
<div className="py-3 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">设备名称</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入设备名称"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
为设备添加一个便于识别的名称
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">设备IMEI</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入设备IMEI"
|
||||
value={deviceImei}
|
||||
onChange={(e) => setDeviceImei(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
请输入设备IMEI码,可在设备信息中查看
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setIsAddDeviceOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl disabled:bg-gray-300 transition-colors"
|
||||
onClick={handleAddDevice}
|
||||
disabled={!deviceImei.trim() || !deviceName.trim() || isSubmittingImei}
|
||||
>
|
||||
{isSubmittingImei ? "添加中..." : "添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{isDeleteDialogOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full p-6">
|
||||
<div className="text-center mb-6">
|
||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">确认删除</h3>
|
||||
<p className="text-gray-600">
|
||||
设备删除后,本设备配置的计划任务操作也将失效。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
onClick={handleCancelDelete}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-4 py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl transition-colors"
|
||||
onClick={handleConfirmDelete}
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Phone } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { authApi } from '@/api';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import AppleIcon from '@/components/icons/AppleIcon';
|
||||
|
||||
// 定义登录表单类型
|
||||
interface LoginForm {
|
||||
phone: string;
|
||||
password: string;
|
||||
verificationCode: string;
|
||||
agreeToTerms: boolean;
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'password' | 'verification'>('password');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [form, setForm] = useState<LoginForm>({
|
||||
phone: '',
|
||||
password: '',
|
||||
verificationCode: '',
|
||||
agreeToTerms: false,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const { login } = useAuth();
|
||||
|
||||
// 检查URL是否为登录页面
|
||||
const isLoginPage = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
return urlObj.pathname === '/login' || urlObj.pathname.endsWith('/login');
|
||||
} catch {
|
||||
// 如果URL格式不正确,返回false
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 倒计时效果
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, agreeToTerms: e.target.checked }));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '请输入手机号',
|
||||
description: '手机号不能为空',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
if (!phoneRegex.test(form.phone)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '手机号格式错误',
|
||||
description: '请输入正确的11位手机号',
|
||||
});
|
||||
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 authApi.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));
|
||||
|
||||
// 调用认证上下文的登录方法
|
||||
login(response.data.token, response.data.member);
|
||||
|
||||
// 显示成功提示
|
||||
toast({
|
||||
title: '登录成功',
|
||||
description: '欢迎回来!',
|
||||
});
|
||||
|
||||
// 跳转到首页或重定向URL
|
||||
const returnUrl = searchParams.get('returnUrl');
|
||||
if (returnUrl) {
|
||||
const decodedUrl = decodeURIComponent(returnUrl);
|
||||
// 检查重定向URL是否为登录页面,避免无限重定向
|
||||
if (isLoginPage(decodedUrl)) {
|
||||
navigate('/');
|
||||
} else {
|
||||
window.location.href = decodedUrl;
|
||||
}
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.msg || '登录失败');
|
||||
}
|
||||
} else {
|
||||
// 验证码登录
|
||||
const response = await authApi.loginWithCode(form.phone, form.verificationCode);
|
||||
|
||||
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));
|
||||
|
||||
// 调用认证上下文的登录方法
|
||||
login(response.data.token, response.data.member);
|
||||
|
||||
// 显示成功提示
|
||||
toast({
|
||||
title: '登录成功',
|
||||
description: '欢迎回来!',
|
||||
});
|
||||
|
||||
// 跳转到首页或重定向URL
|
||||
const returnUrl = searchParams.get('returnUrl');
|
||||
if (returnUrl) {
|
||||
const decodedUrl = decodeURIComponent(returnUrl);
|
||||
// 检查重定向URL是否为登录页面,避免无限重定向
|
||||
if (isLoginPage(decodedUrl)) {
|
||||
navigate('/');
|
||||
} else {
|
||||
window.location.href = decodedUrl;
|
||||
}
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.msg || '登录失败');
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
if (!phoneRegex.test(form.phone)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '手机号格式错误',
|
||||
description: '请输入正确的11位手机号',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await authApi.sendVerificationCode(form.phone);
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: '验证码已发送',
|
||||
description: '请查收短信验证码',
|
||||
});
|
||||
setCountdown(60); // 开始60秒倒计时
|
||||
} else {
|
||||
throw new Error(response.msg || '发送失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '发送失败',
|
||||
description: error instanceof Error ? error.message : '请稍后重试',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWechatLogin = () => {
|
||||
// 微信登录逻辑
|
||||
toast({
|
||||
title: '功能开发中',
|
||||
description: '微信登录功能正在开发中,请使用其他方式登录',
|
||||
});
|
||||
};
|
||||
|
||||
const handleAppleLogin = () => {
|
||||
// Apple登录逻辑
|
||||
toast({
|
||||
title: '功能开发中',
|
||||
description: 'Apple登录功能正在开发中,请使用其他方式登录',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center px-4 py-8">
|
||||
<div className="max-w-md w-full">
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">欢迎登录</h1>
|
||||
<p className="text-gray-600 text-sm">你所在地区仅支持 手机号 / 微信 / Apple 登录</p>
|
||||
</div>
|
||||
|
||||
{/* 标签页切换 */}
|
||||
<div className="flex border-b border-gray-200 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={`flex-1 py-3 text-center border-b-2 transition-colors font-medium ${
|
||||
activeTab === 'password'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('verification')}
|
||||
className={`flex-1 py-3 text-center border-b-2 transition-colors font-medium ${
|
||||
activeTab === 'verification'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
{/* 手机号输入 */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
手机号
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入手机号"
|
||||
className="w-full pl-16 pr-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1 text-sm">
|
||||
<Phone className="h-4 w-4" />
|
||||
+86
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
{activeTab === 'password' && (
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入密码"
|
||||
className="w-full pl-4 pr-12 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 验证码输入 */}
|
||||
{activeTab === 'verification' && (
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
验证码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="verificationCode"
|
||||
value={form.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入验证码"
|
||||
className="w-full pl-4 pr-32 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendVerificationCode}
|
||||
disabled={isLoading || countdown > 0}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 px-4 h-10 rounded-md text-sm font-medium transition-colors ${
|
||||
countdown > 0
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-50 text-blue-500 hover:bg-blue-100'
|
||||
}`}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户协议 */}
|
||||
<div className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
checked={form.agreeToTerms}
|
||||
onChange={handleCheckboxChange}
|
||||
disabled={isLoading}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm text-gray-600 leading-relaxed cursor-pointer"
|
||||
>
|
||||
我已阅读并同意
|
||||
<button type="button" className="text-blue-500 hover:text-blue-600 mx-1">《存客宝用户协议》</button>
|
||||
和
|
||||
<button type="button" className="text-blue-500 hover:text-blue-600 mx-1">《隐私政策》</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
登录中...
|
||||
</div>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-white text-gray-500">其他登录方式</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第三方登录 */}
|
||||
<div className="flex justify-center space-x-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWechatLogin}
|
||||
className="flex flex-col items-center space-y-2 p-4 text-gray-500 hover:text-gray-700 transition-colors rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<WeChatIcon className="h-8 w-8" />
|
||||
<span className="text-xs">微信</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAppleLogin}
|
||||
className="flex flex-col items-center space-y-2 p-4 text-gray-500 hover:text-gray-700 transition-colors rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<AppleIcon className="h-8 w-8" />
|
||||
<span className="text-xs">Apple</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Orders() {
|
||||
return <div>订单页</div>;
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Users, TrendingUp, Calendar, Settings, Play, Pause, Edit } from 'lucide-react';
|
||||
|
||||
interface PlanData {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
createdAt: string;
|
||||
totalCustomers: number;
|
||||
todayCustomers: number;
|
||||
growth: string;
|
||||
description?: string;
|
||||
scenario: string;
|
||||
}
|
||||
|
||||
export default function PlanDetail() {
|
||||
const { planId } = useParams<{ planId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [plan, setPlan] = useState<PlanData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlanData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 模拟API调用
|
||||
const mockPlan: PlanData = {
|
||||
id: planId || '',
|
||||
name: '春季营销计划',
|
||||
status: 'active',
|
||||
createdAt: '2024-03-15',
|
||||
totalCustomers: 456,
|
||||
todayCustomers: 23,
|
||||
growth: '+8.2%',
|
||||
description: '针对春季市场的营销推广计划,通过多种渠道获取潜在客户',
|
||||
scenario: 'douyin',
|
||||
};
|
||||
|
||||
setPlan(mockPlan);
|
||||
} catch (error) {
|
||||
setError('获取计划数据失败');
|
||||
console.error('获取计划数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlanData();
|
||||
}, [planId]);
|
||||
|
||||
const handleStatusChange = async (newStatus: 'active' | 'paused') => {
|
||||
if (!plan) return;
|
||||
|
||||
try {
|
||||
// 这里可以调用实际的API
|
||||
// await fetch(`/api/plans/${plan.id}/status`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ status: newStatus }),
|
||||
// });
|
||||
|
||||
setPlan({ ...plan, status: newStatus });
|
||||
} catch (error) {
|
||||
console.error('更新计划状态失败:', error);
|
||||
alert('更新失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !plan) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<div className="text-red-500 text-center py-8">{error || '计划不存在'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<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">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="mr-3 p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold">{plan.name}</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => navigate(`/plans/${plan.id}/edit`)}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/plans/${plan.id}/settings`)}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
{/* 计划描述 */}
|
||||
{plan.description && (
|
||||
<div className="bg-white rounded-lg p-4 mb-6">
|
||||
<p className="text-gray-600 text-sm">{plan.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据统计 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">总获客数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{plan.totalCustomers}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-green-500 text-sm">
|
||||
<TrendingUp className="h-4 w-4 mr-1" />
|
||||
<span>{plan.growth}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">今日获客</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{plan.todayCustomers}</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-gray-500 text-sm">
|
||||
<span>创建于 {plan.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态控制 */}
|
||||
<div className="bg-white rounded-lg p-4 mb-6">
|
||||
<h3 className="text-lg font-medium mb-4">计划状态</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${
|
||||
plan.status === 'active'
|
||||
? 'text-green-600 bg-green-50'
|
||||
: plan.status === 'paused'
|
||||
? 'text-yellow-600 bg-yellow-50'
|
||||
: 'text-gray-600 bg-gray-50'
|
||||
}`}>
|
||||
{plan.status === 'active' ? '进行中' : plan.status === 'paused' ? '已暂停' : '已完成'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{plan.status === 'active' ? (
|
||||
<button
|
||||
onClick={() => handleStatusChange('paused')}
|
||||
className="flex items-center px-3 py-2 bg-yellow-500 text-white rounded-md hover:bg-yellow-600 transition-colors text-sm"
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-1" />
|
||||
暂停
|
||||
</button>
|
||||
) : plan.status === 'paused' ? (
|
||||
<button
|
||||
onClick={() => handleStatusChange('active')}
|
||||
className="flex items-center px-3 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors text-sm"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
启动
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能区域 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/customers`)}>
|
||||
<div className="flex items-center">
|
||||
<Users className="h-8 w-8 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">客户管理</h3>
|
||||
<p className="text-sm text-gray-500">查看和管理获客客户</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/analytics`)}>
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="h-8 w-8 text-green-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">数据分析</h3>
|
||||
<p className="text-sm text-gray-500">查看获客数据统计</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/content`)}>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-8 w-8 text-purple-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">内容管理</h3>
|
||||
<p className="text-sm text-gray-500">管理营销内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/settings`)}>
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-8 w-8 text-gray-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">计划设置</h3>
|
||||
<p className="text-sm text-gray-500">配置计划参数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Calendar } from 'lucide-react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
createdAt: string;
|
||||
totalCustomers: number;
|
||||
todayCustomers: number;
|
||||
growth: string;
|
||||
scenario: string;
|
||||
}
|
||||
|
||||
export default function Plans() {
|
||||
const navigate = useNavigate();
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 模拟API调用
|
||||
const mockPlans: Plan[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '春季营销计划',
|
||||
status: 'active',
|
||||
createdAt: '2024-03-15',
|
||||
totalCustomers: 456,
|
||||
todayCustomers: 23,
|
||||
growth: '+8.2%',
|
||||
scenario: 'douyin',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '新品推广计划',
|
||||
status: 'active',
|
||||
createdAt: '2024-03-10',
|
||||
totalCustomers: 234,
|
||||
todayCustomers: 15,
|
||||
growth: '+5.1%',
|
||||
scenario: 'xiaohongshu',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '节日活动计划',
|
||||
status: 'paused',
|
||||
createdAt: '2024-02-28',
|
||||
totalCustomers: 789,
|
||||
todayCustomers: 0,
|
||||
growth: '+0%',
|
||||
scenario: 'gongzhonghao',
|
||||
},
|
||||
];
|
||||
|
||||
setPlans(mockPlans);
|
||||
} catch (error) {
|
||||
setError('获取计划数据失败');
|
||||
console.error('获取计划数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'paused':
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
case 'completed':
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return '进行中';
|
||||
case 'paused':
|
||||
return '已暂停';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<PageHeader
|
||||
title="获客计划"
|
||||
showBack={false}
|
||||
/>
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<PageHeader
|
||||
title="获客计划"
|
||||
showBack={false}
|
||||
/>
|
||||
<div className="text-red-500 text-center py-8">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<PageHeader
|
||||
title="获客计划"
|
||||
showBack={false}
|
||||
rightContent={
|
||||
<button
|
||||
onClick={() => navigate('/scenarios/new')}
|
||||
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建计划
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{plans.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>暂无获客计划</p>
|
||||
<button
|
||||
onClick={() => navigate('/scenarios/new')}
|
||||
className="mt-2 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
立即创建
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className="bg-white rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate(`/plans/${plan.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
<h3 className="font-medium text-gray-900">{plan.name}</h3>
|
||||
<span className={`ml-2 px-2 py-1 text-xs rounded-full ${getStatusColor(plan.status)}`}>
|
||||
{getStatusText(plan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
<span>创建于 {plan.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="text-gray-500">总获客:</span>
|
||||
<span className="font-medium ml-1">{plan.totalCustomers}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm mt-1">
|
||||
<span className="text-gray-500">今日:</span>
|
||||
<span className="font-medium ml-1">{plan.todayCustomers}</span>
|
||||
<span className="text-green-500 ml-1">({plan.growth})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Settings, Bell, LogOut, Smartphone, MessageCircle, Database, FolderOpen } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<any>(null);
|
||||
const [stats, setStats] = useState({
|
||||
devices: 12,
|
||||
wechat: 25,
|
||||
traffic: 8,
|
||||
content: 156,
|
||||
});
|
||||
|
||||
// 从localStorage获取用户信息
|
||||
useEffect(() => {
|
||||
const userInfoStr = localStorage.getItem('userInfo');
|
||||
if (userInfoStr) {
|
||||
setUserInfo(JSON.parse(userInfoStr));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 用户信息
|
||||
const currentUserInfo = {
|
||||
name: userInfo?.username || user?.username || "卡若",
|
||||
email: userInfo?.email || "zhangsan@example.com",
|
||||
role: "管理员",
|
||||
joinDate: "2023-01-15",
|
||||
lastLogin: "2024-01-20 14:30",
|
||||
};
|
||||
|
||||
// 功能模块数据
|
||||
const functionModules = [
|
||||
{
|
||||
id: "devices",
|
||||
title: "设备管理",
|
||||
description: "管理您的设备和微信账号",
|
||||
icon: <Smartphone className="h-5 w-5 text-blue-500" />,
|
||||
count: stats.devices,
|
||||
path: "/devices",
|
||||
bgColor: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
id: "wechat",
|
||||
title: "微信号管理",
|
||||
description: "管理微信账号和好友",
|
||||
icon: <MessageCircle className="h-5 w-5 text-green-500" />,
|
||||
count: stats.wechat,
|
||||
path: "/wechat-accounts",
|
||||
bgColor: "bg-green-50",
|
||||
},
|
||||
{
|
||||
id: "traffic",
|
||||
title: "流量池",
|
||||
description: "管理用户流量池和分组",
|
||||
icon: <Database className="h-5 w-5 text-purple-500" />,
|
||||
count: stats.traffic,
|
||||
path: "/traffic-pool",
|
||||
bgColor: "bg-purple-50",
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
title: "内容库",
|
||||
description: "管理营销内容和素材",
|
||||
icon: <FolderOpen className="h-5 w-5 text-orange-500" />,
|
||||
count: stats.content,
|
||||
path: "/content",
|
||||
bgColor: "bg-orange-50",
|
||||
},
|
||||
];
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
// 这里可以调用实际的API
|
||||
// const [deviceStats, wechatStats, trafficStats, contentStats] = await Promise.allSettled([
|
||||
// getDeviceStats(),
|
||||
// getWechatStats(),
|
||||
// getTrafficStats(),
|
||||
// getContentStats(),
|
||||
// ]);
|
||||
|
||||
// 暂时使用模拟数据
|
||||
setStats({
|
||||
devices: 12,
|
||||
wechat: 25,
|
||||
traffic: 8,
|
||||
content: 156,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("加载统计数据失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
// 清除本地存储的用户信息
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('token_expired');
|
||||
localStorage.removeItem('s2_accountId');
|
||||
localStorage.removeItem('userInfo');
|
||||
setShowLogoutDialog(false);
|
||||
logout();
|
||||
navigate('/login');
|
||||
toast({
|
||||
title: '退出成功',
|
||||
description: '您已安全退出系统',
|
||||
});
|
||||
};
|
||||
|
||||
const handleFunctionClick = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-gray-500">请先登录</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title="我的"
|
||||
showBack={false}
|
||||
titleColor="blue"
|
||||
actions={[
|
||||
{
|
||||
type: 'icon',
|
||||
icon: Bell,
|
||||
onClick: () => console.log('Notifications'),
|
||||
},
|
||||
{
|
||||
type: 'icon',
|
||||
icon: Settings,
|
||||
onClick: () => console.log('Settings'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 pb-16">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 用户信息卡片 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={userInfo?.avatar || user?.avatar || ''} />
|
||||
<AvatarFallback className="bg-gray-200 text-gray-600 text-lg font-medium">
|
||||
{currentUserInfo.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h2 className="text-lg font-medium">{currentUserInfo.name}</h2>
|
||||
<span className="px-2 py-1 text-xs bg-gradient-to-r from-orange-400 to-orange-500 text-white rounded-full font-medium shadow-sm">
|
||||
{currentUserInfo.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">{currentUserInfo.email}</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div>最近登录: {currentUserInfo.lastLogin}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 我的功能 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
{functionModules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="flex items-center p-4 rounded-lg border hover:bg-gray-50 cursor-pointer transition-colors w-full"
|
||||
onClick={() => handleFunctionClick(module.path)}
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${module.bgColor} mr-3`}>{module.icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{module.title}</div>
|
||||
<div className="text-xs text-gray-500">{module.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="px-2 py-1 text-xs bg-gray-50 text-gray-700 rounded-full border border-gray-200 font-medium shadow-sm">
|
||||
{module.count}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-red-600 border-red-200 hover:bg-red-50 bg-transparent"
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 退出登录确认对话框 */}
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认退出登录</DialogTitle>
|
||||
<DialogDescription>
|
||||
您确定要退出登录吗?退出后需要重新登录才能使用完整功能。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="outline" onClick={() => setShowLogoutDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleLogout}>
|
||||
确认退出
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Layout from "@/components/Layout";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import {
|
||||
Plus,
|
||||
Users,
|
||||
Calendar,
|
||||
Copy,
|
||||
Trash2,
|
||||
Edit,
|
||||
Settings,
|
||||
Loader2,
|
||||
Code,
|
||||
Search,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
fetchPlanList,
|
||||
fetchPlanDetail,
|
||||
copyPlan,
|
||||
deletePlan,
|
||||
type Task,
|
||||
} from "@/api/scenarios";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import "@/components/Layout.css";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ScenarioData {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
description: string;
|
||||
totalPlans: number;
|
||||
totalCustomers: number;
|
||||
todayCustomers: number;
|
||||
growth: string;
|
||||
}
|
||||
|
||||
interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export default function ScenarioDetail() {
|
||||
const { scenarioId, scenarioName } = useParams<{
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const [scenario, setScenario] = useState<ScenarioData | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
const [currentApiSettings, setCurrentApiSettings] = useState<ApiSettings>({
|
||||
apiKey: "",
|
||||
webhookUrl: "",
|
||||
taskId: "",
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音直播获客",
|
||||
kuaishou: "快手直播获客",
|
||||
xiaohongshu: "小红书种草获客",
|
||||
weibo: "微博话题获客",
|
||||
haibao: "海报扫码获客",
|
||||
phone: "电话号码获客",
|
||||
gongzhonghao: "公众号引流获客",
|
||||
weixinqun: "微信群裂变获客",
|
||||
payment: "付款码获客",
|
||||
api: "API接口获客",
|
||||
};
|
||||
return channelMap[channel] || `${channel}获客`;
|
||||
};
|
||||
|
||||
// 获取场景描述
|
||||
const getScenarioDescription = (channel: string) => {
|
||||
const descriptions: Record<string, string> = {
|
||||
douyin: "通过抖音平台进行精准获客,利用短视频内容吸引目标用户",
|
||||
xiaohongshu: "利用小红书平台进行内容营销获客,通过优质内容建立品牌形象",
|
||||
gongzhonghao: "通过微信公众号进行获客,建立私域流量池",
|
||||
haibao: "通过海报分享进行获客,快速传播品牌信息",
|
||||
phone: "通过电话营销进行获客,直接与客户沟通",
|
||||
weixinqun: "通过微信群进行获客,利用社交裂变效应",
|
||||
payment: "通过付款码进行获客,便捷的支付方式",
|
||||
api: "通过API接口进行获客,支持第三方系统集成",
|
||||
};
|
||||
return descriptions[channel] || "通过该平台进行获客";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarioData = async () => {
|
||||
if (!scenarioId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// 获取计划列表
|
||||
const response = await fetchPlanList(scenarioId, 1, 20);
|
||||
|
||||
// 设置计划列表(可能为空)
|
||||
if (response && response.data && response.data.list) {
|
||||
setTasks(response.data.list);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
|
||||
// 构建场景数据(无论是否有计划都要创建)
|
||||
const scenarioData: ScenarioData = {
|
||||
id: scenarioId,
|
||||
name: scenarioName || "",
|
||||
image: "", // 可以根据需要设置图片
|
||||
description: getScenarioDescription(scenarioId),
|
||||
totalPlans: response?.data?.list?.length || 0,
|
||||
totalCustomers: 0, // 移除统计
|
||||
todayCustomers: 0, // 移除统计
|
||||
growth: "", // 移除增长
|
||||
};
|
||||
|
||||
setScenario(scenarioData);
|
||||
} catch (error) {
|
||||
console.error("获取场景数据失败:", error);
|
||||
// 即使API失败也要创建基本的场景数据
|
||||
const scenarioData: ScenarioData = {
|
||||
id: scenarioId,
|
||||
name: getScenarioName(),
|
||||
image: "",
|
||||
description: getScenarioDescription(scenarioId),
|
||||
totalPlans: 0,
|
||||
totalCustomers: 0,
|
||||
todayCustomers: 0,
|
||||
growth: "",
|
||||
};
|
||||
setScenario(scenarioData);
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScenarioData();
|
||||
}, [scenarioId]);
|
||||
|
||||
// 获取场景名称 - 优先使用URL查询参数,其次使用映射
|
||||
const getScenarioName = useCallback(() => {
|
||||
// 优先使用URL查询参数中的name
|
||||
const urlName = searchParams.get("name");
|
||||
if (urlName) {
|
||||
return urlName;
|
||||
}
|
||||
|
||||
// 如果没有URL参数,使用映射
|
||||
return getChannelName(scenarioId || "");
|
||||
}, [searchParams, scenarioId]);
|
||||
|
||||
// 更新场景数据中的名称
|
||||
useEffect(() => {
|
||||
setScenario((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
name: (() => {
|
||||
const urlName = searchParams.get("name");
|
||||
if (urlName) return urlName;
|
||||
return getChannelName(scenarioId || "");
|
||||
})(),
|
||||
}
|
||||
: null
|
||||
);
|
||||
}, [searchParams, scenarioId]);
|
||||
|
||||
const handleCopyPlan = async (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToCopy) return;
|
||||
|
||||
try {
|
||||
const response = await copyPlan(taskId);
|
||||
if (response && response.code === 200) {
|
||||
toast({
|
||||
title: "计划已复制",
|
||||
description: `已成功复制"${taskToCopy.name}"`,
|
||||
});
|
||||
|
||||
// 重新加载数据
|
||||
const refreshResponse = await fetchPlanList(scenarioId!, 1, 20);
|
||||
if (
|
||||
refreshResponse &&
|
||||
refreshResponse.code === 200 &&
|
||||
refreshResponse.data
|
||||
) {
|
||||
setTasks(refreshResponse.data.list);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response?.msg || "复制失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("复制计划失败:", error);
|
||||
toast({
|
||||
title: "复制失败",
|
||||
description: error instanceof Error ? error.message : "复制计划失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (taskId: string) => {
|
||||
const taskToDelete = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
if (!window.confirm(`确定要删除"${taskToDelete.name}"吗?`)) return;
|
||||
|
||||
try {
|
||||
const response = await deletePlan(taskId);
|
||||
if (response && response.code === 200) {
|
||||
toast({
|
||||
title: "计划已删除",
|
||||
description: `已成功删除"${taskToDelete.name}"`,
|
||||
});
|
||||
|
||||
// 重新加载数据
|
||||
const refreshResponse = await fetchPlanList(scenarioId!, 1, 20);
|
||||
if (
|
||||
refreshResponse &&
|
||||
refreshResponse.code === 200 &&
|
||||
refreshResponse.data
|
||||
) {
|
||||
setTasks(refreshResponse.data.list);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response?.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除计划失败:", error);
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: error instanceof Error ? error.message : "删除计划失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (taskId: string, newStatus: 1 | 0) => {
|
||||
try {
|
||||
// 这里应该调用状态切换API,暂时模拟
|
||||
setTasks((prev) =>
|
||||
prev.map((task) =>
|
||||
task.id === taskId ? { ...task, status: newStatus } : task
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "状态已更新",
|
||||
description: `计划已${newStatus === 1 ? "启动" : "暂停"}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("状态切换失败:", error);
|
||||
toast({
|
||||
title: "状态切换失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenApiSettings = async (taskId: string) => {
|
||||
try {
|
||||
const response = await fetchPlanDetail(taskId);
|
||||
if (response && response.code === 200 && response.data) {
|
||||
setCurrentApiSettings({
|
||||
apiKey: response.data.apiKey || "demo-api-key-123456",
|
||||
webhookUrl:
|
||||
response.data.textUrl?.fullUrl ||
|
||||
`https://api.example.com/webhook/${taskId}`,
|
||||
taskId,
|
||||
});
|
||||
setShowApiDialog(true);
|
||||
} else {
|
||||
throw new Error(response?.msg || "获取API设置失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取API设置失败:", error);
|
||||
toast({
|
||||
title: "获取API设置失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyApiUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "接口地址已复制到剪贴板",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNewPlan = () => {
|
||||
navigate(`/scenarios/new/${scenarioId}`);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "text-green-600 bg-green-50";
|
||||
case 0:
|
||||
return "text-yellow-600 bg-yellow-50";
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 0:
|
||||
return "已暂停";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title={scenario?.name || "场景详情"}
|
||||
defaultBackPath="/scenarios"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载场景数据中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="场景详情" defaultBackPath="/scenarios" />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!scenario) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="场景详情" defaultBackPath="/scenarios" />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载场景数据中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoadingTasks(true);
|
||||
await fetchPlanList(scenarioId!, 1, 20);
|
||||
setLoadingTasks(false);
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) => task.name.includes(searchTerm));
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<PageHeader
|
||||
title={scenario.name}
|
||||
defaultBackPath="/scenarios"
|
||||
rightContent={
|
||||
<button
|
||||
onClick={handleCreateNewPlan}
|
||||
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建计划
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center space-x-2 m-4">
|
||||
<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={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loadingTasks}
|
||||
>
|
||||
{loadingTasks ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="p-4">
|
||||
{/* 计划列表 */}
|
||||
<div className="rounded-lg">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="mb-4">
|
||||
<Users className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-lg font-medium mb-2">
|
||||
暂无获客计划
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
创建您的第一个获客计划,开始获取客户
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateNewPlan}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
创建第一个计划
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredTasks.map((task) => (
|
||||
<div key={task.id} className="p-4 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{task.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 text-xs rounded-full ${getStatusColor(
|
||||
task.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
<span>最后更新: {task.lastUpdated}</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-sm text-gray-500">
|
||||
<span>
|
||||
设备: {task.stats?.devices || 0} | 获客:{" "}
|
||||
{task.stats?.acquired || 0} | 添加:{" "}
|
||||
{task.stats?.added || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => navigate(`/scenarios/edit/${task.id}`)}
|
||||
className={`p-2 rounded-md ${
|
||||
task.status === 1
|
||||
? "text-yellow-600 hover:bg-yellow-50"
|
||||
: "text-green-600 hover:bg-green-50"
|
||||
}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenApiSettings(task.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-md"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleCopyPlan(task.id)}
|
||||
className="p-2 text-gray-600 hover:bg-gray-50 rounded-md"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeletePlan(task.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-md"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* API接口设置对话框 */}
|
||||
{showApiDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Code className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">计划接口配置</h3>
|
||||
<p className="text-gray-500 text-sm">
|
||||
通过API接口直接导入客资到该获客计划
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowApiDialog(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-2xl">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* API密钥配置 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium">API密钥</h4>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded">
|
||||
安全认证
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
value={currentApiSettings.apiKey}
|
||||
readOnly
|
||||
className="flex-1 p-2 bg-white border rounded font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(currentApiSettings.apiKey);
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "API密钥已复制到剪贴板",
|
||||
});
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>安全提示:</strong>
|
||||
请妥善保管API密钥,不要在客户端代码中暴露。建议在服务器端使用该密钥。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址配置 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium">接口地址</h4>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded">
|
||||
POST请求
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
value={currentApiSettings.webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 p-2 bg-white border rounded font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleCopyApiUrl(currentApiSettings.webhookUrl)
|
||||
}
|
||||
className="px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<h5 className="font-medium text-green-800 mb-2">
|
||||
必要参数
|
||||
</h5>
|
||||
<div className="space-y-1 text-sm text-green-700">
|
||||
<div>
|
||||
<code className="bg-green-100 px-1 rounded">name</code>{" "}
|
||||
- 客户姓名
|
||||
</div>
|
||||
<div>
|
||||
<code className="bg-green-100 px-1 rounded">phone</code>{" "}
|
||||
- 手机号码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<h5 className="font-medium text-blue-800 mb-2">可选参数</h5>
|
||||
<div className="space-y-1 text-sm text-blue-700">
|
||||
<div>
|
||||
<code className="bg-blue-100 px-1 rounded">source</code>{" "}
|
||||
- 来源标识
|
||||
</div>
|
||||
<div>
|
||||
<code className="bg-blue-100 px-1 rounded">remark</code>{" "}
|
||||
- 备注信息
|
||||
</div>
|
||||
<div>
|
||||
<code className="bg-blue-100 px-1 rounded">tags</code> -
|
||||
客户标签
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, TrendingUp, Loader2 } from "lucide-react";
|
||||
import UnifiedHeader from "@/components/UnifiedHeader";
|
||||
import Layout from "@/components/Layout";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import { fetchScenes, type SceneItem } from "@/api/scenarios";
|
||||
import "@/components/Layout.css";
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
description?: string;
|
||||
count: number;
|
||||
growth: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function Scenarios() {
|
||||
const navigate = useNavigate();
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// 场景描述映射
|
||||
const scenarioDescriptions: Record<string, string> = useMemo(
|
||||
() => ({
|
||||
douyin: "通过抖音平台进行精准获客",
|
||||
xiaohongshu: "利用小红书平台进行内容营销获客",
|
||||
gongzhonghao: "通过微信公众号进行获客",
|
||||
haibao: "通过海报分享进行获客",
|
||||
phone: "通过电话营销进行获客",
|
||||
weixinqun: "通过微信群进行获客",
|
||||
payment: "通过付款码进行获客",
|
||||
api: "通过API接口进行获客",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarios = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetchScenes({ page: 1, limit: 20 });
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
// 转换API数据为前端需要的格式
|
||||
const transformedScenarios: Scenario[] = response.data.map(
|
||||
(item: SceneItem) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
image:
|
||||
item.image ||
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png",
|
||||
description:
|
||||
scenarioDescriptions[item.name.toLowerCase()] ||
|
||||
"通过该平台进行获客",
|
||||
count: Math.floor(Math.random() * 200) + 50, // 模拟今日数据
|
||||
growth: `+${Math.floor(Math.random() * 20) + 5}%`, // 模拟增长率
|
||||
status: item.status === 1 ? "active" : "inactive",
|
||||
})
|
||||
);
|
||||
|
||||
setScenarios(transformedScenarios);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取场景数据失败:", error);
|
||||
setError("获取场景数据失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScenarios();
|
||||
}, [scenarioDescriptions]);
|
||||
|
||||
const handleScenarioClick = (scenarioId: string, scenarioName: string) => {
|
||||
navigate(
|
||||
`/scenarios/list/${scenarioId}/${encodeURIComponent(scenarioName)}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleNewPlan = () => {
|
||||
navigate("/scenarios/new");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={<UnifiedHeader title="场景获客" showBack={false} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载场景数据中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && scenarios.length === 0) {
|
||||
return (
|
||||
<Layout
|
||||
header={<UnifiedHeader title="场景获客" showBack={false} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title="场景获客"
|
||||
showBack={false}
|
||||
titleColor="blue"
|
||||
actions={[
|
||||
{
|
||||
type: "button",
|
||||
icon: Plus,
|
||||
label: "新建计划",
|
||||
size: "sm",
|
||||
onClick: handleNewPlan,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p className="text-yellow-800 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{scenarios.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className="bg-white rounded-lg shadow overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => handleScenarioClick(scenario.id, scenario.name)}
|
||||
>
|
||||
<div className="p-4 flex flex-col items-center">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center mb-2">
|
||||
<img
|
||||
src={scenario.image}
|
||||
alt={scenario.name}
|
||||
className="w-8 h-8"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时使用默认图标
|
||||
e.currentTarget.src =
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-blue-600 font-medium text-center">
|
||||
{scenario.name}
|
||||
</h3>
|
||||
{scenario.description && (
|
||||
<p className="text-xs text-gray-500 text-center mt-1 line-clamp-2">
|
||||
{scenario.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center mt-2 text-gray-500 text-sm">
|
||||
<span>今日: </span>
|
||||
<span className="font-medium ml-1">{scenario.count}</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-1 text-green-500 text-xs">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
<span>{scenario.growth}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toast } from "tdesign-mobile-react";
|
||||
import { Steps, StepItem } from "tdesign-mobile-react";
|
||||
import { BasicSettings } from "./steps/BasicSettings";
|
||||
import { FriendRequestSettings } from "./steps/FriendRequestSettings";
|
||||
import { MessageSettings } from "./steps/MessageSettings";
|
||||
import Layout from "@/components/Layout";
|
||||
import {
|
||||
getPlanScenes,
|
||||
createScenarioPlan,
|
||||
fetchPlanDetail,
|
||||
PlanDetail,
|
||||
updateScenarioPlan,
|
||||
} from "@/api/scenarios";
|
||||
|
||||
// 步骤定义 - 只保留三个步骤
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
];
|
||||
|
||||
// 类型定义
|
||||
interface FormData {
|
||||
name: string;
|
||||
scenario: number;
|
||||
posters: any[]; // 后续可替换为具体Poster类型
|
||||
device: string[];
|
||||
remarkType: string;
|
||||
greeting: string;
|
||||
addInterval: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
enabled: boolean;
|
||||
sceneId: string | number;
|
||||
remarkFormat: string;
|
||||
addFriendInterval: number;
|
||||
}
|
||||
|
||||
export default function NewPlan() {
|
||||
const router = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
scenario: 1,
|
||||
posters: [],
|
||||
device: [],
|
||||
remarkType: "phone",
|
||||
greeting: "你好,请通过",
|
||||
addInterval: 1,
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
enabled: true,
|
||||
sceneId: "",
|
||||
remarkFormat: "",
|
||||
addFriendInterval: 1,
|
||||
});
|
||||
const [sceneList, setSceneList] = useState<any[]>([]);
|
||||
const [sceneLoading, setSceneLoading] = useState(true);
|
||||
const { scenarioId, planId } = useParams<{
|
||||
scenarioId: string;
|
||||
planId: string;
|
||||
}>();
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setSceneLoading(true);
|
||||
//获取场景类型
|
||||
getPlanScenes()
|
||||
.then((res) => {
|
||||
setSceneList(res?.data || []);
|
||||
})
|
||||
.finally(() => setSceneLoading(false));
|
||||
if (planId) {
|
||||
setIsEdit(true);
|
||||
//获取计划详情
|
||||
const res = await fetchPlanDetail(planId);
|
||||
if (res.code === 200 && res.data) {
|
||||
const detail = res.data as PlanDetail;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
name: detail.name ?? "",
|
||||
scenario: Number(detail.scenario) || 1,
|
||||
posters: detail.posters ?? [],
|
||||
device: detail.device ?? [],
|
||||
remarkType: detail.remarkType ?? "phone",
|
||||
greeting: detail.greeting ?? "",
|
||||
addInterval: detail.addInterval ?? 1,
|
||||
startTime: detail.startTime ?? "09:00",
|
||||
endTime: detail.endTime ?? "18:00",
|
||||
enabled: detail.enabled ?? true,
|
||||
sceneId: Number(detail.scenario) || 1,
|
||||
remarkFormat: detail.remarkFormat ?? "",
|
||||
addFriendInterval: detail.addFriendInterval ?? 1,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (scenarioId) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
...{ scenario: Number(scenarioId) || 1 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 更新表单数据
|
||||
const onChange = (data: any) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
let result;
|
||||
if (isEdit && planId) {
|
||||
// 编辑:拼接后端需要的完整参数
|
||||
const editData = {
|
||||
...formData,
|
||||
id: Number(planId),
|
||||
planId: Number(planId),
|
||||
// 兼容后端需要的字段
|
||||
// 你可以根据实际需要补充其它字段
|
||||
};
|
||||
result = await updateScenarioPlan(planId, editData);
|
||||
} else {
|
||||
// 新建
|
||||
result = await createScenarioPlan(formData);
|
||||
}
|
||||
if (result.code === 200) {
|
||||
Toast({
|
||||
message: isEdit ? "计划已更新" : "获客计划已创建",
|
||||
theme: "success",
|
||||
});
|
||||
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
|
||||
router(`/scenarios/list/${formData.sceneId}/${sceneItem.name}`);
|
||||
} else {
|
||||
Toast({ message: result.msg, theme: "error" });
|
||||
}
|
||||
} catch (error) {
|
||||
Toast({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: isEdit
|
||||
? "更新计划失败,请重试"
|
||||
: "创建计划失败,请重试",
|
||||
theme: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave();
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 上一步
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// 渲染当前步骤内容
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<BasicSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleNext}
|
||||
sceneList={sceneList}
|
||||
sceneLoading={sceneLoading}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<FriendRequestSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleNext}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<MessageSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleSave}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<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(-1)}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-4 py-6">
|
||||
<Steps current={currentStep - 1}>
|
||||
{steps.map((step) => (
|
||||
<StepItem
|
||||
key={step.id}
|
||||
title={step.title}
|
||||
content={step.subtitle}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="p-4">{renderStepContent()}</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user