同步代码
This commit is contained in:
4
Cunkebao/.env.development
Normal file
4
Cunkebao/.env.development
Normal file
@@ -0,0 +1,4 @@
|
||||
# 基础环境变量示例
|
||||
# VITE_API_BASE_URL=http://www.yishi.com
|
||||
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||
VITE_APP_TITLE=存客宝
|
||||
3
Cunkebao/.env.production
Normal file
3
Cunkebao/.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# 基础环境变量示例
|
||||
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||
VITE_APP_TITLE=存客宝
|
||||
64
Cunkebao/.eslintrc.js
Normal file
64
Cunkebao/.eslintrc.js
Normal file
@@ -0,0 +1,64 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended", // 这个配置会自动处理大部分冲突
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 12,
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["react", "react-hooks", "@typescript-eslint", "prettier"],
|
||||
rules: {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
|
||||
"react/prop-types": "off",
|
||||
"linebreak-style": "off",
|
||||
"eol-last": "off",
|
||||
"no-empty": "warn",
|
||||
"prefer-const": "warn",
|
||||
// 确保与 Prettier 完全兼容
|
||||
"comma-dangle": "off",
|
||||
"comma-spacing": "off",
|
||||
"comma-style": "off",
|
||||
"object-curly-spacing": "off",
|
||||
"array-bracket-spacing": "off",
|
||||
indent: "off",
|
||||
quotes: "off",
|
||||
semi: "off",
|
||||
"arrow-parens": "off",
|
||||
"no-multiple-empty-lines": "off",
|
||||
"max-len": "off",
|
||||
"space-before-function-paren": "off",
|
||||
"space-before-blocks": "off",
|
||||
"keyword-spacing": "off",
|
||||
"space-infix-ops": "off",
|
||||
"space-in-parens": "off",
|
||||
"space-in-brackets": "off",
|
||||
"object-property-newline": "off",
|
||||
"array-element-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"object-curly-newline": "off",
|
||||
"array-bracket-newline": "off",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
};
|
||||
27
Cunkebao/.gitattributes
vendored
Normal file
27
Cunkebao/.gitattributes
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# 设置默认行为,如果core.autocrlf没有设置,Git会自动处理行尾符
|
||||
* text=auto
|
||||
|
||||
# 明确指定文本文件使用LF
|
||||
*.js text eol=lf
|
||||
*.jsx text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.json text eol=lf
|
||||
*.css text eol=lf
|
||||
*.scss text eol=lf
|
||||
*.html text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# 二进制文件
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
29
Cunkebao/.gitignore
vendored
29
Cunkebao/.gitignore
vendored
@@ -1,23 +1,6 @@
|
||||
# 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*
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
yarn.lock
|
||||
.env
|
||||
.DS_Store
|
||||
13
Cunkebao/.prettierrc
Normal file
13
Cunkebao/.prettierrc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed"
|
||||
}
|
||||
8
Cunkebao/.vite/deps/_metadata.json
Normal file
8
Cunkebao/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "efe0acf4",
|
||||
"configHash": "2bed34b3",
|
||||
"lockfileHash": "ef01d341",
|
||||
"browserHash": "91bd3b2c",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
Cunkebao/.vite/deps/package.json
Normal file
3
Cunkebao/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"formulahendry.auto-rename-tag",
|
||||
"christian-kohler.path-intellisense",
|
||||
"ms-vscode.vscode-json"
|
||||
]
|
||||
}
|
||||
45
Cunkebao/.vscode/settings.json
vendored
Normal file
45
Cunkebao/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"files.eol": "\n",
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimFinalNewlines": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"eslint.format.enable": false,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false
|
||||
}
|
||||
@@ -1,293 +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'),
|
||||
},
|
||||
},
|
||||
};
|
||||
95
Cunkebao/devlop.py
Normal file
95
Cunkebao/devlop.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import os
|
||||
import zipfile
|
||||
import paramiko
|
||||
|
||||
# 配置
|
||||
local_dir = './dist' # 本地要打包的目录
|
||||
zip_name = 'dist.zip'
|
||||
# 上传到服务器的 zip 路径
|
||||
remote_path = '/www/wwwroot/auto-devlop/ckb-operation/dist.zip' # 服务器上的临时zip路径
|
||||
server_ip = '42.194.245.239'
|
||||
server_port = 6523
|
||||
server_user = 'yongpxu'
|
||||
server_pwd = 'Aa123456789.'
|
||||
# 服务器 dist 相关目录
|
||||
remote_base_dir = '/www/wwwroot/auto-devlop/ckb-operation'
|
||||
dist_dir = f'{remote_base_dir}/dist'
|
||||
dist1_dir = f'{remote_base_dir}/dist1'
|
||||
dist2_dir = f'{remote_base_dir}/dist2'
|
||||
|
||||
# 美化输出用的函数
|
||||
from datetime import datetime
|
||||
|
||||
def info(msg):
|
||||
print(f"\033[36m[INFO {datetime.now().strftime('%H:%M:%S')}] {msg}\033[0m")
|
||||
|
||||
def success(msg):
|
||||
print(f"\033[32m[SUCCESS] {msg}\033[0m")
|
||||
|
||||
def error(msg):
|
||||
print(f"\033[31m[ERROR] {msg}\033[0m")
|
||||
|
||||
def step(msg):
|
||||
print(f"\n\033[35m==== {msg} ====" + "\033[0m")
|
||||
|
||||
# 1. 先运行 yarn build
|
||||
step('Step 1: 构建项目 (yarn build)')
|
||||
info('开始执行 yarn build...')
|
||||
ret = os.system('yarn build')
|
||||
if ret != 0:
|
||||
error('yarn build 失败,终止部署!')
|
||||
exit(1)
|
||||
success('yarn build 完成')
|
||||
|
||||
# 2. 打包
|
||||
step('Step 2: 打包 dist 目录为 zip')
|
||||
info('开始打包 dist 目录...')
|
||||
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for root, dirs, files in os.walk(local_dir):
|
||||
for file in files:
|
||||
filepath = os.path.join(root, file)
|
||||
arcname = os.path.relpath(filepath, local_dir)
|
||||
zipf.write(filepath, arcname)
|
||||
success('本地打包完成')
|
||||
|
||||
# 3. 上传
|
||||
step('Step 3: 上传 zip 包到服务器')
|
||||
info('开始上传 zip 包...')
|
||||
transport = paramiko.Transport((server_ip, server_port))
|
||||
transport.connect(username=server_user, password=server_pwd)
|
||||
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||
sftp.put(zip_name, remote_path)
|
||||
sftp.close()
|
||||
transport.close()
|
||||
success('上传到服务器完成')
|
||||
|
||||
# 删除本地 dist.zip
|
||||
try:
|
||||
os.remove(zip_name)
|
||||
success('本地 dist.zip 已删除')
|
||||
except Exception as e:
|
||||
error(f'本地 dist.zip 删除失败: {e}')
|
||||
|
||||
# 4. 远程解压并覆盖
|
||||
step('Step 4: 服务器端解压、切换目录')
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(server_ip, server_port, server_user, server_pwd)
|
||||
commands = [
|
||||
f'unzip -oq {remote_path} -d {dist2_dir}', # 静默解压
|
||||
f'rm {remote_path}',
|
||||
f'if [ -d {dist_dir} ]; then mv {dist_dir} {dist1_dir}; fi',
|
||||
f'mv {dist2_dir} {dist_dir}',
|
||||
f'rm -rf {dist1_dir}'
|
||||
]
|
||||
for i, cmd in enumerate(commands, 1):
|
||||
info(f'执行第{i}步: {cmd}')
|
||||
stdin, stdout, stderr = ssh.exec_command(cmd)
|
||||
out, err = stdout.read().decode(), stderr.read().decode()
|
||||
# 只打印非 unzip 命令的输出
|
||||
if i != 1 and out.strip():
|
||||
print(out.strip())
|
||||
if err.strip():
|
||||
error(err.strip())
|
||||
ssh.close()
|
||||
success('服务器解压并覆盖完成,部署成功!')
|
||||
BIN
Cunkebao/favicon.ico
Normal file
BIN
Cunkebao/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
19
Cunkebao/index.html
Normal file
19
Cunkebao/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>存客宝</title>
|
||||
<style>
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
<!-- 引入 uni-app web-view SDK(必须) -->
|
||||
<script type="text/javascript" src="./websdk.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
Cunkebao/next-env.d.ts
vendored
6
Cunkebao/next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
19654
Cunkebao/package-lock.json
generated
19654
Cunkebao/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,106 +1,49 @@
|
||||
{
|
||||
"name": "nkebao2",
|
||||
"version": "0.1.0",
|
||||
"name": "cunkebao",
|
||||
"version": "3.0.0",
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"antd": "^5.13.1",
|
||||
"antd-mobile": "^5.39.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"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"
|
||||
"vconsole": "^3.15.1",
|
||||
"zustand": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^7.1.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^4.9.5"
|
||||
"@types/node": "^24.0.14",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^7.7.0",
|
||||
"@typescript-eslint/parser": "^7.7.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss-pxtorem": "^6.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.75.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^7.0.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "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"
|
||||
]
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\"",
|
||||
"lint:check": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
plugins: {
|
||||
'postcss-pxtorem': {
|
||||
rootValue: 16,
|
||||
propList: ['*'],
|
||||
},
|
||||
},
|
||||
};
|
||||
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>
|
||||
BIN
Cunkebao/public/logo.png
Normal file
BIN
Cunkebao/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 488 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"name": "Cunkebao",
|
||||
"short_name": "Cunkebao",
|
||||
"description": "Cunkebao Mobile App",
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
@@ -8,18 +15,16 @@
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"src": "logo.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"src": "logo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
308
Cunkebao/public/websdk.js
Normal file
308
Cunkebao/public/websdk.js
Normal file
@@ -0,0 +1,308 @@
|
||||
!(function (e, n) {
|
||||
"object" == typeof exports && "undefined" != typeof module
|
||||
? (module.exports = n())
|
||||
: "function" == typeof define && define.amd
|
||||
? define(n)
|
||||
: ((e = e || self).uni = n());
|
||||
})(this, function () {
|
||||
"use strict";
|
||||
try {
|
||||
var e = {};
|
||||
(Object.defineProperty(e, "passive", {
|
||||
get: function () {
|
||||
!0;
|
||||
},
|
||||
}),
|
||||
window.addEventListener("test-passive", null, e));
|
||||
} catch (e) {}
|
||||
var n = Object.prototype.hasOwnProperty;
|
||||
function i(e, i) {
|
||||
return n.call(e, i);
|
||||
}
|
||||
var t = [];
|
||||
function o() {
|
||||
return window.__dcloud_weex_postMessage || window.__dcloud_weex_;
|
||||
}
|
||||
function a() {
|
||||
return window.__uniapp_x_postMessage || window.__uniapp_x_;
|
||||
}
|
||||
var r = function (e, n) {
|
||||
var i = { options: { timestamp: +new Date() }, name: e, arg: n };
|
||||
if (a()) {
|
||||
if ("postMessage" === e) {
|
||||
var r = { data: n };
|
||||
return window.__uniapp_x_postMessage
|
||||
? window.__uniapp_x_postMessage(r)
|
||||
: window.__uniapp_x_.postMessage(JSON.stringify(r));
|
||||
}
|
||||
var d = {
|
||||
type: "WEB_INVOKE_APPSERVICE",
|
||||
args: { data: i, webviewIds: t },
|
||||
};
|
||||
window.__uniapp_x_postMessage
|
||||
? window.__uniapp_x_postMessageToService(d)
|
||||
: window.__uniapp_x_.postMessageToService(JSON.stringify(d));
|
||||
} else if (o()) {
|
||||
if ("postMessage" === e) {
|
||||
var s = { data: [n] };
|
||||
return window.__dcloud_weex_postMessage
|
||||
? window.__dcloud_weex_postMessage(s)
|
||||
: window.__dcloud_weex_.postMessage(JSON.stringify(s));
|
||||
}
|
||||
var w = {
|
||||
type: "WEB_INVOKE_APPSERVICE",
|
||||
args: { data: i, webviewIds: t },
|
||||
};
|
||||
window.__dcloud_weex_postMessage
|
||||
? window.__dcloud_weex_postMessageToService(w)
|
||||
: window.__dcloud_weex_.postMessageToService(JSON.stringify(w));
|
||||
} else {
|
||||
if (!window.plus)
|
||||
return window.parent.postMessage(
|
||||
{ type: "WEB_INVOKE_APPSERVICE", data: i, pageId: "" },
|
||||
"*",
|
||||
);
|
||||
if (0 === t.length) {
|
||||
var u = plus.webview.currentWebview();
|
||||
if (!u) throw new Error("plus.webview.currentWebview() is undefined");
|
||||
var g = u.parent(),
|
||||
v = "";
|
||||
((v = g ? g.id : u.id), t.push(v));
|
||||
}
|
||||
if (plus.webview.getWebviewById("__uniapp__service"))
|
||||
plus.webview.postMessageToUniNView(
|
||||
{ type: "WEB_INVOKE_APPSERVICE", args: { data: i, webviewIds: t } },
|
||||
"__uniapp__service",
|
||||
);
|
||||
else {
|
||||
var c = JSON.stringify(i);
|
||||
plus.webview
|
||||
.getLaunchWebview()
|
||||
.evalJS(
|
||||
'UniPlusBridge.subscribeHandler("'
|
||||
.concat("WEB_INVOKE_APPSERVICE", '",')
|
||||
.concat(c, ",")
|
||||
.concat(JSON.stringify(t), ");"),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
d = {
|
||||
navigateTo: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.url;
|
||||
r("navigateTo", { url: encodeURI(n) });
|
||||
},
|
||||
navigateBack: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.delta;
|
||||
r("navigateBack", { delta: parseInt(n) || 1 });
|
||||
},
|
||||
switchTab: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.url;
|
||||
r("switchTab", { url: encodeURI(n) });
|
||||
},
|
||||
reLaunch: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.url;
|
||||
r("reLaunch", { url: encodeURI(n) });
|
||||
},
|
||||
redirectTo: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||
n = e.url;
|
||||
r("redirectTo", { url: encodeURI(n) });
|
||||
},
|
||||
getEnv: function (e) {
|
||||
a()
|
||||
? e({ uvue: !0 })
|
||||
: o()
|
||||
? e({ nvue: !0 })
|
||||
: window.plus
|
||||
? e({ plus: !0 })
|
||||
: e({ h5: !0 });
|
||||
},
|
||||
postMessage: function () {
|
||||
var e =
|
||||
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {};
|
||||
r("postMessage", e.data || {});
|
||||
},
|
||||
},
|
||||
s = /uni-app/i.test(navigator.userAgent),
|
||||
w = /Html5Plus/i.test(navigator.userAgent),
|
||||
u = /complete|loaded|interactive/;
|
||||
var g =
|
||||
window.my &&
|
||||
navigator.userAgent.indexOf(
|
||||
["t", "n", "e", "i", "l", "C", "y", "a", "p", "i", "l", "A"]
|
||||
.reverse()
|
||||
.join(""),
|
||||
) > -1;
|
||||
var v =
|
||||
window.swan && window.swan.webView && /swan/i.test(navigator.userAgent);
|
||||
var c =
|
||||
window.qq &&
|
||||
window.qq.miniProgram &&
|
||||
/QQ/i.test(navigator.userAgent) &&
|
||||
/miniProgram/i.test(navigator.userAgent);
|
||||
var p =
|
||||
window.tt &&
|
||||
window.tt.miniProgram &&
|
||||
/toutiaomicroapp/i.test(navigator.userAgent);
|
||||
var _ =
|
||||
window.wx &&
|
||||
window.wx.miniProgram &&
|
||||
/micromessenger/i.test(navigator.userAgent) &&
|
||||
/miniProgram/i.test(navigator.userAgent);
|
||||
var m = window.qa && /quickapp/i.test(navigator.userAgent);
|
||||
var f =
|
||||
window.ks &&
|
||||
window.ks.miniProgram &&
|
||||
/micromessenger/i.test(navigator.userAgent) &&
|
||||
/miniProgram/i.test(navigator.userAgent);
|
||||
var l =
|
||||
window.tt &&
|
||||
window.tt.miniProgram &&
|
||||
/Lark|Feishu/i.test(navigator.userAgent);
|
||||
var E =
|
||||
window.jd && window.jd.miniProgram && /jdmp/i.test(navigator.userAgent);
|
||||
var x =
|
||||
window.xhs &&
|
||||
window.xhs.miniProgram &&
|
||||
/xhsminiapp/i.test(navigator.userAgent);
|
||||
for (
|
||||
var S,
|
||||
h = function () {
|
||||
((window.UniAppJSBridge = !0),
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("UniAppJSBridgeReady", {
|
||||
bubbles: !0,
|
||||
cancelable: !0,
|
||||
}),
|
||||
));
|
||||
},
|
||||
y = [
|
||||
function (e) {
|
||||
if (s || w)
|
||||
return (
|
||||
window.__uniapp_x_postMessage ||
|
||||
window.__uniapp_x_ ||
|
||||
window.__dcloud_weex_postMessage ||
|
||||
window.__dcloud_weex_
|
||||
? document.addEventListener("DOMContentLoaded", e)
|
||||
: window.plus && u.test(document.readyState)
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("plusready", e),
|
||||
d
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (_)
|
||||
return (
|
||||
window.WeixinJSBridge && window.WeixinJSBridge.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("WeixinJSBridgeReady", e),
|
||||
window.wx.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (c)
|
||||
return (
|
||||
window.QQJSBridge && window.QQJSBridge.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("QQJSBridgeReady", e),
|
||||
window.qq.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (g) {
|
||||
document.addEventListener("DOMContentLoaded", e);
|
||||
var n = window.my;
|
||||
return {
|
||||
navigateTo: n.navigateTo,
|
||||
navigateBack: n.navigateBack,
|
||||
switchTab: n.switchTab,
|
||||
reLaunch: n.reLaunch,
|
||||
redirectTo: n.redirectTo,
|
||||
postMessage: n.postMessage,
|
||||
getEnv: n.getEnv,
|
||||
};
|
||||
}
|
||||
},
|
||||
function (e) {
|
||||
if (v)
|
||||
return (
|
||||
document.addEventListener("DOMContentLoaded", e),
|
||||
window.swan.webView
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (p)
|
||||
return (
|
||||
document.addEventListener("DOMContentLoaded", e),
|
||||
window.tt.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (m) {
|
||||
window.QaJSBridge && window.QaJSBridge.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("QaJSBridgeReady", e);
|
||||
var n = window.qa;
|
||||
return {
|
||||
navigateTo: n.navigateTo,
|
||||
navigateBack: n.navigateBack,
|
||||
switchTab: n.switchTab,
|
||||
reLaunch: n.reLaunch,
|
||||
redirectTo: n.redirectTo,
|
||||
postMessage: n.postMessage,
|
||||
getEnv: n.getEnv,
|
||||
};
|
||||
}
|
||||
},
|
||||
function (e) {
|
||||
if (f)
|
||||
return (
|
||||
window.WeixinJSBridge && window.WeixinJSBridge.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("WeixinJSBridgeReady", e),
|
||||
window.ks.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (l)
|
||||
return (
|
||||
document.addEventListener("DOMContentLoaded", e),
|
||||
window.tt.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (E)
|
||||
return (
|
||||
window.JDJSBridgeReady && window.JDJSBridgeReady.invoke
|
||||
? setTimeout(e, 0)
|
||||
: document.addEventListener("JDJSBridgeReady", e),
|
||||
window.jd.miniProgram
|
||||
);
|
||||
},
|
||||
function (e) {
|
||||
if (x) return window.xhs.miniProgram;
|
||||
},
|
||||
function (e) {
|
||||
return (document.addEventListener("DOMContentLoaded", e), d);
|
||||
},
|
||||
],
|
||||
M = 0;
|
||||
M < y.length && !(S = y[M](h));
|
||||
M++
|
||||
);
|
||||
S || (S = {});
|
||||
var P = "undefined" != typeof uni ? uni : {};
|
||||
if (!P.navigateTo) for (var b in S) i(S, b) && (P[b] = S[b]);
|
||||
return ((P.webView = S), P);
|
||||
});
|
||||
@@ -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,190 +1,13 @@
|
||||
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';
|
||||
import React from "react";
|
||||
import AppRouter from "@/router";
|
||||
import UpdateNotification from "@/components/UpdateNotification";
|
||||
|
||||
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>
|
||||
<>
|
||||
<AppRouter />
|
||||
<UpdateNotification position="top" autoReload={false} showToast={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
27
Cunkebao/src/api/common.ts
Normal file
27
Cunkebao/src/api/common.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import request from "./request";
|
||||
/**
|
||||
* 通用文件上传方法(支持图片、文件)
|
||||
* @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);
|
||||
|
||||
// 使用 request 方法上传文件,设置正确的 Content-Type
|
||||
const res = await request(uploadUrl, formData, "POST", {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return res.url;
|
||||
} catch (e: any) {
|
||||
throw new Error(e?.message || "文件上传失败");
|
||||
}
|
||||
}
|
||||
@@ -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 +1,90 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { requestInterceptor, responseInterceptor, errorInterceptor } from './interceptors';
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
Method,
|
||||
AxiosResponse,
|
||||
} from "axios";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
const { token } = useUserStore.getState();
|
||||
const DEFAULT_DEBOUNCE_GAP = 1000;
|
||||
const debounceMap = new Map<string, number>();
|
||||
|
||||
// 创建axios实例
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: process.env.REACT_APP_API_BASE_URL || 'https://ckbapi.quwanzhi.com',
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
||||
timeout: 20000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
async (config) => {
|
||||
// 检查token是否需要刷新
|
||||
if (config.headers.Authorization) {
|
||||
const shouldContinue = await requestInterceptor();
|
||||
if (!shouldContinue) {
|
||||
throw new Error('请求被拦截,需要重新登录');
|
||||
instance.interceptors.request.use((config: any) => {
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res: AxiosResponse) => {
|
||||
const { code, success, msg } = res.data || {};
|
||||
if (code === 200 || success) {
|
||||
return res.data.data ?? res.data;
|
||||
}
|
||||
Toast.show({ content: msg || "接口错误", position: "top" });
|
||||
if (code === 401) {
|
||||
localStorage.removeItem("token");
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
if (currentPath === "/login") {
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加token到请求头
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
return Promise.reject(msg || "接口错误");
|
||||
},
|
||||
err => {
|
||||
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||
return Promise.reject(err);
|
||||
},
|
||||
(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);
|
||||
export function request(
|
||||
url: string,
|
||||
data?: any,
|
||||
method: Method = "GET",
|
||||
config?: AxiosRequestConfig,
|
||||
debounceGap?: number,
|
||||
): Promise<any> {
|
||||
const gap =
|
||||
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
||||
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
||||
const now = Date.now();
|
||||
const last = debounceMap.get(key) || 0;
|
||||
if (gap > 0 && now - last < gap) {
|
||||
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
|
||||
return Promise.reject("请求过于频繁,请稍后再试");
|
||||
}
|
||||
);
|
||||
debounceMap.set(key, now);
|
||||
|
||||
// 封装GET请求
|
||||
export const get = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return request.get(url, config);
|
||||
};
|
||||
const axiosConfig: AxiosRequestConfig = {
|
||||
url,
|
||||
method,
|
||||
...config,
|
||||
};
|
||||
|
||||
// 封装POST请求
|
||||
export const post = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return request.post(url, data, config);
|
||||
};
|
||||
// 如果是FormData,不设置Content-Type,让浏览器自动设置
|
||||
if (data instanceof FormData) {
|
||||
delete axiosConfig.headers?.["Content-Type"];
|
||||
}
|
||||
|
||||
// 封装PUT请求
|
||||
export const put = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return request.put(url, data, config);
|
||||
};
|
||||
if (method.toUpperCase() === "GET") {
|
||||
axiosConfig.params = data;
|
||||
} else {
|
||||
axiosConfig.data = data;
|
||||
}
|
||||
return instance(axiosConfig);
|
||||
}
|
||||
|
||||
// 封装DELETE请求
|
||||
export const del = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return request.delete(url, config);
|
||||
};
|
||||
|
||||
// 导出request实例
|
||||
export { request };
|
||||
export default request;
|
||||
export default request;
|
||||
|
||||
@@ -1,327 +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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计划小程序二维码
|
||||
* @param taskid 任务ID
|
||||
* @returns base64二维码
|
||||
*/
|
||||
export const getWxMinAppCode = async (taskId: string): Promise<{ code: number; data?: string; msg?: string }> => {
|
||||
try {
|
||||
return await get<{ code: number; data?: string; msg?: string }>(
|
||||
`/v1/plan/getWxMinAppCode?taskId=${ taskId }`,
|
||||
|
||||
);
|
||||
} catch (error) {
|
||||
return { code: 500, msg: '获取小程序二维码失败' };
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
10
Cunkebao/src/components/AccountSelection/api.ts
Normal file
10
Cunkebao/src/components/AccountSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取好友列表
|
||||
export function getAccountList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/workbench/account-list", params, "GET");
|
||||
}
|
||||
34
Cunkebao/src/components/AccountSelection/data.ts
Normal file
34
Cunkebao/src/components/AccountSelection/data.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// 账号对象类型
|
||||
export interface AccountItem {
|
||||
id: number;
|
||||
userName: string;
|
||||
realName: string;
|
||||
departmentName: string;
|
||||
avatar?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
//弹窗的
|
||||
export interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
selectedOptions: AccountItem[];
|
||||
onSelect: (options: AccountItem[]) => void;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface AccountSelectionProps {
|
||||
selectedOptions: AccountItem[];
|
||||
onSelect: (options: AccountItem[]) => void;
|
||||
accounts?: AccountItem[]; // 可选:用于在外层显示已选账号详情
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||
}
|
||||
231
Cunkebao/src/components/AccountSelection/index.module.scss
Normal file
231
Cunkebao/src/components/AccountSelection/index.module.scss
Normal file
@@ -0,0 +1,231 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.friendList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.friendListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.friendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.radioWrapper {
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e6eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
}
|
||||
.friendInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.friendAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.friendDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendCustomer {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.cancelBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
.confirmBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
139
Cunkebao/src/components/AccountSelection/index.tsx
Normal file
139
Cunkebao/src/components/AccountSelection/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import style from "./index.module.scss";
|
||||
import SelectionPopup from "./selectionPopup";
|
||||
import { AccountItem, AccountSelectionProps } from "./data";
|
||||
|
||||
export default function AccountSelection({
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
accounts: propAccounts = [],
|
||||
placeholder = "选择账号",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: AccountSelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setRealVisible(true);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedOptions.length === 0) return "";
|
||||
return `已选择 ${selectedOptions.length} 个账号`;
|
||||
};
|
||||
|
||||
// 删除已选账号
|
||||
const handleRemoveAccount = (id: number) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedOptions.filter(d => d.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选账号列表窗口 */}
|
||||
{showSelectedList && selectedOptions.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedOptions.map(acc => (
|
||||
<div
|
||||
key={acc.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
【{acc.realName}】 {acc.userName}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveAccount(acc.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<SelectionPopup
|
||||
visible={realVisible}
|
||||
onVisibleChange={setRealVisible}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelect={onSelect}
|
||||
readonly={readonly}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
202
Cunkebao/src/components/AccountSelection/selectionPopup.tsx
Normal file
202
Cunkebao/src/components/AccountSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Popup } from "antd-mobile";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import style from "./index.module.scss";
|
||||
import { getAccountList } from "./api";
|
||||
import { AccountItem, SelectionPopupProps } from "./data";
|
||||
|
||||
export default function SelectionPopup({
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: SelectionPopupProps) {
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalAccounts, setTotalAccounts] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 累积已加载过的账号,确保确认时能返回更完整的对象
|
||||
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
const fetchAccounts = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, limit: pageSize };
|
||||
if (keyword.trim()) params.keyword = keyword.trim();
|
||||
|
||||
const response = await getAccountList(params);
|
||||
if (response && response.list) {
|
||||
setAccounts(response.list);
|
||||
const total: number = response.total || response.list.length || 0;
|
||||
setTotalAccounts(total);
|
||||
setTotalPages(Math.max(1, Math.ceil(total / pageSize)));
|
||||
|
||||
// 累积到映射表
|
||||
response.list.forEach((acc: AccountItem) => {
|
||||
loadedAccountMapRef.current.set(acc.id, acc);
|
||||
});
|
||||
} else {
|
||||
setAccounts([]);
|
||||
setTotalAccounts(0);
|
||||
setTotalPages(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取账号列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountToggle = (account: AccountItem) => {
|
||||
if (readonly || !onSelect) return;
|
||||
const isSelected = selectedOptions.some(opt => opt.id === account.id);
|
||||
const next = isSelected
|
||||
? selectedOptions.filter(opt => opt.id !== account.id)
|
||||
: selectedOptions.concat(account);
|
||||
onSelect(next);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(selectedOptions);
|
||||
}
|
||||
onVisibleChange(false);
|
||||
};
|
||||
|
||||
// 弹窗打开时初始化数据
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
loadedAccountMapRef.current.clear();
|
||||
fetchAccounts(1, "");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (searchQuery === "") return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchAccounts(1, searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible]);
|
||||
|
||||
// 页码变化
|
||||
useEffect(() => {
|
||||
if (!visible || currentPage === 1) return;
|
||||
fetchAccounts(currentPage, searchQuery);
|
||||
}, [currentPage, visible, searchQuery]);
|
||||
|
||||
const selectedIdSet = useMemo(
|
||||
() => new Set(selectedOptions.map(opt => opt.id)),
|
||||
[selectedOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible && !readonly}
|
||||
onMaskClick={() => onVisibleChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择账号"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索账号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchAccounts(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalAccounts}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedOptions.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => onVisibleChange(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.friendList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : accounts.length > 0 ? (
|
||||
<div className={style.friendListInner}>
|
||||
{accounts.map(acc => (
|
||||
<label
|
||||
key={acc.id}
|
||||
className={style.friendItem}
|
||||
onClick={() => !readonly && handleAccountToggle(acc)}
|
||||
>
|
||||
<div className={style.radioWrapper}>
|
||||
<div
|
||||
className={
|
||||
selectedIdSet.has(acc.id)
|
||||
? style.radioSelected
|
||||
: style.radioUnselected
|
||||
}
|
||||
>
|
||||
{selectedIdSet.has(acc.id) && (
|
||||
<div className={style.radioDot}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.friendInfo}>
|
||||
<div className={style.friendAvatar}>
|
||||
{acc.avatar ? (
|
||||
<img
|
||||
src={acc.avatar}
|
||||
alt={acc.userName}
|
||||
className={style.avatarImg}
|
||||
/>
|
||||
) : (
|
||||
(acc.userName?.charAt(0) ?? "?")
|
||||
)}
|
||||
</div>
|
||||
<div className={style.friendDetail}>
|
||||
<div className={style.friendName}>{acc.userName}</div>
|
||||
<div className={style.friendId}>
|
||||
真实姓名: {acc.realName}
|
||||
</div>
|
||||
<div className={style.friendId}>
|
||||
部门: {acc.departmentName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的账号`
|
||||
: "没有找到账号"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
5
Cunkebao/src/components/ContentSelection/api.ts
Normal file
5
Cunkebao/src/components/ContentSelection/api.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export function getContentLibraryList(params: any) {
|
||||
return request("/v1/content/library/list", params, "GET");
|
||||
}
|
||||
21
Cunkebao/src/components/ContentSelection/data.ts
Normal file
21
Cunkebao/src/components/ContentSelection/data.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// 内容库接口类型
|
||||
export interface ContentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface ContentSelectionProps {
|
||||
selectedOptions: ContentItem[];
|
||||
onSelect: (selectedItems: ContentItem[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (selectedItems: ContentItem[]) => void;
|
||||
}
|
||||
117
Cunkebao/src/components/ContentSelection/index.module.scss
Normal file
117
Cunkebao/src/components/ContentSelection/index.module.scss
Normal file
@@ -0,0 +1,117 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.selectedListWindow {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedListRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.libraryList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.libraryListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.libraryItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.checkboxWrapper {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.checkboxSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.checkboxUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e5e6eb;
|
||||
background: #fff;
|
||||
}
|
||||
.checkboxDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
}
|
||||
.libraryInfo {
|
||||
flex: 1;
|
||||
}
|
||||
.libraryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.libraryName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
.typeTag {
|
||||
font-size: 12px;
|
||||
color: #1677ff;
|
||||
border: 1px solid #1677ff;
|
||||
border-radius: 12px;
|
||||
padding: 2px 10px;
|
||||
margin-left: 8px;
|
||||
background: #f4f8ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.libraryMeta {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.libraryDesc {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
302
Cunkebao/src/components/ContentSelection/index.tsx
Normal file
302
Cunkebao/src/components/ContentSelection/index.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import { Popup, Checkbox } from "antd-mobile";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import { getContentLibraryList } from "./api";
|
||||
import { ContentItem, ContentSelectionProps } from "./data";
|
||||
|
||||
// 类型标签文本
|
||||
const getTypeText = (type?: number) => {
|
||||
if (type === 1) return "文本";
|
||||
if (type === 2) return "图片";
|
||||
if (type === 3) return "视频";
|
||||
return "未知";
|
||||
};
|
||||
|
||||
// 时间格式化
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return "-";
|
||||
return `${d.getFullYear()}/${(d.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
|
||||
.getHours()
|
||||
.toString()
|
||||
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
|
||||
.getSeconds()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
export default function ContentSelection({
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
placeholder = "选择内容库",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: ContentSelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [libraries, setLibraries] = useState<ContentItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalLibraries, setTotalLibraries] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 删除已选内容库
|
||||
const handleRemoveLibrary = (id: number) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedOptions.filter(c => c.id !== id));
|
||||
};
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
setRealVisible(true);
|
||||
fetchLibraries(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (realVisible && currentPage !== 1) {
|
||||
fetchLibraries(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, realVisible, searchQuery]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!realVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchLibraries(1, searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, realVisible]);
|
||||
|
||||
// 获取内容库列表API
|
||||
const fetchLibraries = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
const response = await getContentLibraryList(params);
|
||||
if (response && response.list) {
|
||||
setLibraries(response.list);
|
||||
setTotalLibraries(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理内容库选择
|
||||
const handleLibraryToggle = (library: ContentItem) => {
|
||||
if (readonly) return;
|
||||
const newSelected = selectedOptions.some(c => c.id === library.id)
|
||||
? selectedOptions.filter(c => c.id !== library.id)
|
||||
: [...selectedOptions, library];
|
||||
onSelect(newSelected);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedOptions.length === 0) return "";
|
||||
return `已选择 ${selectedOptions.length} 个内容库`;
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(selectedOptions);
|
||||
}
|
||||
setRealVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选内容库列表窗口 */}
|
||||
{showSelectedList && selectedOptions.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedOptions.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{item.name || item.id}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveLibrary(item.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<Popup
|
||||
visible={realVisible && !readonly}
|
||||
onMaskClick={() => setRealVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择内容库"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索内容库"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalLibraries}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedOptions.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => setRealVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.libraryList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : libraries.length > 0 ? (
|
||||
<div className={style.libraryListInner}>
|
||||
{libraries.map(item => (
|
||||
<label key={item.id} className={style.libraryItem}>
|
||||
<Checkbox
|
||||
checked={selectedOptions.map(c => c.id).includes(item.id)}
|
||||
onChange={() => !readonly && handleLibraryToggle(item)}
|
||||
disabled={readonly}
|
||||
className={style.checkboxWrapper}
|
||||
/>
|
||||
<div className={style.libraryInfo}>
|
||||
<div className={style.libraryHeader}>
|
||||
<span className={style.libraryName}>{item.name}</span>
|
||||
<span className={style.typeTag}>
|
||||
{getTypeText(item.sourceType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.libraryMeta}>
|
||||
<div>创建人: {item.creatorName || "-"}</div>
|
||||
<div>更新时间: {formatDate(item.updateTime)}</div>
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className={style.libraryDesc}>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的内容库`
|
||||
: "没有找到内容库"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +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="w-full h-full max-w-none max-h-none flex flex-col bg-white"
|
||||
aria-describedby="device-selection-description"
|
||||
>
|
||||
<div id="device-selection-description" className="sr-only">
|
||||
请选择一个或多个设备,支持搜索和筛选。
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="flex-1 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
Cunkebao/src/components/DeviceSelection/api.ts
Normal file
10
Cunkebao/src/components/DeviceSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取设备列表
|
||||
export function getDeviceList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/devices", params, "GET");
|
||||
}
|
||||
26
Cunkebao/src/components/DeviceSelection/data.ts
Normal file
26
Cunkebao/src/components/DeviceSelection/data.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// 设备选择项接口
|
||||
export interface DeviceSelectionItem {
|
||||
id: number;
|
||||
memo: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
wxid?: string;
|
||||
nickname?: string;
|
||||
usedInPlans?: number;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface DeviceSelectionProps {
|
||||
selectedOptions: DeviceSelectionItem[];
|
||||
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
mode?: "input" | "dialog"; // 新增,默认input
|
||||
open?: boolean; // 仅mode=dialog时生效
|
||||
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
|
||||
selectedListMaxHeight?: number; // 新增,已选列表最大高度,默认500
|
||||
showInput?: boolean; // 新增
|
||||
showSelectedList?: boolean; // 新增
|
||||
readonly?: boolean; // 新增
|
||||
}
|
||||
182
Cunkebao/src/components/DeviceSelection/index.module.scss
Normal file
182
Cunkebao/src/components/DeviceSelection/index.module.scss
Normal file
@@ -0,0 +1,182 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 56px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.popupTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.popupSearchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.popupSearchInput {
|
||||
padding-left: 36px !important;
|
||||
border-radius: 12px !important;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.statusSelect {
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
font-size: 15px;
|
||||
padding: 0 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.deviceList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.deviceListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.deviceItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.deviceCheckbox {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.deviceInfo {
|
||||
flex: 1;
|
||||
}
|
||||
.deviceInfoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.deviceName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
}
|
||||
.statusOnline {
|
||||
width: 56px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.statusOffline {
|
||||
width: 56px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
background: #e5e6eb;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.deviceInfoDetail {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.refreshBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
margin: 0 8px;
|
||||
}
|
||||
139
Cunkebao/src/components/DeviceSelection/index.tsx
Normal file
139
Cunkebao/src/components/DeviceSelection/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from "react";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { DeviceSelectionProps } from "./data";
|
||||
import SelectionPopup from "./selectionPopup";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
placeholder = "选择设备",
|
||||
className = "",
|
||||
mode = "input",
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedListMaxHeight = 300, // 默认300
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
}) => {
|
||||
// 弹窗控制
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const isDialog = mode === "dialog";
|
||||
const realVisible = isDialog ? !!open : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (isDialog && onOpenChange) onOpenChange(v);
|
||||
if (!isDialog) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setRealVisible(true);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedOptions.length === 0) return "";
|
||||
return `已选择 ${selectedOptions.length} 个设备`;
|
||||
};
|
||||
|
||||
// 删除已选设备
|
||||
const handleRemoveDevice = (id: number) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedOptions.filter(v => v.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* mode=input 显示输入框,mode=dialog不显示 */}
|
||||
{mode === "input" && showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选设备列表窗口 */}
|
||||
{mode === "input" && showSelectedList && selectedOptions.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedOptions.map(device => (
|
||||
<div
|
||||
key={device.id}
|
||||
className={style.selectedListRow}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 8px",
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
【 {device.memo}】 - {device.wechatId}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveDevice(device.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<SelectionPopup
|
||||
visible={realVisible && !readonly}
|
||||
onClose={() => setRealVisible(false)}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceSelection;
|
||||
198
Cunkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
198
Cunkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Checkbox, Popup } from "antd-mobile";
|
||||
import { getDeviceList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import { DeviceSelectionItem } from "./data";
|
||||
|
||||
interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
selectedOptions: DeviceSelectionItem[];
|
||||
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
}) => {
|
||||
// 设备数据
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// 获取设备列表,支持keyword和分页
|
||||
const fetchDevices = useCallback(
|
||||
async (keyword: string = "", page: number = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getDeviceList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
keyword: keyword.trim() || undefined,
|
||||
});
|
||||
if (res && Array.isArray(res.list)) {
|
||||
setDevices(
|
||||
res.list.map((d: any) => ({
|
||||
id: d.id?.toString() || "",
|
||||
memo: d.memo || d.imei || "",
|
||||
imei: d.imei || "",
|
||||
wechatId: d.wechatId || "",
|
||||
status: d.alive === 1 ? "online" : "offline",
|
||||
wxid: d.wechatId || "",
|
||||
nickname: d.nickname || "",
|
||||
usedInPlans: d.usedInPlans || 0,
|
||||
})),
|
||||
);
|
||||
setTotal(res.total || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 打开弹窗时获取第一页
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchDevices("", 1);
|
||||
}
|
||||
}, [visible, fetchDevices]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchDevices(searchQuery, 1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible, fetchDevices]);
|
||||
|
||||
// 翻页时重新请求
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
fetchDevices(searchQuery, currentPage);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter(device => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
// 处理设备选择
|
||||
const handleDeviceToggle = (device: DeviceSelectionItem) => {
|
||||
if (selectedOptions.some(v => v.id === device.id)) {
|
||||
onSelect(selectedOptions.filter(v => v.id !== device.id));
|
||||
} else {
|
||||
const newSelectedOptions = [...selectedOptions, device];
|
||||
onSelect(newSelectedOptions);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择设备"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索设备IMEI/备注/微信号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchDevices(searchQuery, currentPage)}
|
||||
showTabs={true}
|
||||
tabsConfig={{
|
||||
activeKey: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
tabs: [
|
||||
{ title: "全部", key: "all" },
|
||||
{ title: "在线", key: "online" },
|
||||
{ title: "离线", key: "offline" },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={total}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedOptions.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={onClose}
|
||||
onConfirm={onClose}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.deviceList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.deviceListInner}>
|
||||
{filteredDevices.map(device => (
|
||||
<label key={device.id} className={style.deviceItem}>
|
||||
<Checkbox
|
||||
checked={selectedOptions.some(v => v.id === device.id)}
|
||||
onChange={() => handleDeviceToggle(device)}
|
||||
className={style.deviceCheckbox}
|
||||
/>
|
||||
<div className={style.deviceInfo}>
|
||||
<div className={style.deviceInfoRow}>
|
||||
<span className={style.deviceName}>{device.memo}</span>
|
||||
<div
|
||||
className={
|
||||
device.status === "online"
|
||||
? style.statusOnline
|
||||
: style.statusOffline
|
||||
}
|
||||
>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.deviceInfoDetail}>
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionPopup;
|
||||
@@ -1,234 +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="w-full h-full max-w-none max-h-none flex flex-col bg-white"
|
||||
aria-describedby="device-selection-dialog-description"
|
||||
>
|
||||
<div id="device-selection-dialog-description" className="sr-only">
|
||||
请选择一个或多个设备,支持搜索和筛选。
|
||||
</div>
|
||||
<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">
|
||||
{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 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,381 +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="w-full h-full max-w-none max-h-none flex flex-col p-0 gap-0 overflow-hidden bg-white"
|
||||
aria-describedby="friend-selection-description"
|
||||
>
|
||||
<div id="friend-selection-description" className="sr-only">
|
||||
请选择一个或多个微信好友,支持搜索和分页。
|
||||
</div>
|
||||
<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">
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
Cunkebao/src/components/FriendSelection/api.ts
Normal file
11
Cunkebao/src/components/FriendSelection/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取好友列表
|
||||
export function getFriendList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
deviceIds?: string; // 逗号分隔
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/friend", params, "GET");
|
||||
}
|
||||
27
Cunkebao/src/components/FriendSelection/data.ts
Normal file
27
Cunkebao/src/components/FriendSelection/data.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface FriendSelectionItem {
|
||||
id: number;
|
||||
wechatId: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface FriendSelectionProps {
|
||||
selectedOptions?: FriendSelectionItem[];
|
||||
onSelect: (friends: FriendSelectionItem[]) => void;
|
||||
deviceIds?: string[];
|
||||
enableDeviceFilter?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean; // 新增
|
||||
onVisibleChange?: (visible: boolean) => void; // 新增
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (
|
||||
selectedIds: number[],
|
||||
selectedItems: FriendSelectionItem[],
|
||||
) => void; // 新增
|
||||
}
|
||||
246
Cunkebao/src/components/FriendSelection/index.module.scss
Normal file
246
Cunkebao/src/components/FriendSelection/index.module.scss
Normal file
@@ -0,0 +1,246 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.selectedListRow {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.selectedListRowContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.selectedListRowContentText {
|
||||
flex: 1;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.friendList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.friendListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.friendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.radioWrapper {
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioSelected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioUnselected {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #e5e6eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.radioDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1890ff;
|
||||
}
|
||||
.friendInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.friendAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.friendDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.friendName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.friendCustomer {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.cancelBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
.confirmBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
140
Cunkebao/src/components/FriendSelection/index.tsx
Normal file
140
Cunkebao/src/components/FriendSelection/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import { Avatar } from "antd-mobile";
|
||||
import style from "./index.module.scss";
|
||||
import { FriendSelectionProps } from "./data";
|
||||
import SelectionPopup from "./selectionPopup";
|
||||
|
||||
export default function FriendSelection({
|
||||
selectedOptions = [],
|
||||
onSelect,
|
||||
deviceIds = [],
|
||||
enableDeviceFilter = true,
|
||||
placeholder = "选择微信好友",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: FriendSelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
// 内部弹窗交给 selectionPopup 处理
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setRealVisible(true);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (!selectedOptions || selectedOptions.length === 0) return "";
|
||||
return `已选择 ${selectedOptions.length} 个好友`;
|
||||
};
|
||||
|
||||
// 删除已选好友
|
||||
const handleRemoveFriend = (id: number) => {
|
||||
if (readonly) return;
|
||||
onSelect((selectedOptions || []).filter(v => v.id !== id));
|
||||
};
|
||||
|
||||
// 弹窗确认回调
|
||||
const handleConfirm = (
|
||||
selectedIds: number[],
|
||||
selectedItems: typeof selectedOptions,
|
||||
) => {
|
||||
onSelect(selectedItems);
|
||||
if (onConfirm) onConfirm(selectedIds, selectedItems);
|
||||
setRealVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选好友列表窗口 */}
|
||||
{showSelectedList && (selectedOptions || []).length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{(selectedOptions || []).map(friend => (
|
||||
<div key={friend.id} className={style.selectedListRow}>
|
||||
<div className={style.selectedListRowContent}>
|
||||
<Avatar src={friend.avatar} />
|
||||
<div className={style.selectedListRowContentText}>
|
||||
<div>{friend.nickname}</div>
|
||||
<div>{friend.wechatId}</div>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveFriend(friend.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<SelectionPopup
|
||||
visible={realVisible && !readonly}
|
||||
onVisibleChange={setRealVisible}
|
||||
selectedOptions={selectedOptions || []}
|
||||
onSelect={onSelect}
|
||||
deviceIds={deviceIds}
|
||||
enableDeviceFilter={enableDeviceFilter}
|
||||
readonly={readonly}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
213
Cunkebao/src/components/FriendSelection/selectionPopup.tsx
Normal file
213
Cunkebao/src/components/FriendSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Popup, Checkbox } from "antd-mobile";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import { getFriendList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import type { FriendSelectionItem } from "./data";
|
||||
|
||||
interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
selectedOptions: FriendSelectionItem[];
|
||||
onSelect: (friends: FriendSelectionItem[]) => void;
|
||||
deviceIds?: string[];
|
||||
enableDeviceFilter?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (
|
||||
selectedIds: number[],
|
||||
selectedItems: FriendSelectionItem[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
deviceIds = [],
|
||||
enableDeviceFilter = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [friends, setFriends] = useState<FriendSelectionItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalFriends, setTotalFriends] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取好友列表API
|
||||
const fetchFriends = useCallback(
|
||||
async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
|
||||
if (enableDeviceFilter && deviceIds.length > 0) {
|
||||
params.deviceIds = deviceIds.join(",");
|
||||
}
|
||||
|
||||
const response = await getFriendList(params);
|
||||
if (response && response.list) {
|
||||
setFriends(response.list);
|
||||
setTotalFriends(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取好友列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[deviceIds, enableDeviceFilter],
|
||||
);
|
||||
|
||||
// 处理好友选择
|
||||
const handleFriendToggle = (friend: FriendSelectionItem) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newSelectedFriends = selectedOptions.some(f => f.id === friend.id)
|
||||
? selectedOptions.filter(f => f.id !== friend.id)
|
||||
: selectedOptions.concat(friend);
|
||||
|
||||
onSelect(newSelectedFriends);
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(
|
||||
selectedOptions.map(v => v.id),
|
||||
selectedOptions,
|
||||
);
|
||||
}
|
||||
onVisibleChange(false);
|
||||
};
|
||||
|
||||
// 弹窗打开时初始化
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
fetchFriends(1, "");
|
||||
}
|
||||
}, [visible]); // 只在弹窗开启时请求
|
||||
|
||||
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||
useEffect(() => {
|
||||
if (!visible || searchQuery === "") return; // 弹窗关闭或搜索词为空时不请求
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchFriends(1, searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible]);
|
||||
|
||||
// 页码变化时请求数据(只在弹窗打开且页码不是1时执行)
|
||||
useEffect(() => {
|
||||
if (!visible || currentPage === 1) return; // 弹窗关闭或第一页时不请求
|
||||
fetchFriends(currentPage, searchQuery);
|
||||
}, [currentPage, visible, searchQuery]);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible && !readonly}
|
||||
onMaskClick={() => onVisibleChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择微信好友"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索好友"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchFriends(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalFriends}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedOptions.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => onVisibleChange(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.friendList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : friends.length > 0 ? (
|
||||
<div className={style.friendListInner}>
|
||||
{friends.map(friend => (
|
||||
<div key={friend.id} className={style.friendItem}>
|
||||
<Checkbox
|
||||
checked={selectedOptions.some(f => f.id === friend.id)}
|
||||
onChange={() => !readonly && handleFriendToggle(friend)}
|
||||
disabled={readonly}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<div className={style.friendInfo}>
|
||||
<div className={style.friendAvatar}>
|
||||
{friend.avatar ? (
|
||||
<img
|
||||
src={friend.avatar}
|
||||
alt={friend.nickname}
|
||||
className={style.avatarImg}
|
||||
/>
|
||||
) : (
|
||||
friend.nickname.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className={style.friendDetail}>
|
||||
<div className={style.friendName}>{friend.nickname}</div>
|
||||
<div className={style.friendId}>
|
||||
微信ID: {friend.wechatId}
|
||||
</div>
|
||||
{friend.customer && (
|
||||
<div className={style.friendCustomer}>
|
||||
归属客户: {friend.customer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{deviceIds.length === 0
|
||||
? "请先选择设备"
|
||||
: searchQuery
|
||||
? `没有找到包含"${searchQuery}"的好友`
|
||||
: "没有找到好友"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionPopup;
|
||||
@@ -1,343 +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="w-full h-full max-w-none max-h-none flex flex-col p-0 gap-0 overflow-hidden bg-white"
|
||||
aria-describedby="group-selection-description"
|
||||
>
|
||||
<div id="group-selection-description" className="sr-only">
|
||||
请选择一个或多个群聊,支持搜索和分页。
|
||||
</div>
|
||||
<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">
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
Cunkebao/src/components/GroupSelection/api.ts
Normal file
10
Cunkebao/src/components/GroupSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取群组列表
|
||||
export function getGroupList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/chatroom", params, "GET");
|
||||
}
|
||||
43
Cunkebao/src/components/GroupSelection/data.ts
Normal file
43
Cunkebao/src/components/GroupSelection/data.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// 群组接口类型
|
||||
export interface WechatGroup {
|
||||
id: string;
|
||||
chatroomId: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
ownerWechatId: string;
|
||||
ownerNickname: string;
|
||||
ownerAvatar: string;
|
||||
}
|
||||
|
||||
export interface GroupSelectionItem {
|
||||
id: string;
|
||||
avatar: string;
|
||||
chatroomId?: string;
|
||||
createTime?: number;
|
||||
identifier?: string;
|
||||
name: string;
|
||||
ownerAlias?: string;
|
||||
ownerAvatar?: string;
|
||||
ownerNickname?: string;
|
||||
ownerWechatId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export interface GroupSelectionProps {
|
||||
selectedOptions: GroupSelectionItem[];
|
||||
onSelect: (groups: GroupSelectionItem[]) => void;
|
||||
onSelectDetail?: (groups: WechatGroup[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
selectedListMaxHeight?: number;
|
||||
showInput?: boolean;
|
||||
showSelectedList?: boolean;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (
|
||||
selectedIds: string[],
|
||||
selectedItems: GroupSelectionItem[],
|
||||
) => void; // 新增
|
||||
}
|
||||
206
Cunkebao/src/components/GroupSelection/index.module.scss
Normal file
206
Cunkebao/src/components/GroupSelection/index.module.scss
Normal file
@@ -0,0 +1,206 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.selectedListRow {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.selectedListRowContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.selectedListRowContentText {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.groupList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.groupListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.groupItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.groupInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.groupAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.groupDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.groupName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.groupId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.groupOwner {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
126
Cunkebao/src/components/GroupSelection/index.tsx
Normal file
126
Cunkebao/src/components/GroupSelection/index.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useState } from "react";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import { Avatar } from "antd-mobile";
|
||||
import style from "./index.module.scss";
|
||||
import SelectionPopup from "./selectionPopup";
|
||||
import { GroupSelectionProps } from "./data";
|
||||
export default function GroupSelection({
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
placeholder = "选择群聊",
|
||||
className = "",
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedListMaxHeight = 300,
|
||||
showInput = true,
|
||||
showSelectedList = true,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: GroupSelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
|
||||
// 删除已选群聊
|
||||
const handleRemoveGroup = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedOptions.filter(g => g.id !== id));
|
||||
};
|
||||
|
||||
// 受控弹窗逻辑
|
||||
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||
const setRealVisible = (v: boolean) => {
|
||||
if (onVisibleChange) onVisibleChange(v);
|
||||
if (visible === undefined) setPopupVisible(v);
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const openPopup = () => {
|
||||
if (readonly) return;
|
||||
setRealVisible(true);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedOptions.length === 0) return "";
|
||||
return `已选择 ${selectedOptions.length} 个群聊`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
{showInput && (
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={getDisplayText()}
|
||||
onClick={openPopup}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear={!readonly}
|
||||
size="large"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
style={
|
||||
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 已选群聊列表窗口 */}
|
||||
{showSelectedList && selectedOptions.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedOptions.map(group => (
|
||||
<div key={group.id} className={style.selectedListRow}>
|
||||
<div className={style.selectedListRowContent}>
|
||||
<Avatar src={group.avatar} />
|
||||
<div className={style.selectedListRowContentText}>
|
||||
<div>{group.name}</div>
|
||||
<div>{group.chatroomId}</div>
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
marginLeft: 4,
|
||||
color: "#ff4d4f",
|
||||
border: "none",
|
||||
background: "none",
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={() => handleRemoveGroup(group.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<SelectionPopup
|
||||
visible={realVisible}
|
||||
onVisibleChange={setRealVisible}
|
||||
selectedOptions={selectedOptions}
|
||||
onSelect={onSelect}
|
||||
onSelectDetail={onSelectDetail}
|
||||
readonly={readonly}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
220
Cunkebao/src/components/GroupSelection/selectionPopup.tsx
Normal file
220
Cunkebao/src/components/GroupSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Popup, Checkbox } from "antd-mobile";
|
||||
|
||||
import { getGroupList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
import { GroupSelectionItem } from "./data";
|
||||
// 群组接口类型
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
chatroomId?: string;
|
||||
ownerWechatId?: string;
|
||||
ownerNickname?: string;
|
||||
ownerAvatar?: string;
|
||||
}
|
||||
|
||||
// 弹窗属性接口
|
||||
interface SelectionPopupProps {
|
||||
visible: boolean;
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
selectedOptions: GroupSelectionItem[];
|
||||
onSelect: (groups: GroupSelectionItem[]) => void;
|
||||
onSelectDetail?: (groups: WechatGroup[]) => void;
|
||||
readonly?: boolean;
|
||||
onConfirm?: (
|
||||
selectedIds: string[],
|
||||
selectedItems: GroupSelectionItem[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function SelectionPopup({
|
||||
visible,
|
||||
onVisibleChange,
|
||||
selectedOptions,
|
||||
onSelect,
|
||||
onSelectDetail,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}: SelectionPopupProps) {
|
||||
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);
|
||||
|
||||
// 获取群聊列表API
|
||||
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
|
||||
const response = await getGroupList(params);
|
||||
if (response && response.list) {
|
||||
setGroups(response.list);
|
||||
setTotalGroups(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取群聊列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理群聊选择
|
||||
const handleGroupToggle = (group: GroupSelectionItem) => {
|
||||
if (readonly) return;
|
||||
|
||||
const newSelectedGroups = selectedOptions.some(g => g.id === group.id)
|
||||
? selectedOptions.filter(g => g.id !== group.id)
|
||||
: selectedOptions.concat(group);
|
||||
|
||||
onSelect(newSelectedGroups);
|
||||
|
||||
// 如果有 onSelectDetail 回调,传递完整的群聊对象
|
||||
if (onSelectDetail) {
|
||||
const selectedGroupObjs = groups.filter(group =>
|
||||
newSelectedGroups.some(g => g.id === group.id),
|
||||
);
|
||||
onSelectDetail(selectedGroupObjs);
|
||||
}
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(
|
||||
selectedOptions.map(g => g.id),
|
||||
selectedOptions,
|
||||
);
|
||||
}
|
||||
onVisibleChange(false);
|
||||
};
|
||||
|
||||
// 弹窗打开时初始化数据(只执行一次)
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentPage(1);
|
||||
setSearchQuery("");
|
||||
fetchGroups(1, "");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||
useEffect(() => {
|
||||
if (!visible || searchQuery === "") return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, searchQuery);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible]);
|
||||
|
||||
// 页码变化时请求数据(只在弹窗打开且页码不是1时执行)
|
||||
useEffect(() => {
|
||||
if (!visible || currentPage === 1) return;
|
||||
fetchGroups(currentPage, searchQuery);
|
||||
}, [currentPage, visible, searchQuery]);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible && !readonly}
|
||||
onMaskClick={() => onVisibleChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择群聊"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索群聊"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchGroups(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalGroups}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedOptions.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => onVisibleChange(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.groupList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
<div className={style.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : groups.length > 0 ? (
|
||||
<div className={style.groupListInner}>
|
||||
{groups.map(group => (
|
||||
<div key={group.id} className={style.groupItem}>
|
||||
<Checkbox
|
||||
checked={selectedOptions.some(g => g.id === group.id)}
|
||||
onChange={() => !readonly && handleGroupToggle(group)}
|
||||
disabled={readonly}
|
||||
style={{ marginRight: 12 }}
|
||||
/>
|
||||
<div className={style.groupInfo}>
|
||||
<div className={style.groupAvatar}>
|
||||
{group.avatar ? (
|
||||
<img
|
||||
src={group.avatar}
|
||||
alt={group.name}
|
||||
className={style.avatarImg}
|
||||
/>
|
||||
) : (
|
||||
group.name.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div className={style.groupDetail}>
|
||||
<div className={style.groupName}>{group.name}</div>
|
||||
<div className={style.groupId}>
|
||||
群ID: {group.chatroomId}
|
||||
</div>
|
||||
{group.ownerNickname && (
|
||||
<div className={style.groupOwner}>
|
||||
群主: {group.ownerNickname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>
|
||||
{searchQuery
|
||||
? `没有找到包含"${searchQuery}"的群聊`
|
||||
: "没有找到群聊"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.listContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loadMoreButtonContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.noMoreText {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pullToRefresh {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 自定义滚动条样式
|
||||
.listContainer::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.listContainer {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.loadMoreButtonContainer {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.noMoreText {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
195
Cunkebao/src/components/InfiniteList/InfiniteList.tsx
Normal file
195
Cunkebao/src/components/InfiniteList/InfiniteList.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
PullToRefresh,
|
||||
InfiniteScroll,
|
||||
Button,
|
||||
SpinLoading,
|
||||
} from "antd-mobile";
|
||||
import styles from "./InfiniteList.module.scss";
|
||||
|
||||
interface InfiniteListProps<T> {
|
||||
// 数据相关
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
hasMore?: boolean;
|
||||
loadingText?: string;
|
||||
noMoreText?: string;
|
||||
|
||||
// 渲染相关
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
keyExtractor?: (item: T, index: number) => string | number;
|
||||
|
||||
// 事件回调
|
||||
onLoadMore?: () => Promise<void> | void;
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
|
||||
// 样式相关
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
containerStyle?: React.CSSProperties;
|
||||
|
||||
// 功能开关
|
||||
enablePullToRefresh?: boolean;
|
||||
enableInfiniteScroll?: boolean;
|
||||
enableLoadMoreButton?: boolean;
|
||||
|
||||
// 自定义高度
|
||||
height?: string | number;
|
||||
minHeight?: string | number;
|
||||
}
|
||||
|
||||
const InfiniteList = <T extends any>({
|
||||
data,
|
||||
loading = false,
|
||||
hasMore = true,
|
||||
loadingText = "加载中...",
|
||||
noMoreText = "没有更多了",
|
||||
|
||||
renderItem,
|
||||
keyExtractor = (_, index) => index,
|
||||
|
||||
onLoadMore,
|
||||
onRefresh,
|
||||
|
||||
className = "",
|
||||
itemClassName = "",
|
||||
containerStyle = {},
|
||||
|
||||
enablePullToRefresh = true,
|
||||
enableInfiniteScroll = true,
|
||||
enableLoadMoreButton = false,
|
||||
|
||||
height = "100%",
|
||||
minHeight = "200px",
|
||||
}: InfiniteListProps<T>) => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 处理下拉刷新
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!onRefresh) return;
|
||||
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
console.error("Refresh failed:", error);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [onRefresh]);
|
||||
|
||||
// 处理加载更多
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!onLoadMore || loadingMore || !hasMore) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
await onLoadMore();
|
||||
} catch (error) {
|
||||
console.error("Load more failed:", error);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [onLoadMore, loadingMore, hasMore]);
|
||||
|
||||
// 点击加载更多按钮
|
||||
const handleLoadMoreClick = useCallback(() => {
|
||||
handleLoadMore();
|
||||
}, [handleLoadMore]);
|
||||
|
||||
// 容器样式
|
||||
const containerStyles: React.CSSProperties = {
|
||||
height,
|
||||
minHeight,
|
||||
...containerStyle,
|
||||
};
|
||||
|
||||
// 渲染列表项
|
||||
const renderListItems = () => {
|
||||
return data.map((item, index) => (
|
||||
<div
|
||||
key={keyExtractor(item, index)}
|
||||
className={`${styles.listItem} ${itemClassName}`}
|
||||
>
|
||||
{renderItem(item, index)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// 渲染加载更多按钮
|
||||
const renderLoadMoreButton = () => {
|
||||
if (!enableLoadMoreButton || !hasMore) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.loadMoreButtonContainer}>
|
||||
<Button
|
||||
size="small"
|
||||
loading={loadingMore}
|
||||
onClick={handleLoadMoreClick}
|
||||
disabled={loading || !hasMore}
|
||||
>
|
||||
{loadingMore ? loadingText : "点击加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染无更多数据提示
|
||||
const renderNoMoreText = () => {
|
||||
if (hasMore || data.length === 0) return null;
|
||||
|
||||
return <div className={styles.noMoreText}>{noMoreText}</div>;
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
if (data.length > 0 || loading) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>📝</div>
|
||||
<div className={styles.emptyText}>暂无数据</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`${styles.listContainer} ${className}`}
|
||||
style={containerStyles}
|
||||
>
|
||||
{renderListItems()}
|
||||
{renderLoadMoreButton()}
|
||||
{renderNoMoreText()}
|
||||
{renderEmptyState()}
|
||||
|
||||
{/* 无限滚动组件 */}
|
||||
{enableInfiniteScroll && (
|
||||
<InfiniteScroll
|
||||
loadMore={handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
threshold={100}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果启用下拉刷新,包装PullToRefresh
|
||||
if (enablePullToRefresh && onRefresh) {
|
||||
return (
|
||||
<PullToRefresh
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
className={styles.pullToRefresh}
|
||||
>
|
||||
{content}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export default InfiniteList;
|
||||
@@ -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;
|
||||
52
Cunkebao/src/components/Layout/Layout.tsx
Normal file
52
Cunkebao/src/components/Layout/Layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { SpinLoading } from "antd-mobile";
|
||||
import styles from "./layout.module.scss";
|
||||
|
||||
interface LayoutProps {
|
||||
loading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({
|
||||
children,
|
||||
header,
|
||||
footer,
|
||||
loading = false,
|
||||
}) => {
|
||||
// 移动端100vh兼容
|
||||
useEffect(() => {
|
||||
const setRealHeight = () => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--real-vh",
|
||||
`${window.innerHeight * 0.01}px`,
|
||||
);
|
||||
};
|
||||
setRealHeight();
|
||||
window.addEventListener("resize", setRealHeight);
|
||||
return () => window.removeEventListener("resize", setRealHeight);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
style={{ height: "calc(var(--real-vh, 1vh) * 100)" }}
|
||||
>
|
||||
{header && <header>{header}</header>}
|
||||
<main>
|
||||
{loading ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
<div className={styles.loadingText}>加载中...</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</main>
|
||||
{footer && <footer>{footer}</footer>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
28
Cunkebao/src/components/Layout/layout.module.scss
Normal file
28
Cunkebao/src/components/Layout/layout.module.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
}
|
||||
|
||||
.container main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
53
Cunkebao/src/components/LineChart.tsx
Normal file
53
Cunkebao/src/components/LineChart.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
|
||||
interface LineChartProps {
|
||||
title?: string;
|
||||
xData: string[];
|
||||
yData: number[];
|
||||
height?: number | string;
|
||||
}
|
||||
|
||||
const LineChart: React.FC<LineChartProps> = ({
|
||||
title = "",
|
||||
xData,
|
||||
yData,
|
||||
height = 200,
|
||||
}) => {
|
||||
const option = {
|
||||
title: {
|
||||
text: title,
|
||||
left: "center",
|
||||
textStyle: { fontSize: 16 },
|
||||
},
|
||||
tooltip: { trigger: "axis" },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: xData,
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
boundaryGap: ["10%", "10%"], // 上下留白
|
||||
min: (value: any) => value.min - 10, // 下方多留一点空间
|
||||
max: (value: any) => value.max + 10, // 上方多留一点空间
|
||||
minInterval: 1,
|
||||
axisLabel: { margin: 12 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: yData,
|
||||
type: "line",
|
||||
smooth: true,
|
||||
symbol: "circle",
|
||||
lineStyle: { color: "#1677ff" },
|
||||
itemStyle: { color: "#1677ff" },
|
||||
},
|
||||
],
|
||||
grid: { left: 40, right: 24, top: 40, bottom: 32 },
|
||||
};
|
||||
|
||||
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
|
||||
};
|
||||
|
||||
export default LineChart;
|
||||
57
Cunkebao/src/components/MeauMobile/MeauMoible.tsx
Normal file
57
Cunkebao/src/components/MeauMobile/MeauMoible.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { TabBar } from "antd-mobile";
|
||||
import { PieOutline, UserOutline } from "antd-mobile-icons";
|
||||
import { HomeOutlined, TeamOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: "home",
|
||||
title: "首页",
|
||||
icon: <HomeOutlined />,
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
key: "scenarios",
|
||||
title: "场景获客",
|
||||
icon: <TeamOutlined />,
|
||||
path: "/scenarios",
|
||||
},
|
||||
{
|
||||
key: "workspace",
|
||||
title: "工作台",
|
||||
icon: <PieOutline />,
|
||||
path: "/workspace",
|
||||
},
|
||||
{
|
||||
key: "mine",
|
||||
title: "我的",
|
||||
icon: <UserOutline />,
|
||||
path: "/mine",
|
||||
},
|
||||
];
|
||||
|
||||
interface MeauMobileProps {
|
||||
activeKey: string;
|
||||
}
|
||||
|
||||
const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<TabBar
|
||||
style={{ background: "#fff" }}
|
||||
activeKey={activeKey}
|
||||
onChange={key => {
|
||||
const tab = tabs.find(t => t.key === key);
|
||||
if (tab && tab.path) navigate(tab.path);
|
||||
}}
|
||||
>
|
||||
{tabs.map(item => (
|
||||
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
|
||||
))}
|
||||
</TabBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeauMobile;
|
||||
62
Cunkebao/src/components/NavCommon/index.tsx
Normal file
62
Cunkebao/src/components/NavCommon/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { NavBar } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getSafeAreaHeight } from "@/utils/common";
|
||||
interface NavCommonProps {
|
||||
title: string;
|
||||
backFn?: () => void;
|
||||
right?: React.ReactNode;
|
||||
left?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavCommon: React.FC<NavCommonProps> = ({
|
||||
title,
|
||||
backFn,
|
||||
right,
|
||||
left,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [paddingTop, setPaddingTop] = useState("0px");
|
||||
useEffect(() => {
|
||||
setPaddingTop(getSafeAreaHeight() + "px");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
paddingTop: paddingTop,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<NavBar
|
||||
back={null}
|
||||
left={
|
||||
left ? (
|
||||
left
|
||||
) : (
|
||||
<div className="nav-title">
|
||||
<ArrowLeftOutlined
|
||||
twoToneColor="#1677ff"
|
||||
onClick={() => {
|
||||
if (backFn) {
|
||||
backFn();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
right={right}
|
||||
>
|
||||
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||
{title}
|
||||
</span>
|
||||
</NavBar>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavCommon;
|
||||
@@ -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;
|
||||
56
Cunkebao/src/components/PlaceholderPage.tsx
Normal file
56
Cunkebao/src/components/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { NavBar, Button } from "antd-mobile";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
showAddButton?: boolean;
|
||||
addButtonText?: string;
|
||||
showFooter?: boolean;
|
||||
}
|
||||
|
||||
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({
|
||||
title,
|
||||
showBack = true,
|
||||
showAddButton = false,
|
||||
addButtonText = "新建",
|
||||
showFooter = true,
|
||||
}) => {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavBar
|
||||
backArrow={showBack}
|
||||
style={{ background: "#fff" }}
|
||||
onBack={showBack ? () => window.history.back() : undefined}
|
||||
left={
|
||||
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||
{title}
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
showAddButton ? (
|
||||
<Button size="small" color="primary">
|
||||
<PlusOutlined />
|
||||
<span style={{ marginLeft: 4, fontSize: 12 }}>
|
||||
{addButtonText}
|
||||
</span>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={showFooter ? <MeauMobile /> : undefined}
|
||||
>
|
||||
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
|
||||
<h3>{title}页面</h3>
|
||||
<p>此页面正在开发中...</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderPage;
|
||||
71
Cunkebao/src/components/PopuLayout/footer.module.scss
Normal file
71
Cunkebao/src/components/PopuLayout/footer.module.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f5f5f5;
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
margin: 0 8px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
67
Cunkebao/src/components/PopuLayout/footer.tsx
Normal file
67
Cunkebao/src/components/PopuLayout/footer.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { Button } from "antd";
|
||||
import style from "./footer.module.scss";
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
|
||||
|
||||
interface PopupFooterProps {
|
||||
total: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
loading: boolean;
|
||||
selectedCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
total,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
selectedCount,
|
||||
onPageChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 分页栏 */}
|
||||
<div className={style.paginationRow}>
|
||||
<div className={style.totalCount}>总计 {total} 条记录</div>
|
||||
<div className={style.paginationControls}>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
</Button>
|
||||
<span className={style.pageInfo}>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.popupFooter}>
|
||||
<div className={style.selectedCount}>已选择 {selectedCount} 条记录</div>
|
||||
<div className={style.footerBtnGroup}>
|
||||
<Button color="primary" variant="filled" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={onConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupFooter;
|
||||
51
Cunkebao/src/components/PopuLayout/header.module.scss
Normal file
51
Cunkebao/src/components/PopuLayout/header.module.scss
Normal file
@@ -0,0 +1,51 @@
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.popupTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popupSearchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.refreshBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.loadingIcon {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
86
Cunkebao/src/components/PopuLayout/header.tsx
Normal file
86
Cunkebao/src/components/PopuLayout/header.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import { Tabs } from "antd-mobile";
|
||||
import style from "./header.module.scss";
|
||||
|
||||
interface PopupHeaderProps {
|
||||
title: string;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
showRefresh?: boolean;
|
||||
showSearch?: boolean;
|
||||
showTabs?: boolean;
|
||||
tabsConfig?: {
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
tabs: Array<{ title: string; key: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const PopupHeader: React.FC<PopupHeaderProps> = ({
|
||||
title,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchPlaceholder = "搜索...",
|
||||
loading = false,
|
||||
onRefresh,
|
||||
showRefresh = true,
|
||||
showSearch = true,
|
||||
showTabs = false,
|
||||
tabsConfig,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={style.popupHeader}>
|
||||
<div className={style.popupTitle}>{title}</div>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<div className={style.popupSearchRow}>
|
||||
<div className={style.popupSearchInputWrap}>
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showRefresh && onRefresh && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className={style.refreshBtn}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={style.loadingIcon}>⟳</div>
|
||||
) : (
|
||||
<ReloadOutlined />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTabs && tabsConfig && (
|
||||
<Tabs
|
||||
activeKey={tabsConfig.activeKey}
|
||||
onChange={tabsConfig.onChange}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{tabsConfig.tabs.map(tab => (
|
||||
<Tabs.Tab key={tab.key} title={tab.title} />
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupHeader;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
43
Cunkebao/src/components/StepIndicator/index.tsx
Normal file
43
Cunkebao/src/components/StepIndicator/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Steps } from "antd-mobile";
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
steps: { id: number; title: string; subtitle: string }[];
|
||||
}
|
||||
|
||||
const StepIndicator: React.FC<StepIndicatorProps> = ({
|
||||
currentStep,
|
||||
steps,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
|
||||
<Steps current={currentStep - 1}>
|
||||
{steps.map((step, idx) => (
|
||||
<Steps.Step
|
||||
key={step.id}
|
||||
title={step.subtitle}
|
||||
icon={
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{step.id}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepIndicator;
|
||||
@@ -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;
|
||||
180
Cunkebao/src/components/UpdateNotification/index.tsx
Normal file
180
Cunkebao/src/components/UpdateNotification/index.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "antd-mobile";
|
||||
import { updateChecker } from "@/utils/updateChecker";
|
||||
import {
|
||||
ReloadOutlined,
|
||||
CloudDownloadOutlined,
|
||||
RocketOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
position?: "top" | "bottom";
|
||||
autoReload?: boolean;
|
||||
showToast?: boolean;
|
||||
}
|
||||
|
||||
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
|
||||
position = "top",
|
||||
autoReload = false,
|
||||
showToast = true,
|
||||
}) => {
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 注册更新检测回调
|
||||
const handleUpdate = (info: { hasUpdate: boolean }) => {
|
||||
if (info.hasUpdate) {
|
||||
setHasUpdate(true);
|
||||
setIsVisible(true);
|
||||
|
||||
if (autoReload) {
|
||||
// 自动刷新
|
||||
setTimeout(() => {
|
||||
updateChecker.forceReload();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateChecker.onUpdate(handleUpdate);
|
||||
|
||||
// 启动更新检测
|
||||
updateChecker.start();
|
||||
|
||||
return () => {
|
||||
updateChecker.offUpdate(handleUpdate);
|
||||
updateChecker.stop();
|
||||
};
|
||||
}, [autoReload, showToast]);
|
||||
const handleReload = () => {
|
||||
updateChecker.forceReload();
|
||||
};
|
||||
|
||||
if (!isVisible || !hasUpdate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 99999,
|
||||
background: "linear-gradient(135deg, #1890ff 0%, #096dd9 100%)",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "20px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{/* 背景装饰 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10%",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
fontSize: "120px",
|
||||
opacity: 0.1,
|
||||
animation: "float 3s ease-in-out infinite",
|
||||
}}
|
||||
>
|
||||
<RocketOutlined />
|
||||
</div>
|
||||
|
||||
{/* 主要内容 */}
|
||||
<div style={{ position: "relative", zIndex: 1 }}>
|
||||
{/* 图标 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "80px",
|
||||
marginBottom: "20px",
|
||||
animation: "pulse 2s ease-in-out infinite",
|
||||
}}
|
||||
>
|
||||
<CloudDownloadOutlined />
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "28px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "12px",
|
||||
textShadow: "0 2px 4px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
发现新版本
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
opacity: 0.9,
|
||||
marginBottom: "40px",
|
||||
lineHeight: "1.5",
|
||||
maxWidth: "300px",
|
||||
}}
|
||||
>
|
||||
为了给您提供更好的体验,请更新到最新版本
|
||||
</div>
|
||||
|
||||
{/* 更新按钮 */}
|
||||
<Button
|
||||
size="large"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
border: "2px solid rgba(255,255,255,0.5)",
|
||||
color: "#1890ff",
|
||||
fontSize: "18px",
|
||||
fontWeight: "bold",
|
||||
padding: "12px 40px",
|
||||
borderRadius: "50px",
|
||||
backdropFilter: "blur(10px)",
|
||||
boxShadow: "0 8px 32px rgba(24,144,255,0.3)",
|
||||
transition: "all 0.3s ease",
|
||||
}}
|
||||
onClick={handleReload}
|
||||
>
|
||||
<ReloadOutlined style={{ marginRight: "8px" }} />
|
||||
立即更新
|
||||
</Button>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
opacity: 0.7,
|
||||
marginTop: "20px",
|
||||
}}
|
||||
>
|
||||
更新将自动重启应用
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 动画样式 */}
|
||||
<style>
|
||||
{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateX(-50%) translateY(0px); }
|
||||
50% { transform: translateX(-50%) translateY(-20px); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateNotification;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user