同步代码

This commit is contained in:
超级老白兔
2025-08-12 09:27:50 +08:00
parent dd4446fd8c
commit 9c3bc5200a
421 changed files with 54063 additions and 53044 deletions

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

@@ -0,0 +1,3 @@
# 基础环境变量示例
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=存客宝

64
Cunkebao/.eslintrc.js Normal file
View 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
View 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
View File

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

View File

@@ -0,0 +1,8 @@
{
"hash": "efe0acf4",
"configHash": "2bed34b3",
"lockfileHash": "ef01d341",
"browserHash": "91bd3b2c",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

11
Cunkebao/.vscode/extensions.json vendored Normal file
View 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
View 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
}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

19
Cunkebao/index.html Normal file
View 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>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

308
Cunkebao/public/websdk.js Normal file
View 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);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 || "文件上传失败");
}
}

View File

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

View File

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

View File

@@ -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',
},
];
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '获取小程序二维码失败' };
}
};

View File

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

View File

@@ -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 || '图片上传失败');
}

View File

@@ -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 || '文件上传失败');
}
}

View File

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

View 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");
}

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

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

View 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}
/>
</>
);
}

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import request from "@/api/request";
export function getContentLibraryList(params: any) {
return request("/v1/content/library/list", params, "GET");
}

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

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

View 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>
</>
);
}

View File

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

View 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");
}

View 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; // 新增
}

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

View 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;

View 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;

View File

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

View File

@@ -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"
>
&lt;
</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"
>
&gt;
</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>
</>
);
}

View 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");
}

View 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; // 新增
}

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

View 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}
/>
</>
);
}

View 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;

View File

@@ -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"
>
&lt;
</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"
>
&gt;
</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>
</>
);
}

View 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");
}

View 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; // 新增
}

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

View 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}
/>
</>
);
}

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

View File

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

View 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;

View File

@@ -1,10 +0,0 @@
.container {
display: flex;
height: 100vh;
flex-direction: column;
}
.container main {
flex: 1;
overflow: auto;
}

View File

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

View 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;

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

View File

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

View 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;

View 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;

View 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;

View File

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

View 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;

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

View 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;

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

View 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;

View File

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

View File

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

View 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;

View File

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

View File

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

View File

@@ -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"
>
&lt;
</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"
>
&gt;
</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>
</>
);
}

View File

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

View 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