同步代码
This commit is contained in:
4
Cunkebao/.env.development
Normal file
4
Cunkebao/.env.development
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# 基础环境变量示例
|
||||||
|
# VITE_API_BASE_URL=http://www.yishi.com
|
||||||
|
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||||
|
VITE_APP_TITLE=存客宝
|
||||||
3
Cunkebao/.env.production
Normal file
3
Cunkebao/.env.production
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 基础环境变量示例
|
||||||
|
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
|
||||||
|
VITE_APP_TITLE=存客宝
|
||||||
64
Cunkebao/.eslintrc.js
Normal file
64
Cunkebao/.eslintrc.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:prettier/recommended", // 这个配置会自动处理大部分冲突
|
||||||
|
],
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
ecmaVersion: 12,
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
plugins: ["react", "react-hooks", "@typescript-eslint", "prettier"],
|
||||||
|
rules: {
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"linebreak-style": "off",
|
||||||
|
"eol-last": "off",
|
||||||
|
"no-empty": "warn",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
// 确保与 Prettier 完全兼容
|
||||||
|
"comma-dangle": "off",
|
||||||
|
"comma-spacing": "off",
|
||||||
|
"comma-style": "off",
|
||||||
|
"object-curly-spacing": "off",
|
||||||
|
"array-bracket-spacing": "off",
|
||||||
|
indent: "off",
|
||||||
|
quotes: "off",
|
||||||
|
semi: "off",
|
||||||
|
"arrow-parens": "off",
|
||||||
|
"no-multiple-empty-lines": "off",
|
||||||
|
"max-len": "off",
|
||||||
|
"space-before-function-paren": "off",
|
||||||
|
"space-before-blocks": "off",
|
||||||
|
"keyword-spacing": "off",
|
||||||
|
"space-infix-ops": "off",
|
||||||
|
"space-in-parens": "off",
|
||||||
|
"space-in-brackets": "off",
|
||||||
|
"object-property-newline": "off",
|
||||||
|
"array-element-newline": "off",
|
||||||
|
"function-paren-newline": "off",
|
||||||
|
"object-curly-newline": "off",
|
||||||
|
"array-bracket-newline": "off",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
27
Cunkebao/.gitattributes
vendored
Normal file
27
Cunkebao/.gitattributes
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 设置默认行为,如果core.autocrlf没有设置,Git会自动处理行尾符
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# 明确指定文本文件使用LF
|
||||||
|
*.js text eol=lf
|
||||||
|
*.jsx text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.scss text eol=lf
|
||||||
|
*.html text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
|
||||||
|
# 二进制文件
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.svg binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
29
Cunkebao/.gitignore
vendored
29
Cunkebao/.gitignore
vendored
@@ -1,23 +1,6 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
node_modules/
|
||||||
|
dist/
|
||||||
# dependencies
|
build/
|
||||||
/node_modules
|
yarn.lock
|
||||||
/.pnp
|
.env
|
||||||
.pnp.js
|
.DS_Store
|
||||||
|
|
||||||
# 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*
|
|
||||||
13
Cunkebao/.prettierrc
Normal file
13
Cunkebao/.prettierrc
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"quoteProps": "as-needed"
|
||||||
|
}
|
||||||
8
Cunkebao/.vite/deps/_metadata.json
Normal file
8
Cunkebao/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "efe0acf4",
|
||||||
|
"configHash": "2bed34b3",
|
||||||
|
"lockfileHash": "ef01d341",
|
||||||
|
"browserHash": "91bd3b2c",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
Cunkebao/.vite/deps/package.json
Normal file
3
Cunkebao/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"ms-vscode.vscode-typescript-next",
|
||||||
|
"formulahendry.auto-rename-tag",
|
||||||
|
"christian-kohler.path-intellisense",
|
||||||
|
"ms-vscode.vscode-json"
|
||||||
|
]
|
||||||
|
}
|
||||||
45
Cunkebao/.vscode/settings.json
vendored
Normal file
45
Cunkebao/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"files.eol": "\n",
|
||||||
|
"files.insertFinalNewline": true,
|
||||||
|
"files.trimFinalNewlines": true,
|
||||||
|
"files.trimTrailingWhitespace": true,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.organizeImports": "never"
|
||||||
|
},
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact"
|
||||||
|
],
|
||||||
|
"eslint.format.enable": false,
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[scss]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[css]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"typescript.preferences.importModuleSpecifier": "relative",
|
||||||
|
"typescript.suggest.autoImports": true,
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"editor.detectIndentation": false
|
||||||
|
}
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
# 内客宝 - 智能获客管理平台
|
|
||||||
|
|
||||||
## 📋 项目简介
|
|
||||||
|
|
||||||
内客宝是一个专业的微信获客和流量管理平台,基于 React 技术栈构建。平台提供智能化的客户获取、管理和运营解决方案,集成了多种自动化工具,帮助企业高效管理存客宝活动。
|
|
||||||
|
|
||||||
## 🚀 技术栈详解
|
|
||||||
|
|
||||||
### 核心框架
|
|
||||||
|
|
||||||
- **React 18.2.0** - 现代化的用户界面库
|
|
||||||
- **TypeScript 4.9.5** - 类型安全的 JavaScript 超集
|
|
||||||
- **Create React App (CRA) 5.0.1** - React 应用脚手架
|
|
||||||
- **React Router DOM 6.20.0** - 客户端路由管理
|
|
||||||
|
|
||||||
### 构建工具
|
|
||||||
|
|
||||||
- **CRACO 7.1.0** - Create React App Configuration Override
|
|
||||||
- 支持自定义 webpack 配置
|
|
||||||
- 路径别名配置
|
|
||||||
- 构建优化
|
|
||||||
|
|
||||||
### UI 组件库
|
|
||||||
|
|
||||||
- **Radix UI** - 无样式的可访问组件库
|
|
||||||
- 完整的组件生态系统(30+ 组件)
|
|
||||||
- 优秀的无障碍访问支持
|
|
||||||
- 高度可定制
|
|
||||||
- **Tailwind CSS 3.4.17** - 实用优先的 CSS 框架
|
|
||||||
- 响应式设计支持
|
|
||||||
- 自定义主题配置
|
|
||||||
- 原子化 CSS 类
|
|
||||||
|
|
||||||
### 图标和样式
|
|
||||||
|
|
||||||
- **Lucide React 0.454.0** - 精美的图标库
|
|
||||||
- **Tailwind CSS Animate** - CSS 动画库
|
|
||||||
- **Class Variance Authority** - 组件变体管理
|
|
||||||
- **Tailwind Merge** - Tailwind 类名合并工具
|
|
||||||
|
|
||||||
### 状态管理和表单
|
|
||||||
|
|
||||||
- **React Hook Form 7.54.1** - 高性能表单库
|
|
||||||
- **Zod 3.24.1** - TypeScript 优先的模式验证
|
|
||||||
- **@hookform/resolvers 3.9.1** - 表单验证解析器
|
|
||||||
|
|
||||||
### 数据可视化
|
|
||||||
|
|
||||||
- **Recharts** - 基于 React 的图表库
|
|
||||||
- **Chart.js 4.5.0** - 灵活的图表库
|
|
||||||
- **@ant-design/plots** - Ant Design 图表组件
|
|
||||||
|
|
||||||
### HTTP 请求和数据处理
|
|
||||||
|
|
||||||
- **Axios 1.6.0** - HTTP 客户端
|
|
||||||
- **Crypto-js 4.2.0** - 加密库
|
|
||||||
- **Date-fns** - 日期处理库
|
|
||||||
- **XLSX 0.18.5** - Excel 文件处理
|
|
||||||
|
|
||||||
### 通知和反馈
|
|
||||||
|
|
||||||
- **React Hot Toast 2.5.2** - 轻量级通知库
|
|
||||||
- **Sonner 1.7.4** - 现代化 Toast 组件
|
|
||||||
|
|
||||||
### 高级组件
|
|
||||||
|
|
||||||
- **@tanstack/react-table** - 功能强大的表格组件
|
|
||||||
- **Embla Carousel React 8.5.1** - 轮播组件
|
|
||||||
- **React Resizable Panels 2.1.7** - 可调整大小的面板
|
|
||||||
- **Vaul 0.9.6** - 抽屉组件
|
|
||||||
- **Input OTP 1.4.1** - OTP 输入组件
|
|
||||||
- **React Day Picker** - 日期选择器
|
|
||||||
|
|
||||||
### 开发工具
|
|
||||||
|
|
||||||
- **PostCSS 8** - CSS 后处理器
|
|
||||||
- **Autoprefixer 10.4.20** - CSS 前缀自动添加
|
|
||||||
- **ESLint** - 代码质量检查
|
|
||||||
- **Jest** - 单元测试框架
|
|
||||||
- **Testing Library** - React 测试工具
|
|
||||||
|
|
||||||
## 📁 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
nkebao/
|
|
||||||
├── public/ # 静态资源
|
|
||||||
├── src/ # 源代码
|
|
||||||
│ ├── api/ # API 接口封装
|
|
||||||
│ ├── components/ # 全局组件
|
|
||||||
│ │ ├── ui/ # UI 基础组件
|
|
||||||
│ │ └── icons/ # 图标组件
|
|
||||||
│ ├── config/ # 配置文件
|
|
||||||
│ ├── contexts/ # React Context
|
|
||||||
│ ├── hooks/ # 自定义 Hooks
|
|
||||||
│ ├── pages/ # 页面组件
|
|
||||||
│ │ ├── workspace/ # 工作台模块
|
|
||||||
│ │ │ ├── auto-like/ # 自动点赞
|
|
||||||
│ │ │ ├── auto-group/ # 自动建群
|
|
||||||
│ │ │ ├── group-push/ # 群消息推送
|
|
||||||
│ │ │ ├── moments-sync/ # 朋友圈同步
|
|
||||||
│ │ │ ├── ai-assistant/ # AI 对话助手
|
|
||||||
│ │ │ └── traffic-distribution/ # 流量分发
|
|
||||||
│ │ ├── devices/ # 设备管理
|
|
||||||
│ │ ├── scenarios/ # 场景管理
|
|
||||||
│ │ ├── content/ # 内容管理
|
|
||||||
│ │ └── ...
|
|
||||||
│ ├── types/ # TypeScript 类型定义
|
|
||||||
│ ├── utils/ # 工具函数
|
|
||||||
│ ├── App.tsx # 应用根组件
|
|
||||||
│ └── index.tsx # 应用入口
|
|
||||||
├── craco.config.js # CRACO 配置
|
|
||||||
├── tailwind.config.js # Tailwind CSS 配置
|
|
||||||
├── tsconfig.json # TypeScript 配置
|
|
||||||
└── package.json # 项目依赖
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 核心功能模块
|
|
||||||
|
|
||||||
### 工作台 (Workspace)
|
|
||||||
|
|
||||||
- **自动点赞** - 智能点赞管理和配置
|
|
||||||
- **自动建群** - 群组自动化创建和管理
|
|
||||||
- **群消息推送** - 群组消息批量发送
|
|
||||||
- **朋友圈同步** - 内容同步和发布
|
|
||||||
- **AI 对话助手** - 智能客服和对话管理
|
|
||||||
- **流量分发** - 流量分配和策略管理
|
|
||||||
|
|
||||||
### 设备管理 (Devices)
|
|
||||||
|
|
||||||
- 设备状态监控和配置
|
|
||||||
- 设备性能分析
|
|
||||||
- 设备权限管理
|
|
||||||
|
|
||||||
### 场景管理 (Scenarios)
|
|
||||||
|
|
||||||
- 营销场景配置
|
|
||||||
- 自动化流程设计
|
|
||||||
- 场景效果分析
|
|
||||||
|
|
||||||
### 内容管理 (Content)
|
|
||||||
|
|
||||||
- 内容创建与编辑
|
|
||||||
- 内容模板管理
|
|
||||||
- 内容发布调度
|
|
||||||
|
|
||||||
### 其他模块
|
|
||||||
|
|
||||||
- 用户管理 (Users)
|
|
||||||
- 订单管理 (Orders)
|
|
||||||
- 流量池管理 (Traffic Pool)
|
|
||||||
- 联系人导入 (Contact Import)
|
|
||||||
|
|
||||||
## 🛠️ 开发指南
|
|
||||||
|
|
||||||
### 环境要求
|
|
||||||
|
|
||||||
- **Node.js** 16+
|
|
||||||
- **npm** 或 **yarn**
|
|
||||||
|
|
||||||
### 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 npm
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 使用 yarn
|
|
||||||
yarn install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 开发环境启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 npm
|
|
||||||
npm start
|
|
||||||
|
|
||||||
# 使用 yarn
|
|
||||||
yarn start
|
|
||||||
```
|
|
||||||
|
|
||||||
### 构建生产版本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 npm
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 使用 yarn
|
|
||||||
yarn build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 运行测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 npm
|
|
||||||
npm test
|
|
||||||
|
|
||||||
# 使用 yarn
|
|
||||||
yarn test
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 配置说明
|
|
||||||
|
|
||||||
### 路径别名配置
|
|
||||||
|
|
||||||
项目使用 CRACO 配置了路径别名:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
'@': path.resolve(__dirname, 'src'),
|
|
||||||
'@/components': path.resolve(__dirname, 'src/components'),
|
|
||||||
'@/api': path.resolve(__dirname, 'src/api'),
|
|
||||||
'@/types': path.resolve(__dirname, 'src/types'),
|
|
||||||
'@/hooks': path.resolve(__dirname, 'src/hooks'),
|
|
||||||
'@/utils': path.resolve(__dirname, 'src/utils'),
|
|
||||||
'@/styles': path.resolve(__dirname, 'src/styles'),
|
|
||||||
'@/pages': path.resolve(__dirname, 'src/pages'),
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tailwind CSS 配置
|
|
||||||
|
|
||||||
- 自定义字体大小和间距
|
|
||||||
- 响应式断点配置
|
|
||||||
- 主题颜色系统
|
|
||||||
|
|
||||||
### TypeScript 配置
|
|
||||||
|
|
||||||
- 严格模式启用
|
|
||||||
- 路径映射配置
|
|
||||||
- JSX 支持
|
|
||||||
|
|
||||||
## 📱 响应式设计
|
|
||||||
|
|
||||||
项目采用移动优先的响应式设计:
|
|
||||||
|
|
||||||
- 支持桌面端、平板端、移动端
|
|
||||||
- 自适应布局组件
|
|
||||||
- 触摸友好的交互设计
|
|
||||||
|
|
||||||
## 🎨 UI 设计系统
|
|
||||||
|
|
||||||
### 设计原则
|
|
||||||
|
|
||||||
- 简洁现代的设计风格
|
|
||||||
- 一致的用户体验
|
|
||||||
- 无障碍访问支持
|
|
||||||
|
|
||||||
### 组件库特点
|
|
||||||
|
|
||||||
- 基于 Radix UI 的高质量组件
|
|
||||||
- 完整的表单组件系统
|
|
||||||
- 数据展示组件
|
|
||||||
- 导航和布局组件
|
|
||||||
|
|
||||||
## 🔒 安全特性
|
|
||||||
|
|
||||||
- 身份验证和授权
|
|
||||||
- API 请求拦截
|
|
||||||
- 数据验证和清理
|
|
||||||
- 加密功能支持
|
|
||||||
|
|
||||||
## 📊 性能优化
|
|
||||||
|
|
||||||
- 代码分割和懒加载
|
|
||||||
- 组件优化
|
|
||||||
- 缓存策略
|
|
||||||
- 包大小优化
|
|
||||||
|
|
||||||
## 🧪 测试策略
|
|
||||||
|
|
||||||
- 单元测试 (Jest + Testing Library)
|
|
||||||
- 组件测试
|
|
||||||
- 集成测试支持
|
|
||||||
|
|
||||||
## 🤝 贡献指南
|
|
||||||
|
|
||||||
1. Fork 项目
|
|
||||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
|
||||||
5. 创建 Pull Request
|
|
||||||
|
|
||||||
## 📄 许可证
|
|
||||||
|
|
||||||
本项目采用 MIT 许可证。
|
|
||||||
|
|
||||||
## 📞 联系方式
|
|
||||||
|
|
||||||
如有问题或建议,请联系开发团队。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**项目名称**: 内客宝 (nkebao2)
|
|
||||||
**版本**: 0.1.0
|
|
||||||
**技术栈**: React + TypeScript + CRA + Tailwind CSS
|
|
||||||
**最后更新**: 2024 年 12 月
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
webpack: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, 'src'),
|
|
||||||
'@/components': path.resolve(__dirname, 'src/components'),
|
|
||||||
'@/api': path.resolve(__dirname, 'src/api'),
|
|
||||||
'@/types': path.resolve(__dirname, 'src/types'),
|
|
||||||
'@/hooks': path.resolve(__dirname, 'src/hooks'),
|
|
||||||
'@/utils': path.resolve(__dirname, 'src/utils'),
|
|
||||||
'@/styles': path.resolve(__dirname, 'src/styles'),
|
|
||||||
'@/pages': path.resolve(__dirname, 'src/pages'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
95
Cunkebao/devlop.py
Normal file
95
Cunkebao/devlop.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
local_dir = './dist' # 本地要打包的目录
|
||||||
|
zip_name = 'dist.zip'
|
||||||
|
# 上传到服务器的 zip 路径
|
||||||
|
remote_path = '/www/wwwroot/auto-devlop/ckb-operation/dist.zip' # 服务器上的临时zip路径
|
||||||
|
server_ip = '42.194.245.239'
|
||||||
|
server_port = 6523
|
||||||
|
server_user = 'yongpxu'
|
||||||
|
server_pwd = 'Aa123456789.'
|
||||||
|
# 服务器 dist 相关目录
|
||||||
|
remote_base_dir = '/www/wwwroot/auto-devlop/ckb-operation'
|
||||||
|
dist_dir = f'{remote_base_dir}/dist'
|
||||||
|
dist1_dir = f'{remote_base_dir}/dist1'
|
||||||
|
dist2_dir = f'{remote_base_dir}/dist2'
|
||||||
|
|
||||||
|
# 美化输出用的函数
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def info(msg):
|
||||||
|
print(f"\033[36m[INFO {datetime.now().strftime('%H:%M:%S')}] {msg}\033[0m")
|
||||||
|
|
||||||
|
def success(msg):
|
||||||
|
print(f"\033[32m[SUCCESS] {msg}\033[0m")
|
||||||
|
|
||||||
|
def error(msg):
|
||||||
|
print(f"\033[31m[ERROR] {msg}\033[0m")
|
||||||
|
|
||||||
|
def step(msg):
|
||||||
|
print(f"\n\033[35m==== {msg} ====" + "\033[0m")
|
||||||
|
|
||||||
|
# 1. 先运行 yarn build
|
||||||
|
step('Step 1: 构建项目 (yarn build)')
|
||||||
|
info('开始执行 yarn build...')
|
||||||
|
ret = os.system('yarn build')
|
||||||
|
if ret != 0:
|
||||||
|
error('yarn build 失败,终止部署!')
|
||||||
|
exit(1)
|
||||||
|
success('yarn build 完成')
|
||||||
|
|
||||||
|
# 2. 打包
|
||||||
|
step('Step 2: 打包 dist 目录为 zip')
|
||||||
|
info('开始打包 dist 目录...')
|
||||||
|
with zipfile.ZipFile(zip_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for root, dirs, files in os.walk(local_dir):
|
||||||
|
for file in files:
|
||||||
|
filepath = os.path.join(root, file)
|
||||||
|
arcname = os.path.relpath(filepath, local_dir)
|
||||||
|
zipf.write(filepath, arcname)
|
||||||
|
success('本地打包完成')
|
||||||
|
|
||||||
|
# 3. 上传
|
||||||
|
step('Step 3: 上传 zip 包到服务器')
|
||||||
|
info('开始上传 zip 包...')
|
||||||
|
transport = paramiko.Transport((server_ip, server_port))
|
||||||
|
transport.connect(username=server_user, password=server_pwd)
|
||||||
|
sftp = paramiko.SFTPClient.from_transport(transport)
|
||||||
|
sftp.put(zip_name, remote_path)
|
||||||
|
sftp.close()
|
||||||
|
transport.close()
|
||||||
|
success('上传到服务器完成')
|
||||||
|
|
||||||
|
# 删除本地 dist.zip
|
||||||
|
try:
|
||||||
|
os.remove(zip_name)
|
||||||
|
success('本地 dist.zip 已删除')
|
||||||
|
except Exception as e:
|
||||||
|
error(f'本地 dist.zip 删除失败: {e}')
|
||||||
|
|
||||||
|
# 4. 远程解压并覆盖
|
||||||
|
step('Step 4: 服务器端解压、切换目录')
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(server_ip, server_port, server_user, server_pwd)
|
||||||
|
commands = [
|
||||||
|
f'unzip -oq {remote_path} -d {dist2_dir}', # 静默解压
|
||||||
|
f'rm {remote_path}',
|
||||||
|
f'if [ -d {dist_dir} ]; then mv {dist_dir} {dist1_dir}; fi',
|
||||||
|
f'mv {dist2_dir} {dist_dir}',
|
||||||
|
f'rm -rf {dist1_dir}'
|
||||||
|
]
|
||||||
|
for i, cmd in enumerate(commands, 1):
|
||||||
|
info(f'执行第{i}步: {cmd}')
|
||||||
|
stdin, stdout, stderr = ssh.exec_command(cmd)
|
||||||
|
out, err = stdout.read().decode(), stderr.read().decode()
|
||||||
|
# 只打印非 unzip 命令的输出
|
||||||
|
if i != 1 and out.strip():
|
||||||
|
print(out.strip())
|
||||||
|
if err.strip():
|
||||||
|
error(err.strip())
|
||||||
|
ssh.close()
|
||||||
|
success('服务器解压并覆盖完成,部署成功!')
|
||||||
BIN
Cunkebao/favicon.ico
Normal file
BIN
Cunkebao/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
19
Cunkebao/index.html
Normal file
19
Cunkebao/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>存客宝</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- 引入 uni-app web-view SDK(必须) -->
|
||||||
|
<script type="text/javascript" src="./websdk.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
Cunkebao/next-env.d.ts
vendored
6
Cunkebao/next-env.d.ts
vendored
@@ -1,6 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
/// <reference types="next/navigation-types/compat/navigation" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
19654
Cunkebao/package-lock.json
generated
19654
Cunkebao/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,106 +1,49 @@
|
|||||||
{
|
{
|
||||||
"name": "nkebao2",
|
"name": "cunkebao",
|
||||||
"version": "0.1.0",
|
"version": "3.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/plots": "latest",
|
"@ant-design/icons": "^5.6.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"antd": "^5.13.1",
|
||||||
"@radix-ui/react-accordion": "latest",
|
"antd-mobile": "^5.39.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"axios": "^1.6.7",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.1",
|
"dayjs": "^1.11.13",
|
||||||
"@radix-ui/react-avatar": "latest",
|
"echarts": "^5.6.0",
|
||||||
"@radix-ui/react-checkbox": "latest",
|
"echarts-for-react": "^3.0.2",
|
||||||
"@radix-ui/react-collapsible": "latest",
|
|
||||||
"@radix-ui/react-context-menu": "^2.2.4",
|
|
||||||
"@radix-ui/react-dialog": "latest",
|
|
||||||
"@radix-ui/react-dropdown-menu": "latest",
|
|
||||||
"@radix-ui/react-hover-card": "^1.1.4",
|
|
||||||
"@radix-ui/react-icons": "latest",
|
|
||||||
"@radix-ui/react-label": "latest",
|
|
||||||
"@radix-ui/react-menubar": "^1.1.4",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.3",
|
|
||||||
"@radix-ui/react-popover": "latest",
|
|
||||||
"@radix-ui/react-progress": "latest",
|
|
||||||
"@radix-ui/react-radio-group": "latest",
|
|
||||||
"@radix-ui/react-scroll-area": "latest",
|
|
||||||
"@radix-ui/react-select": "latest",
|
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
|
||||||
"@radix-ui/react-switch": "latest",
|
|
||||||
"@radix-ui/react-tabs": "latest",
|
|
||||||
"@radix-ui/react-toast": "latest",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.1",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
|
||||||
"@radix-ui/react-tooltip": "latest",
|
|
||||||
"@tanstack/react-table": "latest",
|
|
||||||
"@testing-library/dom": "^10.4.0",
|
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
|
||||||
"@testing-library/react": "^13.4.0",
|
|
||||||
"@testing-library/user-event": "^13.5.0",
|
|
||||||
"@types/crypto-js": "^4.2.2",
|
|
||||||
"@types/jest": "^27.5.2",
|
|
||||||
"@types/node": "^18.19.34",
|
|
||||||
"@types/react": "^18.2.43",
|
|
||||||
"@types/react-dom": "^18.2.17",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"axios": "^1.6.0",
|
|
||||||
"chart.js": "^4.5.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"cmdk": "1.0.4",
|
|
||||||
"crypto-js": "^4.2.0",
|
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"embla-carousel-react": "8.5.1",
|
|
||||||
"input-otp": "1.4.1",
|
|
||||||
"lucide-react": "^0.525.0",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-day-picker": "latest",
|
|
||||||
"react-dom": "^18.2.0",
|
"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-router-dom": "^6.20.0",
|
||||||
"react-scripts": "5.0.1",
|
"vconsole": "^3.15.1",
|
||||||
"recharts": "latest",
|
"zustand": "^5.0.6"
|
||||||
"regenerator-runtime": "latest",
|
|
||||||
"sonner": "^1.7.4",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"tdesign-mobile-react": "^0.16.0",
|
|
||||||
"vaul": "^0.9.6",
|
|
||||||
"web-vitals": "^2.1.4",
|
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"zod": "^3.24.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@craco/craco": "^7.1.0",
|
"@types/node": "^24.0.14",
|
||||||
"postcss": "^8",
|
"@types/react": "^19.1.8",
|
||||||
"tailwindcss": "^3.4.17",
|
"@types/react-dom": "^19.1.6",
|
||||||
"typescript": "^4.9.5"
|
"@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": {
|
"scripts": {
|
||||||
"dev": "craco start",
|
"dev": "vite",
|
||||||
"build": "craco build",
|
"build": "vite build",
|
||||||
"test": "craco test",
|
"build:check": "tsc && vite build",
|
||||||
"eject": "react-scripts eject"
|
"preview": "vite preview",
|
||||||
},
|
"lint": "eslint src --ext .js,.jsx,.ts,.tsx --fix",
|
||||||
"eslintConfig": {
|
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\"",
|
||||||
"extends": [
|
"lint:check": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||||
"react-app",
|
"format:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx,json,scss,css}\""
|
||||||
"react-app/jest"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'postcss-pxtorem': {
|
||||||
autoprefixer: {},
|
rootValue: 16,
|
||||||
},
|
propList: ['*'],
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1,43 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="theme-color" content="#000000" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Web site created using create-react-app"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
|
||||||
<!--
|
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
|
||||||
-->
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<!--
|
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
|
||||||
|
|
||||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
|
||||||
work correctly both with client-side routing and a non-root public URL.
|
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
|
||||||
-->
|
|
||||||
<title>React App</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
BIN
Cunkebao/public/logo.png
Normal file
BIN
Cunkebao/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 488 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"short_name": "React App",
|
"name": "Cunkebao",
|
||||||
"name": "Create React App Sample",
|
"short_name": "Cunkebao",
|
||||||
|
"description": "Cunkebao Mobile App",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
@@ -8,18 +15,16 @@
|
|||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"src": "logo.png",
|
||||||
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "192x192"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo512.png",
|
"src": "logo.png",
|
||||||
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"start_url": ".",
|
}
|
||||||
"display": "standalone",
|
|
||||||
"theme_color": "#000000",
|
|
||||||
"background_color": "#ffffff"
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# https://www.robotstxt.org/robotstxt.html
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
308
Cunkebao/public/websdk.js
Normal file
308
Cunkebao/public/websdk.js
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
!(function (e, n) {
|
||||||
|
"object" == typeof exports && "undefined" != typeof module
|
||||||
|
? (module.exports = n())
|
||||||
|
: "function" == typeof define && define.amd
|
||||||
|
? define(n)
|
||||||
|
: ((e = e || self).uni = n());
|
||||||
|
})(this, function () {
|
||||||
|
"use strict";
|
||||||
|
try {
|
||||||
|
var e = {};
|
||||||
|
(Object.defineProperty(e, "passive", {
|
||||||
|
get: function () {
|
||||||
|
!0;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
window.addEventListener("test-passive", null, e));
|
||||||
|
} catch (e) {}
|
||||||
|
var n = Object.prototype.hasOwnProperty;
|
||||||
|
function i(e, i) {
|
||||||
|
return n.call(e, i);
|
||||||
|
}
|
||||||
|
var t = [];
|
||||||
|
function o() {
|
||||||
|
return window.__dcloud_weex_postMessage || window.__dcloud_weex_;
|
||||||
|
}
|
||||||
|
function a() {
|
||||||
|
return window.__uniapp_x_postMessage || window.__uniapp_x_;
|
||||||
|
}
|
||||||
|
var r = function (e, n) {
|
||||||
|
var i = { options: { timestamp: +new Date() }, name: e, arg: n };
|
||||||
|
if (a()) {
|
||||||
|
if ("postMessage" === e) {
|
||||||
|
var r = { data: n };
|
||||||
|
return window.__uniapp_x_postMessage
|
||||||
|
? window.__uniapp_x_postMessage(r)
|
||||||
|
: window.__uniapp_x_.postMessage(JSON.stringify(r));
|
||||||
|
}
|
||||||
|
var d = {
|
||||||
|
type: "WEB_INVOKE_APPSERVICE",
|
||||||
|
args: { data: i, webviewIds: t },
|
||||||
|
};
|
||||||
|
window.__uniapp_x_postMessage
|
||||||
|
? window.__uniapp_x_postMessageToService(d)
|
||||||
|
: window.__uniapp_x_.postMessageToService(JSON.stringify(d));
|
||||||
|
} else if (o()) {
|
||||||
|
if ("postMessage" === e) {
|
||||||
|
var s = { data: [n] };
|
||||||
|
return window.__dcloud_weex_postMessage
|
||||||
|
? window.__dcloud_weex_postMessage(s)
|
||||||
|
: window.__dcloud_weex_.postMessage(JSON.stringify(s));
|
||||||
|
}
|
||||||
|
var w = {
|
||||||
|
type: "WEB_INVOKE_APPSERVICE",
|
||||||
|
args: { data: i, webviewIds: t },
|
||||||
|
};
|
||||||
|
window.__dcloud_weex_postMessage
|
||||||
|
? window.__dcloud_weex_postMessageToService(w)
|
||||||
|
: window.__dcloud_weex_.postMessageToService(JSON.stringify(w));
|
||||||
|
} else {
|
||||||
|
if (!window.plus)
|
||||||
|
return window.parent.postMessage(
|
||||||
|
{ type: "WEB_INVOKE_APPSERVICE", data: i, pageId: "" },
|
||||||
|
"*",
|
||||||
|
);
|
||||||
|
if (0 === t.length) {
|
||||||
|
var u = plus.webview.currentWebview();
|
||||||
|
if (!u) throw new Error("plus.webview.currentWebview() is undefined");
|
||||||
|
var g = u.parent(),
|
||||||
|
v = "";
|
||||||
|
((v = g ? g.id : u.id), t.push(v));
|
||||||
|
}
|
||||||
|
if (plus.webview.getWebviewById("__uniapp__service"))
|
||||||
|
plus.webview.postMessageToUniNView(
|
||||||
|
{ type: "WEB_INVOKE_APPSERVICE", args: { data: i, webviewIds: t } },
|
||||||
|
"__uniapp__service",
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
var c = JSON.stringify(i);
|
||||||
|
plus.webview
|
||||||
|
.getLaunchWebview()
|
||||||
|
.evalJS(
|
||||||
|
'UniPlusBridge.subscribeHandler("'
|
||||||
|
.concat("WEB_INVOKE_APPSERVICE", '",')
|
||||||
|
.concat(c, ",")
|
||||||
|
.concat(JSON.stringify(t), ");"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
d = {
|
||||||
|
navigateTo: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.url;
|
||||||
|
r("navigateTo", { url: encodeURI(n) });
|
||||||
|
},
|
||||||
|
navigateBack: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.delta;
|
||||||
|
r("navigateBack", { delta: parseInt(n) || 1 });
|
||||||
|
},
|
||||||
|
switchTab: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.url;
|
||||||
|
r("switchTab", { url: encodeURI(n) });
|
||||||
|
},
|
||||||
|
reLaunch: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.url;
|
||||||
|
r("reLaunch", { url: encodeURI(n) });
|
||||||
|
},
|
||||||
|
redirectTo: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {},
|
||||||
|
n = e.url;
|
||||||
|
r("redirectTo", { url: encodeURI(n) });
|
||||||
|
},
|
||||||
|
getEnv: function (e) {
|
||||||
|
a()
|
||||||
|
? e({ uvue: !0 })
|
||||||
|
: o()
|
||||||
|
? e({ nvue: !0 })
|
||||||
|
: window.plus
|
||||||
|
? e({ plus: !0 })
|
||||||
|
: e({ h5: !0 });
|
||||||
|
},
|
||||||
|
postMessage: function () {
|
||||||
|
var e =
|
||||||
|
arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {};
|
||||||
|
r("postMessage", e.data || {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
s = /uni-app/i.test(navigator.userAgent),
|
||||||
|
w = /Html5Plus/i.test(navigator.userAgent),
|
||||||
|
u = /complete|loaded|interactive/;
|
||||||
|
var g =
|
||||||
|
window.my &&
|
||||||
|
navigator.userAgent.indexOf(
|
||||||
|
["t", "n", "e", "i", "l", "C", "y", "a", "p", "i", "l", "A"]
|
||||||
|
.reverse()
|
||||||
|
.join(""),
|
||||||
|
) > -1;
|
||||||
|
var v =
|
||||||
|
window.swan && window.swan.webView && /swan/i.test(navigator.userAgent);
|
||||||
|
var c =
|
||||||
|
window.qq &&
|
||||||
|
window.qq.miniProgram &&
|
||||||
|
/QQ/i.test(navigator.userAgent) &&
|
||||||
|
/miniProgram/i.test(navigator.userAgent);
|
||||||
|
var p =
|
||||||
|
window.tt &&
|
||||||
|
window.tt.miniProgram &&
|
||||||
|
/toutiaomicroapp/i.test(navigator.userAgent);
|
||||||
|
var _ =
|
||||||
|
window.wx &&
|
||||||
|
window.wx.miniProgram &&
|
||||||
|
/micromessenger/i.test(navigator.userAgent) &&
|
||||||
|
/miniProgram/i.test(navigator.userAgent);
|
||||||
|
var m = window.qa && /quickapp/i.test(navigator.userAgent);
|
||||||
|
var f =
|
||||||
|
window.ks &&
|
||||||
|
window.ks.miniProgram &&
|
||||||
|
/micromessenger/i.test(navigator.userAgent) &&
|
||||||
|
/miniProgram/i.test(navigator.userAgent);
|
||||||
|
var l =
|
||||||
|
window.tt &&
|
||||||
|
window.tt.miniProgram &&
|
||||||
|
/Lark|Feishu/i.test(navigator.userAgent);
|
||||||
|
var E =
|
||||||
|
window.jd && window.jd.miniProgram && /jdmp/i.test(navigator.userAgent);
|
||||||
|
var x =
|
||||||
|
window.xhs &&
|
||||||
|
window.xhs.miniProgram &&
|
||||||
|
/xhsminiapp/i.test(navigator.userAgent);
|
||||||
|
for (
|
||||||
|
var S,
|
||||||
|
h = function () {
|
||||||
|
((window.UniAppJSBridge = !0),
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent("UniAppJSBridgeReady", {
|
||||||
|
bubbles: !0,
|
||||||
|
cancelable: !0,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
},
|
||||||
|
y = [
|
||||||
|
function (e) {
|
||||||
|
if (s || w)
|
||||||
|
return (
|
||||||
|
window.__uniapp_x_postMessage ||
|
||||||
|
window.__uniapp_x_ ||
|
||||||
|
window.__dcloud_weex_postMessage ||
|
||||||
|
window.__dcloud_weex_
|
||||||
|
? document.addEventListener("DOMContentLoaded", e)
|
||||||
|
: window.plus && u.test(document.readyState)
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("plusready", e),
|
||||||
|
d
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (_)
|
||||||
|
return (
|
||||||
|
window.WeixinJSBridge && window.WeixinJSBridge.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("WeixinJSBridgeReady", e),
|
||||||
|
window.wx.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (c)
|
||||||
|
return (
|
||||||
|
window.QQJSBridge && window.QQJSBridge.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("QQJSBridgeReady", e),
|
||||||
|
window.qq.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (g) {
|
||||||
|
document.addEventListener("DOMContentLoaded", e);
|
||||||
|
var n = window.my;
|
||||||
|
return {
|
||||||
|
navigateTo: n.navigateTo,
|
||||||
|
navigateBack: n.navigateBack,
|
||||||
|
switchTab: n.switchTab,
|
||||||
|
reLaunch: n.reLaunch,
|
||||||
|
redirectTo: n.redirectTo,
|
||||||
|
postMessage: n.postMessage,
|
||||||
|
getEnv: n.getEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (v)
|
||||||
|
return (
|
||||||
|
document.addEventListener("DOMContentLoaded", e),
|
||||||
|
window.swan.webView
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (p)
|
||||||
|
return (
|
||||||
|
document.addEventListener("DOMContentLoaded", e),
|
||||||
|
window.tt.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (m) {
|
||||||
|
window.QaJSBridge && window.QaJSBridge.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("QaJSBridgeReady", e);
|
||||||
|
var n = window.qa;
|
||||||
|
return {
|
||||||
|
navigateTo: n.navigateTo,
|
||||||
|
navigateBack: n.navigateBack,
|
||||||
|
switchTab: n.switchTab,
|
||||||
|
reLaunch: n.reLaunch,
|
||||||
|
redirectTo: n.redirectTo,
|
||||||
|
postMessage: n.postMessage,
|
||||||
|
getEnv: n.getEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (f)
|
||||||
|
return (
|
||||||
|
window.WeixinJSBridge && window.WeixinJSBridge.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("WeixinJSBridgeReady", e),
|
||||||
|
window.ks.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (l)
|
||||||
|
return (
|
||||||
|
document.addEventListener("DOMContentLoaded", e),
|
||||||
|
window.tt.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (E)
|
||||||
|
return (
|
||||||
|
window.JDJSBridgeReady && window.JDJSBridgeReady.invoke
|
||||||
|
? setTimeout(e, 0)
|
||||||
|
: document.addEventListener("JDJSBridgeReady", e),
|
||||||
|
window.jd.miniProgram
|
||||||
|
);
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
if (x) return window.xhs.miniProgram;
|
||||||
|
},
|
||||||
|
function (e) {
|
||||||
|
return (document.addEventListener("DOMContentLoaded", e), d);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
M = 0;
|
||||||
|
M < y.length && !(S = y[M](h));
|
||||||
|
M++
|
||||||
|
);
|
||||||
|
S || (S = {});
|
||||||
|
var P = "undefined" != typeof uni ? uni : {};
|
||||||
|
if (!P.navigateTo) for (var b in S) i(S, b) && (P[b] = S[b]);
|
||||||
|
return ((P.webView = S), P);
|
||||||
|
});
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
.App {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-logo {
|
|
||||||
height: 40vmin;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.App-logo {
|
|
||||||
animation: App-logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-header {
|
|
||||||
background-color: #282c34;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: calc(10px + 2vmin);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.App-link {
|
|
||||||
color: #61dafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes App-logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
|
||||||
render(<App />);
|
|
||||||
const linkElement = screen.getByText(/learn react/i);
|
|
||||||
expect(linkElement).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
@@ -1,190 +1,13 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import AppRouter from "@/router";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import UpdateNotification from "@/components/UpdateNotification";
|
||||||
import { WechatAccountProvider } from "./contexts/WechatAccountContext";
|
|
||||||
import ProtectedRoute from "./components/ProtectedRoute";
|
|
||||||
import LayoutWrapper from "./components/LayoutWrapper";
|
|
||||||
import { initInterceptors } from "./api";
|
|
||||||
import Home from "./pages/Home";
|
|
||||||
import Login from "./pages/login/Login";
|
|
||||||
import Devices from "./pages/devices/Devices";
|
|
||||||
import DeviceDetail from "./pages/devices/DeviceDetail";
|
|
||||||
import WechatAccounts from "./pages/wechat-accounts/WechatAccounts";
|
|
||||||
import WechatAccountDetail from "./pages/wechat-accounts/WechatAccountDetail";
|
|
||||||
import Workspace from "./pages/workspace/Workspace";
|
|
||||||
import AutoLike from "./pages/workspace/auto-like/AutoLike";
|
|
||||||
import NewAutoLike from "./pages/workspace/auto-like/NewAutoLike";
|
|
||||||
import AutoLikeDetail from "./pages/workspace/auto-like/AutoLikeDetail";
|
|
||||||
import NewDistribution from "./pages/workspace/traffic-distribution/NewDistribution";
|
|
||||||
import AutoGroup from "./pages/workspace/auto-group/AutoGroup";
|
|
||||||
import AutoGroupDetail from "./pages/workspace/auto-group/Detail";
|
|
||||||
import GroupPush from "./pages/workspace/group-push/GroupPush";
|
|
||||||
import MomentsSync from "./pages/workspace/moments-sync/MomentsSync";
|
|
||||||
import MomentsSyncDetail from "./pages/workspace/moments-sync/Detail";
|
|
||||||
import NewMomentsSync from "./pages/workspace/moments-sync/new";
|
|
||||||
import AIAssistant from "./pages/workspace/ai-assistant/AIAssistant";
|
|
||||||
import TrafficDistribution from "./pages/workspace/traffic-distribution/TrafficDistribution";
|
|
||||||
import TrafficDistributionDetail from "./pages/workspace/traffic-distribution/Detail";
|
|
||||||
import Scenarios from "./pages/scenarios/Scenarios";
|
|
||||||
import NewPlan from "./pages/scenarios/new/page";
|
|
||||||
import ScenarioList from "./pages/scenarios/ScenarioList";
|
|
||||||
import Profile from "./pages/profile/Profile";
|
|
||||||
import Plans from "./pages/plans/Plans";
|
|
||||||
import PlanDetail from "./pages/plans/PlanDetail";
|
|
||||||
import Orders from "./pages/orders/Orders";
|
|
||||||
import TrafficPool from "./pages/traffic-pool/TrafficPool";
|
|
||||||
import ContactImport from "./pages/contact-import/ContactImport";
|
|
||||||
import Content from "./pages/content/Content";
|
|
||||||
import TrafficPoolDetail from "./pages/traffic-pool/TrafficPoolDetail";
|
|
||||||
import NewContent from "./pages/content/NewContent";
|
|
||||||
import Materials from "./pages/content/materials/List";
|
|
||||||
import MaterialsNew from "./pages/content/materials/New";
|
|
||||||
import NewGroupPush from './pages/workspace/group-push/new';
|
|
||||||
// 占位导入(如未实现可后续补充)
|
|
||||||
// import GroupPushDetail from './pages/workspace/group-push/GroupPushDetail';
|
|
||||||
// import EditGroupPush from './pages/workspace/group-push/EditGroupPush';
|
|
||||||
// import NewAutoGroup from './pages/workspace/auto-group/NewAutoGroup';
|
|
||||||
// import EditAutoGroup from './pages/workspace/auto-group/EditAutoGroup';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// 初始化HTTP拦截器
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = initInterceptors();
|
|
||||||
return cleanup;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter
|
<>
|
||||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
<AppRouter />
|
||||||
>
|
<UpdateNotification position="top" autoReload={false} showToast={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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { request } from './request';
|
|
||||||
import type { ApiResponse } from '@/types/common';
|
|
||||||
|
|
||||||
// 登录响应数据类型
|
|
||||||
export interface LoginResponse {
|
|
||||||
token: string;
|
|
||||||
token_expired: string;
|
|
||||||
member: {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
account: string;
|
|
||||||
avatar?: string;
|
|
||||||
s2_accountId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证码响应类型
|
|
||||||
export interface VerificationCodeResponse {
|
|
||||||
code: string;
|
|
||||||
expire_time: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 认证相关API
|
|
||||||
export const authApi = {
|
|
||||||
// 账号密码登录
|
|
||||||
login: async (account: string, password: string) => {
|
|
||||||
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login', {
|
|
||||||
account,
|
|
||||||
password,
|
|
||||||
typeId: 1 // 默认使用用户类型1
|
|
||||||
});
|
|
||||||
return response as unknown as ApiResponse<LoginResponse>;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 验证码登录
|
|
||||||
loginWithCode: async (account: string, code: string) => {
|
|
||||||
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/login/code', {
|
|
||||||
account,
|
|
||||||
code,
|
|
||||||
typeId: 1
|
|
||||||
});
|
|
||||||
return response as unknown as ApiResponse<LoginResponse>;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 发送验证码
|
|
||||||
sendVerificationCode: async (account: string) => {
|
|
||||||
const response = await request.post<ApiResponse<VerificationCodeResponse>>('/v1/auth/send-code', {
|
|
||||||
account,
|
|
||||||
type: 'login' // 登录验证码
|
|
||||||
});
|
|
||||||
return response as unknown as ApiResponse<VerificationCodeResponse>;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取用户信息
|
|
||||||
getUserInfo: async () => {
|
|
||||||
const response = await request.get<ApiResponse<any>>('/v1/auth/info');
|
|
||||||
return response as unknown as ApiResponse<any>;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 刷新Token
|
|
||||||
refreshToken: async () => {
|
|
||||||
const response = await request.post<ApiResponse<{ token: string; token_expired: string }>>('/v1/auth/refresh', {});
|
|
||||||
return response as unknown as ApiResponse<{ token: string; token_expired: string }>;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 微信登录
|
|
||||||
wechatLogin: async (code: string) => {
|
|
||||||
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/wechat', {
|
|
||||||
code
|
|
||||||
});
|
|
||||||
return response as unknown as ApiResponse<LoginResponse>;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Apple登录
|
|
||||||
appleLogin: async (identityToken: string, authorizationCode: string) => {
|
|
||||||
const response = await request.post<ApiResponse<LoginResponse>>('/v1/auth/apple', {
|
|
||||||
identity_token: identityToken,
|
|
||||||
authorization_code: authorizationCode
|
|
||||||
});
|
|
||||||
return response as unknown as ApiResponse<LoginResponse>;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { get, post, del } from './request';
|
|
||||||
import {
|
|
||||||
LikeTask,
|
|
||||||
CreateLikeTaskData,
|
|
||||||
UpdateLikeTaskData,
|
|
||||||
LikeRecord,
|
|
||||||
ApiResponse,
|
|
||||||
PaginatedResponse
|
|
||||||
} from '@/types/auto-like';
|
|
||||||
|
|
||||||
// 获取自动点赞任务列表
|
|
||||||
export async function fetchAutoLikeTasks(): Promise<LikeTask[]> {
|
|
||||||
try {
|
|
||||||
const res = await get<ApiResponse<PaginatedResponse<LikeTask>>>('/v1/workbench/list?type=1&page=1&limit=100');
|
|
||||||
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
return res.data.list || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取自动点赞任务失败:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取单个任务详情
|
|
||||||
export async function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
|
|
||||||
try {
|
|
||||||
console.log(`Fetching task detail for id: ${id}`);
|
|
||||||
// 使用any类型来处理可能的不同响应结构
|
|
||||||
const res = await get<any>(`/v1/workbench/detail?id=${id}`);
|
|
||||||
console.log('Task detail API response:', res);
|
|
||||||
|
|
||||||
if (res.code === 200) {
|
|
||||||
// 检查响应中的data字段
|
|
||||||
if (res.data) {
|
|
||||||
// 如果data是对象,直接返回
|
|
||||||
if (typeof res.data === 'object') {
|
|
||||||
return res.data;
|
|
||||||
} else {
|
|
||||||
console.error('Task detail API response data is not an object:', res.data);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Task detail API response missing data field:', res);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Task detail API error:', res.msg || 'Unknown error');
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取任务详情失败:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建自动点赞任务
|
|
||||||
export async function createAutoLikeTask(data: CreateLikeTaskData): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/create', {
|
|
||||||
...data,
|
|
||||||
type: 1 // 自动点赞类型
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新自动点赞任务
|
|
||||||
export async function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/update', {
|
|
||||||
...data,
|
|
||||||
type: 1 // 自动点赞类型
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除自动点赞任务
|
|
||||||
export async function deleteAutoLikeTask(id: string): Promise<ApiResponse> {
|
|
||||||
return del('/v1/workbench/delete', { params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换任务状态
|
|
||||||
export async function toggleAutoLikeTask(id: string, status: string): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/update-status', { id, status });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制自动点赞任务
|
|
||||||
export async function copyAutoLikeTask(id: string): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/copy', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取点赞记录
|
|
||||||
export async function fetchLikeRecords(
|
|
||||||
workbenchId: string,
|
|
||||||
page: number = 1,
|
|
||||||
limit: number = 20,
|
|
||||||
keyword?: string
|
|
||||||
): Promise<PaginatedResponse<LikeRecord>> {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
workbenchId,
|
|
||||||
page: page.toString(),
|
|
||||||
limit: limit.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
params.append('keyword', keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await get<ApiResponse<PaginatedResponse<LikeRecord>>>(`/v1/workbench/like-records?${params.toString()}`);
|
|
||||||
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
return { list: [], total: 0, page, limit };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取点赞记录失败:', error);
|
|
||||||
return { list: [], total: 0, page, limit };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { LikeTask, LikeRecord, CreateLikeTaskData };
|
|
||||||
27
Cunkebao/src/api/common.ts
Normal file
27
Cunkebao/src/api/common.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import request from "./request";
|
||||||
|
/**
|
||||||
|
* 通用文件上传方法(支持图片、文件)
|
||||||
|
* @param {File} file - 要上传的文件对象
|
||||||
|
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
|
||||||
|
* @returns {Promise<string>} - 上传成功后返回文件url
|
||||||
|
*/
|
||||||
|
export async function uploadFile(
|
||||||
|
file: File,
|
||||||
|
uploadUrl: string = "/v1/attachment/upload",
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// 创建 FormData 对象用于文件上传
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
// 使用 request 方法上传文件,设置正确的 Content-Type
|
||||||
|
const res = await request(uploadUrl, formData, "POST", {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.url;
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error(e?.message || "文件上传失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { get, post, put, del } from './request';
|
|
||||||
import type { ApiResponse, PaginatedResponse } from '@/types/common';
|
|
||||||
|
|
||||||
// 内容库类型定义
|
|
||||||
export interface ContentLibrary {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
sourceType: number;
|
|
||||||
creatorName: string;
|
|
||||||
updateTime: string;
|
|
||||||
status: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内容库列表响应
|
|
||||||
export interface ContentLibraryListResponse {
|
|
||||||
code: number;
|
|
||||||
msg: string;
|
|
||||||
data: {
|
|
||||||
list: ContentLibrary[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取内容库列表
|
|
||||||
export const fetchContentLibraryList = async (
|
|
||||||
page: number = 1,
|
|
||||||
limit: number = 100,
|
|
||||||
keyword?: string
|
|
||||||
): Promise<ContentLibraryListResponse> => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('page', page.toString());
|
|
||||||
params.append('limit', limit.toString());
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
params.append('keyword', keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
return get<ContentLibraryListResponse>(`/v1/content/library/list?${params.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 内容库API对象
|
|
||||||
export const contentLibraryApi = {
|
|
||||||
// 获取内容库列表
|
|
||||||
async getList(page: number = 1, limit: number = 100, keyword?: string): Promise<ContentLibraryListResponse> {
|
|
||||||
return fetchContentLibraryList(page, limit, keyword);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 创建内容库
|
|
||||||
async create(params: { name: string; sourceType: number }): Promise<ApiResponse<ContentLibrary>> {
|
|
||||||
return post<ApiResponse<ContentLibrary>>('/v1/content/library', params);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 更新内容库
|
|
||||||
async update(id: string, params: Partial<ContentLibrary>): Promise<ApiResponse<ContentLibrary>> {
|
|
||||||
return put<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`, params);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 删除内容库
|
|
||||||
async delete(id: string): Promise<ApiResponse<void>> {
|
|
||||||
return del<ApiResponse<void>>(`/v1/content/library/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取内容库详情
|
|
||||||
async getById(id: string): Promise<ApiResponse<ContentLibrary>> {
|
|
||||||
return get<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { get, post, put, del } from './request';
|
|
||||||
import type { ApiResponse, PaginatedResponse } from '@/types/common';
|
|
||||||
import type {
|
|
||||||
Device,
|
|
||||||
DeviceStats,
|
|
||||||
DeviceTaskRecord,
|
|
||||||
QueryDeviceParams,
|
|
||||||
CreateDeviceParams,
|
|
||||||
UpdateDeviceParams,
|
|
||||||
DeviceStatus,
|
|
||||||
ServerDevicesResponse
|
|
||||||
} from '@/types/device';
|
|
||||||
|
|
||||||
const API_BASE = "/devices";
|
|
||||||
|
|
||||||
// 获取设备列表 - 连接到服务器/v1/devices接口
|
|
||||||
export const fetchDeviceList = async (page: number = 1, limit: number = 20, keyword?: string): Promise<ServerDevicesResponse> => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('page', page.toString());
|
|
||||||
params.append('limit', limit.toString());
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
params.append('keyword', keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
return get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取设备详情 - 连接到服务器/v1/devices/:id接口
|
|
||||||
export const fetchDeviceDetail = async (id: string | number): Promise<ApiResponse<any>> => {
|
|
||||||
return get<ApiResponse<any>>(`/v1/devices/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取设备关联的微信账号
|
|
||||||
export const fetchDeviceRelatedAccounts = async (id: string | number): Promise<ApiResponse<any>> => {
|
|
||||||
return get<ApiResponse<any>>(`/v1/wechats/related-device/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取设备操作记录
|
|
||||||
export const fetchDeviceHandleLogs = async (id: string | number, page: number = 1, limit: number = 10): Promise<ApiResponse<any>> => {
|
|
||||||
return get<ApiResponse<any>>(`/v1/devices/${id}/handle-logs?page=${page}&limit=${limit}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新设备任务配置
|
|
||||||
export const updateDeviceTaskConfig = async (
|
|
||||||
config: {
|
|
||||||
deviceId: string | number;
|
|
||||||
autoAddFriend?: boolean;
|
|
||||||
autoReply?: boolean;
|
|
||||||
momentsSync?: boolean;
|
|
||||||
aiChat?: boolean;
|
|
||||||
}
|
|
||||||
): Promise<ApiResponse<any>> => {
|
|
||||||
return post<ApiResponse<any>>(`/v1/devices/task-config`, config);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除设备
|
|
||||||
export const deleteDevice = async (id: number): Promise<ApiResponse<any>> => {
|
|
||||||
return del<ApiResponse<any>>(`/v1/devices/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 设备管理API
|
|
||||||
export const devicesApi = {
|
|
||||||
// 获取设备列表
|
|
||||||
async getList(page: number = 1, limit: number = 20, keyword?: string): Promise<ServerDevicesResponse> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('page', page.toString());
|
|
||||||
params.append('limit', limit.toString());
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
params.append('keyword', keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
return get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取设备二维码
|
|
||||||
async getQRCode(accountId: string): Promise<ApiResponse<{ qrCode: string }>> {
|
|
||||||
return post<ApiResponse<{ qrCode: string }>>('/v1/api/device/add', { accountId });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 通过IMEI添加设备
|
|
||||||
async addByImei(imei: string, name: string): Promise<ApiResponse<any>> {
|
|
||||||
return post<ApiResponse<any>>('/v1/api/device/add-by-imei', { imei, name });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 创建设备
|
|
||||||
async create(params: CreateDeviceParams): Promise<ApiResponse<Device>> {
|
|
||||||
return post<ApiResponse<Device>>(`${API_BASE}`, params);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 更新设备
|
|
||||||
async update(params: UpdateDeviceParams): Promise<ApiResponse<Device>> {
|
|
||||||
return put<ApiResponse<Device>>(`${API_BASE}/${params.id}`, params);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取设备详情
|
|
||||||
async getById(id: string): Promise<ApiResponse<Device>> {
|
|
||||||
return get<ApiResponse<Device>>(`${API_BASE}/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 查询设备列表
|
|
||||||
async query(params: QueryDeviceParams): Promise<ApiResponse<PaginatedResponse<Device>>> {
|
|
||||||
// 创建一个新对象,用于构建URLSearchParams
|
|
||||||
const queryParams: Record<string, string> = {};
|
|
||||||
|
|
||||||
// 按需将params中的属性添加到queryParams
|
|
||||||
if (params.keyword) queryParams.keyword = params.keyword;
|
|
||||||
if (params.status) queryParams.status = params.status;
|
|
||||||
if (params.type) queryParams.type = params.type;
|
|
||||||
if (params.page) queryParams.page = params.page.toString();
|
|
||||||
if (params.pageSize) queryParams.pageSize = params.pageSize.toString();
|
|
||||||
|
|
||||||
// 特殊处理需要JSON序列化的属性
|
|
||||||
if (params.tags) queryParams.tags = JSON.stringify(params.tags);
|
|
||||||
if (params.dateRange) queryParams.dateRange = JSON.stringify(params.dateRange);
|
|
||||||
|
|
||||||
// 构建查询字符串
|
|
||||||
const queryString = new URLSearchParams(queryParams).toString();
|
|
||||||
return get<ApiResponse<PaginatedResponse<Device>>>(`${API_BASE}?${queryString}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 删除设备(旧版本)
|
|
||||||
async deleteById(id: string): Promise<ApiResponse<void>> {
|
|
||||||
return del<ApiResponse<void>>(`${API_BASE}/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 删除设备(新版本)
|
|
||||||
async delete(id: number): Promise<ApiResponse<any>> {
|
|
||||||
return del<ApiResponse<any>>(`/v1/devices/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 重启设备
|
|
||||||
async restart(id: string): Promise<ApiResponse<void>> {
|
|
||||||
return post<ApiResponse<void>>(`${API_BASE}/${id}/restart`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 解绑设备
|
|
||||||
async unbind(id: string): Promise<ApiResponse<void>> {
|
|
||||||
return post<ApiResponse<void>>(`${API_BASE}/${id}/unbind`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取设备统计数据
|
|
||||||
async getStats(id: string): Promise<ApiResponse<DeviceStats>> {
|
|
||||||
return get<ApiResponse<DeviceStats>>(`${API_BASE}/${id}/stats`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取设备任务记录
|
|
||||||
async getTaskRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<DeviceTaskRecord>>> {
|
|
||||||
return get<ApiResponse<PaginatedResponse<DeviceTaskRecord>>>(`${API_BASE}/${id}/tasks?page=${page}&pageSize=${pageSize}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 批量更新设备标签
|
|
||||||
async updateTags(ids: string[], tags: string[]): Promise<ApiResponse<void>> {
|
|
||||||
return post<ApiResponse<void>>(`${API_BASE}/tags`, { deviceIds: ids, tags });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 批量导出设备数据
|
|
||||||
async exportDevices(ids: string[]): Promise<Blob> {
|
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_BASE || 'http://localhost:3000/api'}${API_BASE}/export`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ deviceIds: ids }),
|
|
||||||
});
|
|
||||||
return response.blob();
|
|
||||||
},
|
|
||||||
|
|
||||||
// 检查设备在线状态
|
|
||||||
async checkStatus(ids: string[]): Promise<ApiResponse<Record<string, DeviceStatus>>> {
|
|
||||||
return post<ApiResponse<Record<string, DeviceStatus>>>(`${API_BASE}/status`, { deviceIds: ids });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取设备关联的微信账号
|
|
||||||
async getRelatedAccounts(id: string | number): Promise<ApiResponse<any>> {
|
|
||||||
return get<ApiResponse<any>>(`/v1/wechats/related-device/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取设备操作记录
|
|
||||||
async getHandleLogs(id: string | number, page: number = 1, limit: number = 10): Promise<ApiResponse<any>> {
|
|
||||||
return get<ApiResponse<any>>(`/v1/devices/${id}/handle-logs?page=${page}&limit=${limit}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 更新设备任务配置
|
|
||||||
async updateTaskConfig(config: {
|
|
||||||
deviceId: string | number;
|
|
||||||
autoAddFriend?: boolean;
|
|
||||||
autoReply?: boolean;
|
|
||||||
momentsSync?: boolean;
|
|
||||||
aiChat?: boolean;
|
|
||||||
}): Promise<ApiResponse<any>> {
|
|
||||||
return post<ApiResponse<any>>(`/v1/devices/task-config`, config);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取设备任务配置
|
|
||||||
async getTaskConfig(id: string | number): Promise<ApiResponse<any>> {
|
|
||||||
return get<ApiResponse<any>>(`/v1/devices/${id}/task-config`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import { get, post, put, del } from './request';
|
|
||||||
|
|
||||||
// 群发推送任务类型定义
|
|
||||||
export interface GroupPushTask {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: number; // 1: 运行中, 2: 已暂停
|
|
||||||
deviceCount: number;
|
|
||||||
targetGroups: string[];
|
|
||||||
pushCount: number;
|
|
||||||
successCount: number;
|
|
||||||
lastPushTime: string;
|
|
||||||
createTime: string;
|
|
||||||
creator: string;
|
|
||||||
pushInterval: number;
|
|
||||||
maxPushPerDay: number;
|
|
||||||
timeRange: { start: string; end: string };
|
|
||||||
messageType: 'text' | 'image' | 'video' | 'link';
|
|
||||||
messageContent: string;
|
|
||||||
targetTags: string[];
|
|
||||||
pushMode: 'immediate' | 'scheduled';
|
|
||||||
scheduledTime?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API响应类型
|
|
||||||
interface ApiResponse<T = any> {
|
|
||||||
code: number;
|
|
||||||
message: string;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取群发推送任务列表
|
|
||||||
*/
|
|
||||||
export async function fetchGroupPushTasks(): Promise<GroupPushTask[]> {
|
|
||||||
try {
|
|
||||||
const response = await get<ApiResponse<GroupPushTask[]>>('/v1/workspace/group-push/tasks');
|
|
||||||
|
|
||||||
if (response.code === 200 && Array.isArray(response.data)) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果API不可用,返回模拟数据
|
|
||||||
return getMockGroupPushTasks();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取群发推送任务失败:', error);
|
|
||||||
// 返回模拟数据作为降级方案
|
|
||||||
return getMockGroupPushTasks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除群发推送任务
|
|
||||||
*/
|
|
||||||
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
|
|
||||||
try {
|
|
||||||
const response = await del<ApiResponse>(`/v1/workspace/group-push/tasks/${id}`);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除群发推送任务失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换群发推送任务状态
|
|
||||||
*/
|
|
||||||
export async function toggleGroupPushTask(id: string, status: string): Promise<ApiResponse> {
|
|
||||||
try {
|
|
||||||
const response = await post<ApiResponse>(`/v1/workspace/group-push/tasks/${id}/toggle`, {
|
|
||||||
status
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('切换群发推送任务状态失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制群发推送任务
|
|
||||||
*/
|
|
||||||
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
|
|
||||||
try {
|
|
||||||
const response = await post<ApiResponse>(`/v1/workspace/group-push/tasks/${id}/copy`);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('复制群发推送任务失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建群发推送任务
|
|
||||||
*/
|
|
||||||
export async function createGroupPushTask(taskData: Partial<GroupPushTask>): Promise<ApiResponse> {
|
|
||||||
try {
|
|
||||||
const response = await post<ApiResponse>('/v1/workspace/group-push/tasks', taskData);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建群发推送任务失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新群发推送任务
|
|
||||||
*/
|
|
||||||
export async function updateGroupPushTask(id: string, taskData: Partial<GroupPushTask>): Promise<ApiResponse> {
|
|
||||||
try {
|
|
||||||
const response = await put<ApiResponse>(`/v1/workspace/group-push/tasks/${id}`, taskData);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新群发推送任务失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取群发推送任务详情
|
|
||||||
*/
|
|
||||||
export async function getGroupPushTaskDetail(id: string): Promise<GroupPushTask> {
|
|
||||||
try {
|
|
||||||
const response = await get<ApiResponse<GroupPushTask>>(`/v1/workspace/group-push/tasks/${id}`);
|
|
||||||
|
|
||||||
if (response.code === 200 && response.data) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(response.message || '获取任务详情失败');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取群发推送任务详情失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模拟数据 - 当API不可用时使用
|
|
||||||
*/
|
|
||||||
function getMockGroupPushTasks(): GroupPushTask[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: '产品推广群发',
|
|
||||||
deviceCount: 2,
|
|
||||||
targetGroups: ['VIP客户群', '潜在客户群'],
|
|
||||||
pushCount: 156,
|
|
||||||
successCount: 142,
|
|
||||||
lastPushTime: '2025-02-06 13:12:35',
|
|
||||||
createTime: '2024-11-20 19:04:14',
|
|
||||||
creator: 'admin',
|
|
||||||
status: 1, // 运行中
|
|
||||||
pushInterval: 60,
|
|
||||||
maxPushPerDay: 200,
|
|
||||||
timeRange: { start: '09:00', end: '21:00' },
|
|
||||||
messageType: 'text',
|
|
||||||
messageContent: '新品上市,限时优惠!点击查看详情...',
|
|
||||||
targetTags: ['VIP客户', '高意向'],
|
|
||||||
pushMode: 'immediate',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: '活动通知推送',
|
|
||||||
deviceCount: 1,
|
|
||||||
targetGroups: ['活动群', '推广群'],
|
|
||||||
pushCount: 89,
|
|
||||||
successCount: 78,
|
|
||||||
lastPushTime: '2024-03-04 14:09:35',
|
|
||||||
createTime: '2024-03-04 14:29:04',
|
|
||||||
creator: 'manager',
|
|
||||||
status: 2, // 已暂停
|
|
||||||
pushInterval: 120,
|
|
||||||
maxPushPerDay: 100,
|
|
||||||
timeRange: { start: '10:00', end: '20:00' },
|
|
||||||
messageType: 'image',
|
|
||||||
messageContent: '活动海报.jpg',
|
|
||||||
targetTags: ['活跃用户', '中意向'],
|
|
||||||
pushMode: 'scheduled',
|
|
||||||
scheduledTime: '2024-03-05 10:00:00',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: '新客户欢迎消息',
|
|
||||||
deviceCount: 3,
|
|
||||||
targetGroups: ['新客户群', '体验群'],
|
|
||||||
pushCount: 234,
|
|
||||||
successCount: 218,
|
|
||||||
lastPushTime: '2025-02-06 15:30:22',
|
|
||||||
createTime: '2024-12-01 09:15:30',
|
|
||||||
creator: 'admin',
|
|
||||||
status: 1, // 运行中
|
|
||||||
pushInterval: 30,
|
|
||||||
maxPushPerDay: 300,
|
|
||||||
timeRange: { start: '08:00', end: '22:00' },
|
|
||||||
messageType: 'text',
|
|
||||||
messageContent: '欢迎加入我们的大家庭!这里有最新的产品信息和优惠活动...',
|
|
||||||
targetTags: ['新客户', '欢迎'],
|
|
||||||
pushMode: 'immediate',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// 导出所有API相关的内容
|
|
||||||
export * from './auth';
|
|
||||||
export * from './utils';
|
|
||||||
export * from './interceptors';
|
|
||||||
export * from './request';
|
|
||||||
|
|
||||||
// 导出现有的API模块
|
|
||||||
export * from './devices';
|
|
||||||
export * from './scenarios';
|
|
||||||
export * from './wechat-accounts';
|
|
||||||
export * from './trafficDistribution';
|
|
||||||
|
|
||||||
// 默认导出request实例
|
|
||||||
export { default as request } from './request';
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { refreshAuthToken, isTokenExpiringSoon, clearToken } from './utils';
|
|
||||||
|
|
||||||
// Token过期处理
|
|
||||||
export const handleTokenExpired = () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
// 清除本地存储
|
|
||||||
clearToken();
|
|
||||||
|
|
||||||
// 跳转到登录页面
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 显示API错误,但不会重定向
|
|
||||||
export const showApiError = (error: any, defaultMessage: string = '请求失败') => {
|
|
||||||
if (typeof window === 'undefined') return; // 服务端不处理
|
|
||||||
|
|
||||||
let errorMessage = defaultMessage;
|
|
||||||
|
|
||||||
// 尝试从各种可能的错误格式中获取消息
|
|
||||||
if (error) {
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
errorMessage = error;
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = error.message || defaultMessage;
|
|
||||||
} else if (typeof error === 'object') {
|
|
||||||
// 尝试从API响应中获取错误消息
|
|
||||||
errorMessage = error.msg || error.message || error.error || defaultMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示错误消息
|
|
||||||
console.error('API错误:', errorMessage);
|
|
||||||
|
|
||||||
// 这里可以集成toast系统
|
|
||||||
// 由于toast context在组件层级,这里暂时用console
|
|
||||||
// 实际项目中可以通过事件系统或其他方式集成
|
|
||||||
};
|
|
||||||
|
|
||||||
// 请求拦截器 - 检查token是否需要刷新
|
|
||||||
export const requestInterceptor = async (): Promise<boolean> => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查token是否即将过期
|
|
||||||
if (isTokenExpiringSoon()) {
|
|
||||||
try {
|
|
||||||
console.log('Token即将过期,尝试刷新...');
|
|
||||||
const success = await refreshAuthToken();
|
|
||||||
if (!success) {
|
|
||||||
console.log('Token刷新失败,需要重新登录');
|
|
||||||
handleTokenExpired();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.log('Token刷新成功');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Token刷新过程中出错:', error);
|
|
||||||
handleTokenExpired();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 响应拦截器 - 处理常见错误
|
|
||||||
export const responseInterceptor = (response: any, result: any) => {
|
|
||||||
// 处理401未授权
|
|
||||||
if (response?.status === 401 || (result && result.code === 401)) {
|
|
||||||
handleTokenExpired();
|
|
||||||
throw new Error('登录已过期,请重新登录');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理403禁止访问
|
|
||||||
if (response?.status === 403 || (result && result.code === 403)) {
|
|
||||||
throw new Error('没有权限访问此资源');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理404未找到
|
|
||||||
if (response?.status === 404 || (result && result.code === 404)) {
|
|
||||||
throw new Error('请求的资源不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理500服务器错误
|
|
||||||
if (response?.status >= 500 || (result && result.code >= 500)) {
|
|
||||||
throw new Error('服务器内部错误,请稍后重试');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 错误拦截器 - 统一错误处理
|
|
||||||
export const errorInterceptor = (error: any) => {
|
|
||||||
console.error('API请求错误:', error);
|
|
||||||
|
|
||||||
let errorMessage = '网络请求失败,请稍后重试';
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
errorMessage = error;
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = error.message;
|
|
||||||
} else if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
||||||
errorMessage = '网络连接失败,请检查网络设置';
|
|
||||||
} else if (error.name === 'AbortError') {
|
|
||||||
errorMessage = '请求已取消';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showApiError(error, errorMessage);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 网络状态监听
|
|
||||||
export const setupNetworkListener = () => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const handleOnline = () => {
|
|
||||||
console.log('网络已连接');
|
|
||||||
// 可以在这里添加网络恢复后的处理逻辑
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOffline = () => {
|
|
||||||
console.log('网络已断开');
|
|
||||||
showApiError(null, '网络连接已断开,请检查网络设置');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline);
|
|
||||||
window.addEventListener('offline', handleOffline);
|
|
||||||
|
|
||||||
// 返回清理函数
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('online', handleOnline);
|
|
||||||
window.removeEventListener('offline', handleOffline);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化拦截器
|
|
||||||
export const initInterceptors = () => {
|
|
||||||
// 设置网络监听
|
|
||||||
const cleanupNetwork = setupNetworkListener();
|
|
||||||
|
|
||||||
// 返回清理函数
|
|
||||||
return () => {
|
|
||||||
if (cleanupNetwork) {
|
|
||||||
cleanupNetwork();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { get, post, del } from './request';
|
|
||||||
import {
|
|
||||||
MomentsSyncTask,
|
|
||||||
CreateMomentsSyncData,
|
|
||||||
UpdateMomentsSyncData,
|
|
||||||
SyncRecord,
|
|
||||||
ApiResponse,
|
|
||||||
PaginatedResponse
|
|
||||||
} from '@/types/moments-sync';
|
|
||||||
|
|
||||||
// 获取朋友圈同步任务列表
|
|
||||||
export async function fetchMomentsSyncTasks(): Promise<MomentsSyncTask[]> {
|
|
||||||
try {
|
|
||||||
const res = await get<ApiResponse<PaginatedResponse<MomentsSyncTask>>>('/v1/workbench/list?type=2&page=1&limit=100');
|
|
||||||
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
return res.data.list || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取朋友圈同步任务失败:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取单个任务详情
|
|
||||||
export async function fetchMomentsSyncTaskDetail(id: string): Promise<MomentsSyncTask | null> {
|
|
||||||
try {
|
|
||||||
const res = await get<ApiResponse<MomentsSyncTask>>(`/v1/workbench/detail?id=${id}`);
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取任务详情失败:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建朋友圈同步任务
|
|
||||||
export async function createMomentsSyncTask(data: CreateMomentsSyncData): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/create', {
|
|
||||||
...data,
|
|
||||||
type: 2 // 朋友圈同步类型
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新朋友圈同步任务
|
|
||||||
export async function updateMomentsSyncTask(data: UpdateMomentsSyncData): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/update', {
|
|
||||||
...data,
|
|
||||||
type: 2 // 朋友圈同步类型
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除朋友圈同步任务
|
|
||||||
export async function deleteMomentsSyncTask(id: string): Promise<ApiResponse> {
|
|
||||||
return del('/v1/workbench/delete', { params: { id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换任务状态
|
|
||||||
export async function toggleMomentsSyncTask(id: string, status: string): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/update-status', { id, status });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制朋友圈同步任务
|
|
||||||
export async function copyMomentsSyncTask(id: string): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/copy', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取同步记录
|
|
||||||
export async function fetchSyncRecords(
|
|
||||||
workbenchId: string,
|
|
||||||
page: number = 1,
|
|
||||||
limit: number = 20,
|
|
||||||
keyword?: string
|
|
||||||
): Promise<PaginatedResponse<SyncRecord>> {
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
workbenchId,
|
|
||||||
page: page.toString(),
|
|
||||||
limit: limit.toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
params.append('keyword', keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await get<ApiResponse<PaginatedResponse<SyncRecord>>>(`/v1/workbench/sync-records?${params.toString()}`);
|
|
||||||
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
return { list: [], total: 0, page, limit };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取同步记录失败:', error);
|
|
||||||
return { list: [], total: 0, page, limit };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 手动同步
|
|
||||||
export async function syncMoments(id: string): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/sync', { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步所有任务
|
|
||||||
export async function syncAllMoments(): Promise<ApiResponse> {
|
|
||||||
return post('/v1/workbench/sync-all', { type: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { MomentsSyncTask, SyncRecord, CreateMomentsSyncData };
|
|
||||||
@@ -1,73 +1,90 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
import axios, {
|
||||||
import { requestInterceptor, responseInterceptor, errorInterceptor } from './interceptors';
|
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 instance: AxiosInstance = axios.create({
|
||||||
const request: AxiosInstance = axios.create({
|
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
|
||||||
baseURL: process.env.REACT_APP_API_BASE_URL || 'https://ckbapi.quwanzhi.com',
|
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 请求拦截器
|
instance.interceptors.request.use((config: any) => {
|
||||||
request.interceptors.request.use(
|
if (token) {
|
||||||
async (config) => {
|
config.headers = config.headers || {};
|
||||||
// 检查token是否需要刷新
|
config.headers["Authorization"] = `Bearer ${token}`;
|
||||||
if (config.headers.Authorization) {
|
}
|
||||||
const shouldContinue = await requestInterceptor();
|
return config;
|
||||||
if (!shouldContinue) {
|
});
|
||||||
throw new Error('请求被拦截,需要重新登录');
|
|
||||||
|
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)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Promise.reject(msg || "接口错误");
|
||||||
// 添加token到请求头
|
},
|
||||||
const token = localStorage.getItem('token');
|
err => {
|
||||||
if (token) {
|
Toast.show({ content: err.message || "网络异常", position: "top" });
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
return Promise.reject(err);
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
},
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 响应拦截器
|
export function request(
|
||||||
request.interceptors.response.use(
|
url: string,
|
||||||
(response: AxiosResponse) => {
|
data?: any,
|
||||||
// 处理响应数据
|
method: Method = "GET",
|
||||||
const result = response.data;
|
config?: AxiosRequestConfig,
|
||||||
const processedResult = responseInterceptor(response, result);
|
debounceGap?: number,
|
||||||
return processedResult;
|
): Promise<any> {
|
||||||
},
|
const gap =
|
||||||
(error) => {
|
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
|
||||||
// 统一错误处理
|
const key = `${method}_${url}_${JSON.stringify(data)}`;
|
||||||
return errorInterceptor(error);
|
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请求
|
const axiosConfig: AxiosRequestConfig = {
|
||||||
export const get = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
|
url,
|
||||||
return request.get(url, config);
|
method,
|
||||||
};
|
...config,
|
||||||
|
};
|
||||||
|
|
||||||
// 封装POST请求
|
// 如果是FormData,不设置Content-Type,让浏览器自动设置
|
||||||
export const post = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
|
if (data instanceof FormData) {
|
||||||
return request.post(url, data, config);
|
delete axiosConfig.headers?.["Content-Type"];
|
||||||
};
|
}
|
||||||
|
|
||||||
// 封装PUT请求
|
if (method.toUpperCase() === "GET") {
|
||||||
export const put = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
|
axiosConfig.params = data;
|
||||||
return request.put(url, data, config);
|
} else {
|
||||||
};
|
axiosConfig.data = data;
|
||||||
|
}
|
||||||
|
return instance(axiosConfig);
|
||||||
|
}
|
||||||
|
|
||||||
// 封装DELETE请求
|
export default request;
|
||||||
export const del = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
|
|
||||||
return request.delete(url, config);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 导出request实例
|
|
||||||
export { request };
|
|
||||||
export default request;
|
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
import { get, del, post,put } from './request';
|
|
||||||
import type { ApiResponse } from '@/types/common';
|
|
||||||
|
|
||||||
// 服务器返回的场景数据类型
|
|
||||||
export interface SceneItem {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
status: number;
|
|
||||||
createTime: number;
|
|
||||||
updateTime: number | null;
|
|
||||||
deleteTime: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 前端使用的场景数据类型
|
|
||||||
export interface Channel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
stats: {
|
|
||||||
daily: number;
|
|
||||||
growth: number;
|
|
||||||
};
|
|
||||||
link?: string;
|
|
||||||
plans?: Plan[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计划类型
|
|
||||||
export interface Plan {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
isNew?: boolean;
|
|
||||||
status: "active" | "paused" | "completed";
|
|
||||||
acquisitionCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任务类型
|
|
||||||
export interface Task {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: number;
|
|
||||||
stats: {
|
|
||||||
devices: number;
|
|
||||||
acquired: number;
|
|
||||||
added: number;
|
|
||||||
};
|
|
||||||
lastUpdated: string;
|
|
||||||
executionTime: string;
|
|
||||||
nextExecutionTime: string;
|
|
||||||
trend: { date: string; customers: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消息内容类型
|
|
||||||
export interface MessageContent {
|
|
||||||
id: string;
|
|
||||||
type: string; // "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group" 等
|
|
||||||
content?: string;
|
|
||||||
intervalUnit?: "seconds" | "minutes";
|
|
||||||
sendInterval?: number;
|
|
||||||
// 其他可选字段
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每天的消息计划
|
|
||||||
export interface MessagePlan {
|
|
||||||
day: number;
|
|
||||||
messages: MessageContent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 海报类型
|
|
||||||
export interface Poster {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
preview: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标签类型
|
|
||||||
export interface Tag {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// textUrl类型
|
|
||||||
export interface TextUrl {
|
|
||||||
apiKey: string;
|
|
||||||
originalString?: string;
|
|
||||||
sign?: string;
|
|
||||||
fullUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计划详情类型
|
|
||||||
export interface PlanDetail {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
scenario: number;
|
|
||||||
scenarioTags: Tag[];
|
|
||||||
customTags: Tag[];
|
|
||||||
posters: Poster[];
|
|
||||||
device: string[];
|
|
||||||
enabled: boolean;
|
|
||||||
addInterval: number;
|
|
||||||
remarkFormat: string;
|
|
||||||
endTime: string;
|
|
||||||
greeting: string;
|
|
||||||
startTime: string;
|
|
||||||
remarkType: string;
|
|
||||||
addFriendInterval: number;
|
|
||||||
messagePlans: MessagePlan[];
|
|
||||||
sceneId: number | string;
|
|
||||||
userId: number;
|
|
||||||
companyId: number;
|
|
||||||
status: number;
|
|
||||||
apiKey: string;
|
|
||||||
wxMinAppSrc?: any;
|
|
||||||
textUrl: TextUrl;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取获客场景列表
|
|
||||||
*
|
|
||||||
* @param params 查询参数
|
|
||||||
* @returns 获客场景列表
|
|
||||||
*/
|
|
||||||
export const fetchScenes = async (params: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
keyword?: string;
|
|
||||||
} = {}): Promise<ApiResponse<SceneItem[]>> => {
|
|
||||||
const { page = 1, limit = 10, keyword = "" } = params;
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.append("page", String(page));
|
|
||||||
queryParams.append("limit", String(limit));
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
queryParams.append("keyword", keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await get<ApiResponse<SceneItem[]>>(`/v1/plan/scenes?${queryParams.toString()}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching scenes:", error);
|
|
||||||
// 返回一个错误响应
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
msg: "获取场景列表失败",
|
|
||||||
data: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取场景详情
|
|
||||||
*
|
|
||||||
* @param id 场景ID
|
|
||||||
* @returns 场景详情
|
|
||||||
*/
|
|
||||||
export const fetchSceneDetail = async (id: string | number): Promise<ApiResponse<SceneItem>> => {
|
|
||||||
try {
|
|
||||||
return await get<ApiResponse<SceneItem>>(`/v1/plan/scenes/${id}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching scene detail:", error);
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
msg: "获取场景详情失败",
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取场景名称
|
|
||||||
*
|
|
||||||
* @param channel 场景标识
|
|
||||||
* @returns 场景名称
|
|
||||||
*/
|
|
||||||
export const fetchSceneName = async (channel: string): Promise<ApiResponse<{ name: string }>> => {
|
|
||||||
try {
|
|
||||||
return await get<ApiResponse<{ name: string }>>(`/v1/plan/scenes-detail?id=${channel}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching scene name:", error);
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
msg: "获取场景名称失败",
|
|
||||||
data: { name: channel }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取计划列表
|
|
||||||
*
|
|
||||||
* @param channel 场景标识
|
|
||||||
* @param page 页码
|
|
||||||
* @param pageSize 每页数量
|
|
||||||
* @returns 计划列表
|
|
||||||
*/
|
|
||||||
export const fetchPlanList = async (
|
|
||||||
channel: string,
|
|
||||||
page: number = 1,
|
|
||||||
pageSize: number = 10
|
|
||||||
): Promise<ApiResponse<{ list: Task[]; total: number }>> => {
|
|
||||||
try {
|
|
||||||
return await get<ApiResponse<{ list: Task[]; total: number }>>(
|
|
||||||
`/v1/plan/list?sceneId=${channel}&page=${page}&pageSize=${pageSize}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching plan list:", error);
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
msg: "获取计划列表失败",
|
|
||||||
data: { list: [], total: 0 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制计划
|
|
||||||
*
|
|
||||||
* @param planId 计划ID
|
|
||||||
* @returns 复制结果
|
|
||||||
*/
|
|
||||||
export const copyPlan = async (planId: string): Promise<ApiResponse<any>> => {
|
|
||||||
try {
|
|
||||||
return await get<ApiResponse<any>>(`/v1/plan/copy?planId=${planId}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error copying plan:", error);
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
msg: "复制计划失败",
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除计划
|
|
||||||
*
|
|
||||||
* @param planId 计划ID
|
|
||||||
* @returns 删除结果
|
|
||||||
*/
|
|
||||||
export const deletePlan = async (planId: string): Promise<ApiResponse<any>> => {
|
|
||||||
try {
|
|
||||||
return await del<ApiResponse<any>>(`/v1/plan/delete?planId=${planId}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting plan:", error);
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
msg: "删除计划失败",
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取计划详情
|
|
||||||
*
|
|
||||||
* @param planId 计划ID
|
|
||||||
* @returns 计划详情
|
|
||||||
*/
|
|
||||||
export const fetchPlanDetail = async (planId: string): Promise<ApiResponse<PlanDetail>> => {
|
|
||||||
try {
|
|
||||||
return await get<ApiResponse<PlanDetail>>(`/v1/plan/detail?planId=${planId}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching plan detail:", error);
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
msg: "获取计划详情失败",
|
|
||||||
data: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将服务器返回的场景数据转换为前端展示需要的格式
|
|
||||||
*
|
|
||||||
* @param item 服务器返回的场景数据
|
|
||||||
* @returns 前端展示的场景数据
|
|
||||||
*/
|
|
||||||
export const transformSceneItem = (item: SceneItem): Channel => {
|
|
||||||
// 为每个场景生成随机的"今日"数据和"增长百分比"
|
|
||||||
const dailyCount = Math.floor(Math.random() * 100);
|
|
||||||
const growthPercent = Math.floor(Math.random() * 40) - 10; // -10% 到 30% 的随机值
|
|
||||||
|
|
||||||
// 默认图标(如果服务器没有返回)
|
|
||||||
const defaultIcon = "/assets/icons/poster-icon.svg";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(item.id),
|
|
||||||
name: item.name,
|
|
||||||
icon: item.image || defaultIcon,
|
|
||||||
stats: {
|
|
||||||
daily: dailyCount,
|
|
||||||
growth: growthPercent
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPlanScenes = () => get<any>('/v1/plan/scenes');
|
|
||||||
|
|
||||||
export async function createScenarioPlan(data: any) {
|
|
||||||
return post('/v1/plan/create', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑计划
|
|
||||||
export async function updateScenarioPlan(planId: number | string, data: any) {
|
|
||||||
return await put(`/v1/plan/update?planId=${planId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取计划小程序二维码
|
|
||||||
* @param taskid 任务ID
|
|
||||||
* @returns base64二维码
|
|
||||||
*/
|
|
||||||
export const getWxMinAppCode = async (taskId: string): Promise<{ code: number; data?: string; msg?: string }> => {
|
|
||||||
try {
|
|
||||||
return await get<{ code: number; data?: string; msg?: string }>(
|
|
||||||
`/v1/plan/getWxMinAppCode?taskId=${ taskId }`,
|
|
||||||
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return { code: 500, msg: '获取小程序二维码失败' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { get, post, put, del } from './request';
|
|
||||||
import type { ApiResponse } from '@/types/common';
|
|
||||||
|
|
||||||
// 工作台任务类型
|
|
||||||
export enum WorkbenchTaskType {
|
|
||||||
MOMENTS_SYNC = 1, // 朋友圈同步
|
|
||||||
GROUP_PUSH = 2, // 社群推送
|
|
||||||
AUTO_LIKE = 3, // 自动点赞
|
|
||||||
AUTO_GROUP = 4, // 自动建群
|
|
||||||
TRAFFIC_DISTRIBUTION = 5, // 流量分发
|
|
||||||
}
|
|
||||||
|
|
||||||
// 工作台任务状态
|
|
||||||
export enum WorkbenchTaskStatus {
|
|
||||||
PENDING = 0, // 待处理
|
|
||||||
RUNNING = 1, // 运行中
|
|
||||||
PAUSED = 2, // 已暂停
|
|
||||||
COMPLETED = 3, // 已完成
|
|
||||||
FAILED = 4, // 失败
|
|
||||||
}
|
|
||||||
|
|
||||||
// 账号类型
|
|
||||||
export interface Account {
|
|
||||||
id: string;
|
|
||||||
userName: string;
|
|
||||||
realName: string;
|
|
||||||
nickname: string;
|
|
||||||
memo: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 账号列表响应类型
|
|
||||||
export interface AccountListResponse {
|
|
||||||
list: Account[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流量池类型
|
|
||||||
export interface TrafficPool {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
description?: string;
|
|
||||||
deviceIds: string[];
|
|
||||||
createTime?: string;
|
|
||||||
updateTime?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流量池列表响应类型
|
|
||||||
export interface TrafficPoolListResponse {
|
|
||||||
list: TrafficPool[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流量分发规则类型
|
|
||||||
export interface DistributionRule {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
type: number;
|
|
||||||
status: number;
|
|
||||||
autoStart: number;
|
|
||||||
createTime: string;
|
|
||||||
updateTime: string;
|
|
||||||
companyId: number;
|
|
||||||
config?: {
|
|
||||||
id: number;
|
|
||||||
workbenchId: number;
|
|
||||||
distributeType: number; // 1-均分配, 2-优先级分配, 3-比例分配
|
|
||||||
maxPerDay: number; // 每日最大分配量
|
|
||||||
timeType: number; // 1-全天, 2-自定义时间段
|
|
||||||
startTime: string; // 开始时间
|
|
||||||
endTime: string; // 结束时间
|
|
||||||
account: string[]; // 账号列表
|
|
||||||
devices: string[]; // 设备列表
|
|
||||||
pools: string[]; // 流量池列表
|
|
||||||
createTime: string;
|
|
||||||
updateTime: string;
|
|
||||||
lastUpdated: string;
|
|
||||||
total: {
|
|
||||||
dailyAverage: number; // 日均分发量
|
|
||||||
totalAccounts: number; // 分发账户总数
|
|
||||||
deviceCount: number; // 分发设备数量
|
|
||||||
poolCount: number; // 流量池数量
|
|
||||||
totalUsers: number; // 总用户数
|
|
||||||
};
|
|
||||||
};
|
|
||||||
auto_like?: any;
|
|
||||||
moments_sync?: any;
|
|
||||||
group_push?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流量分发列表响应类型
|
|
||||||
export interface TrafficDistributionListResponse {
|
|
||||||
list: DistributionRule[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取账号列表
|
|
||||||
* @param params 查询参数
|
|
||||||
* @returns 账号列表
|
|
||||||
*/
|
|
||||||
export const fetchAccountList = async (params: {
|
|
||||||
page?: number; // 页码
|
|
||||||
limit?: number; // 每页数量
|
|
||||||
keyword?: string; // 搜索关键词
|
|
||||||
} = {}): Promise<ApiResponse<AccountListResponse>> => {
|
|
||||||
const { page = 1, limit = 10, keyword = "" } = params;
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.append('page', page.toString());
|
|
||||||
queryParams.append('limit', limit.toString());
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
queryParams.append('keyword', keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
return get<ApiResponse<AccountListResponse>>(`/v1/workbench/account-list?${queryParams.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取设备标签(流量池)列表
|
|
||||||
* @param params 查询参数
|
|
||||||
* @returns 流量池列表
|
|
||||||
*/
|
|
||||||
export const fetchDeviceLabels = async (params: {
|
|
||||||
deviceIds: string[]; // 设备ID列表
|
|
||||||
page?: number; // 页码
|
|
||||||
pageSize?: number; // 每页数量
|
|
||||||
keyword?: string; // 搜索关键词
|
|
||||||
}): Promise<ApiResponse<TrafficPoolListResponse>> => {
|
|
||||||
const { deviceIds, page = 1, pageSize = 10, keyword = "" } = params;
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.append('deviceIds', deviceIds.join(','));
|
|
||||||
queryParams.append('page', page.toString());
|
|
||||||
queryParams.append('pageSize', pageSize.toString());
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
queryParams.append('keyword', keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
return get<ApiResponse<TrafficPoolListResponse>>(`/v1/workbench/device-labels?${queryParams.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取流量分发规则列表
|
|
||||||
* @param params 查询参数
|
|
||||||
* @returns 流量分发规则列表
|
|
||||||
*/
|
|
||||||
export const fetchDistributionRules = async (params: {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
keyword?: string;
|
|
||||||
} = {}): Promise<ApiResponse<TrafficDistributionListResponse>> => {
|
|
||||||
const { page = 1, limit = 10, keyword = "" } = params;
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.append('type', WorkbenchTaskType.TRAFFIC_DISTRIBUTION.toString());
|
|
||||||
queryParams.append('page', page.toString());
|
|
||||||
queryParams.append('limit', limit.toString());
|
|
||||||
|
|
||||||
if (keyword) {
|
|
||||||
queryParams.append('keyword', keyword);
|
|
||||||
}
|
|
||||||
|
|
||||||
return get<ApiResponse<TrafficDistributionListResponse>>(`/v1/workbench/list?${queryParams.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取流量分发规则详情
|
|
||||||
* @param id 规则ID
|
|
||||||
* @returns 流量分发规则详情
|
|
||||||
*/
|
|
||||||
export const fetchDistributionRuleDetail = async (id: string): Promise<ApiResponse<DistributionRule>> => {
|
|
||||||
return get<ApiResponse<DistributionRule>>(`/v1/workbench/detail?id=${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建流量分发规则
|
|
||||||
* @param params 创建参数
|
|
||||||
* @returns 创建结果
|
|
||||||
*/
|
|
||||||
export const createDistributionRule = async (params: any): Promise<ApiResponse<{ id: string }>> => {
|
|
||||||
return post<ApiResponse<{ id: string }>>('/v1/workbench/create', {
|
|
||||||
...params,
|
|
||||||
type: WorkbenchTaskType.TRAFFIC_DISTRIBUTION
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新流量分发规则
|
|
||||||
* @param id 规则ID
|
|
||||||
* @param params 更新参数
|
|
||||||
* @returns 更新结果
|
|
||||||
*/
|
|
||||||
export const updateDistributionRule = async (id : string, params: any): Promise<ApiResponse<any>> => {
|
|
||||||
return post<ApiResponse<any>>(`/v1/workbench/update`, {
|
|
||||||
id: id,
|
|
||||||
...params,
|
|
||||||
type: WorkbenchTaskType.TRAFFIC_DISTRIBUTION
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除流量分发规则
|
|
||||||
* @param id 规则ID
|
|
||||||
* @returns 删除结果
|
|
||||||
*/
|
|
||||||
export const deleteDistributionRule = async (id: string): Promise<ApiResponse<any>> => {
|
|
||||||
return del<ApiResponse<any>>(`/v1/workbench/delete?id=${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动/暂停流量分发规则
|
|
||||||
* @param id 规则ID
|
|
||||||
* @param status 状态:1-启动,0-暂停
|
|
||||||
* @returns 操作结果
|
|
||||||
*/
|
|
||||||
export const toggleDistributionRuleStatus = async (id: string, status: 0 | 1): Promise<ApiResponse<any>> => {
|
|
||||||
return post<ApiResponse<any>>('/v1/workbench/update-status', { id, status });
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { request } from './request';
|
|
||||||
import type { AxiosResponse } from 'axios';
|
|
||||||
|
|
||||||
// 上传图片,返回图片地址
|
|
||||||
export async function uploadImage(file: File): Promise<string> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const response: AxiosResponse<any> = await request.post('/v1/attachment/upload', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const res = response.data || response;
|
|
||||||
if (res?.url) {
|
|
||||||
return res.url;
|
|
||||||
}
|
|
||||||
throw new Error(res?.msg || '图片上传失败');
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { authApi } from './auth';
|
|
||||||
import { get, post, put, del } from './request';
|
|
||||||
import type { ApiResponse, PaginatedResponse } from '@/types/common';
|
|
||||||
// 设置token到localStorage
|
|
||||||
export const setToken = (token: string) => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('token', token);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取token
|
|
||||||
export const getToken = (): string | null => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return localStorage.getItem('token');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清除token
|
|
||||||
export const clearToken = () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('userInfo');
|
|
||||||
localStorage.removeItem('token_expired');
|
|
||||||
localStorage.removeItem('s2_accountId');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 验证token是否有效
|
|
||||||
export const validateToken = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const response = await authApi.getUserInfo();
|
|
||||||
return response.code === 200;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Token验证失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 刷新令牌
|
|
||||||
export const refreshAuthToken = async (): Promise<boolean> => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authApi.refreshToken();
|
|
||||||
if (response.code === 200 && response.data?.token) {
|
|
||||||
setToken(response.data.token);
|
|
||||||
// 更新过期时间
|
|
||||||
if (response.data.token_expired) {
|
|
||||||
localStorage.setItem('token_expired', response.data.token_expired);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('刷新Token失败:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查token是否即将过期
|
|
||||||
export const isTokenExpiringSoon = (): boolean => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenExpired = localStorage.getItem('token_expired');
|
|
||||||
if (!tokenExpired) return true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expiredTime = new Date(tokenExpired).getTime();
|
|
||||||
const currentTime = new Date().getTime();
|
|
||||||
// 提前10分钟认为即将过期
|
|
||||||
return currentTime >= (expiredTime - 10 * 60 * 1000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析token过期时间失败:', error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查token是否已过期
|
|
||||||
export const isTokenExpired = (): boolean => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenExpired = localStorage.getItem('token_expired');
|
|
||||||
if (!tokenExpired) return true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expiredTime = new Date(tokenExpired).getTime();
|
|
||||||
const currentTime = new Date().getTime();
|
|
||||||
// 提前5分钟认为过期,给刷新留出时间
|
|
||||||
return currentTime >= (expiredTime - 5 * 60 * 1000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析token过期时间失败:', error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 请求去重器
|
|
||||||
class RequestDeduplicator {
|
|
||||||
private pendingRequests = new Map<string, Promise<any>>();
|
|
||||||
|
|
||||||
async deduplicate<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
|
|
||||||
if (this.pendingRequests.has(key)) {
|
|
||||||
return this.pendingRequests.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = requestFn();
|
|
||||||
this.pendingRequests.set(key, promise);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await promise;
|
|
||||||
return result;
|
|
||||||
} finally {
|
|
||||||
this.pendingRequests.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getPendingCount(): number {
|
|
||||||
return this.pendingRequests.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(): void {
|
|
||||||
this.pendingRequests.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 请求取消管理器
|
|
||||||
class RequestCancelManager {
|
|
||||||
private abortControllers = new Map<string, AbortController>();
|
|
||||||
|
|
||||||
createController(key: string): AbortController {
|
|
||||||
// 取消之前的请求
|
|
||||||
this.cancelRequest(key);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
this.abortControllers.set(key, controller);
|
|
||||||
return controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelRequest(key: string): void {
|
|
||||||
const controller = this.abortControllers.get(key);
|
|
||||||
if (controller) {
|
|
||||||
controller.abort();
|
|
||||||
this.abortControllers.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAllRequests(): void {
|
|
||||||
this.abortControllers.forEach(controller => controller.abort());
|
|
||||||
this.abortControllers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
getController(key: string): AbortController | undefined {
|
|
||||||
return this.abortControllers.get(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出单例实例
|
|
||||||
export const requestDeduplicator = new RequestDeduplicator();
|
|
||||||
export const requestCancelManager = new RequestCancelManager();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用文件上传方法(支持图片、文件)
|
|
||||||
* @param {File} file - 要上传的文件对象
|
|
||||||
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
|
|
||||||
* @returns {Promise<string>} - 上传成功后返回文件url
|
|
||||||
*/
|
|
||||||
export async function uploadFile(file: File, uploadUrl: string = '/v1/attachment/upload'): Promise<string> {
|
|
||||||
try {
|
|
||||||
// 创建 FormData 对象用于文件上传
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
// 使用 post 方法上传文件,设置正确的 Content-Type
|
|
||||||
const res = await post(uploadUrl, formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 检查响应结果
|
|
||||||
if (res?.code === 200 && res?.data?.url) {
|
|
||||||
return res.data.url;
|
|
||||||
} else {
|
|
||||||
throw new Error(res?.msg || '文件上传失败');
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
throw new Error(e?.message || '文件上传失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import { get, post, put } from './request';
|
|
||||||
import type { ApiResponse } from '@/types/common';
|
|
||||||
|
|
||||||
// 添加接口返回数据类型定义
|
|
||||||
interface WechatAccountSummary {
|
|
||||||
accountAge: string;
|
|
||||||
activityLevel: {
|
|
||||||
allTimes: number;
|
|
||||||
dayTimes: number;
|
|
||||||
};
|
|
||||||
accountWeight: {
|
|
||||||
scope: number;
|
|
||||||
ageWeight: number;
|
|
||||||
activityWeigth: number;
|
|
||||||
restrictWeight: number;
|
|
||||||
realNameWeight: number;
|
|
||||||
};
|
|
||||||
statistics: {
|
|
||||||
todayAdded: number;
|
|
||||||
addLimit: number;
|
|
||||||
};
|
|
||||||
restrictions: {
|
|
||||||
id: number;
|
|
||||||
level: string;
|
|
||||||
reason: string;
|
|
||||||
date: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QueryWechatAccountParams {
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
keyword?: string;
|
|
||||||
sort?: string;
|
|
||||||
order?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取微信账号列表
|
|
||||||
* @param params 查询参数
|
|
||||||
* @returns 微信账号列表响应
|
|
||||||
*/
|
|
||||||
export const fetchWechatAccountList = async (params: QueryWechatAccountParams = {}): Promise<ApiResponse<{
|
|
||||||
list: any[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}>> => {
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
|
|
||||||
// 添加查询参数
|
|
||||||
if (params.page) queryParams.append('page', params.page.toString());
|
|
||||||
if (params.limit) queryParams.append('limit', params.limit.toString());
|
|
||||||
if (params.keyword) queryParams.append('nickname', params.keyword); // 使用nickname作为关键词搜索参数
|
|
||||||
if (params.sort) queryParams.append('sort', params.sort);
|
|
||||||
if (params.order) queryParams.append('order', params.order);
|
|
||||||
|
|
||||||
// 发起API请求
|
|
||||||
return get<ApiResponse<{
|
|
||||||
list: any[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}>>(`/v1/wechats?${queryParams.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 刷新微信账号状态
|
|
||||||
* @returns 刷新结果
|
|
||||||
*/
|
|
||||||
export const refreshWechatAccounts = async (): Promise<ApiResponse<any>> => {
|
|
||||||
return put<ApiResponse<any>>('/v1/wechats/refresh', {});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行微信好友转移
|
|
||||||
* @param sourceId 源微信账号ID
|
|
||||||
* @param targetId 目标微信账号ID
|
|
||||||
* @returns 转移结果
|
|
||||||
*/
|
|
||||||
export const transferWechatFriends = async (sourceId: string | number, targetId: string | number): Promise<ApiResponse<any>> => {
|
|
||||||
return post<ApiResponse<any>>('/v1/wechats/transfer-friends', {
|
|
||||||
source_id: sourceId,
|
|
||||||
target_id: targetId
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将服务器返回的微信账号数据转换为前端使用的格式
|
|
||||||
* @param serverAccount 服务器返回的微信账号数据
|
|
||||||
* @returns 前端使用的微信账号数据
|
|
||||||
*/
|
|
||||||
export const transformWechatAccount = (serverAccount: any): any => {
|
|
||||||
// 从deviceInfo中提取设备信息
|
|
||||||
let deviceName = '';
|
|
||||||
|
|
||||||
if (serverAccount.deviceInfo) {
|
|
||||||
// 尝试解析设备信息字符串
|
|
||||||
const deviceInfo = serverAccount.deviceInfo.split(' ');
|
|
||||||
if (deviceInfo.length > 0) {
|
|
||||||
// 提取设备名称
|
|
||||||
if (deviceInfo.length > 1) {
|
|
||||||
deviceName = deviceInfo[1] ? deviceInfo[1].replace(/[()]/g, '').trim() : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 如果没有设备名称,使用备用名称
|
|
||||||
if (!deviceName) {
|
|
||||||
deviceName = serverAccount.deviceMemo || '未命名设备';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 假设每天最多可添加20个好友
|
|
||||||
const maxDailyAdds = 20;
|
|
||||||
const todayAdded = serverAccount.todayNewFriendCount || 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: serverAccount.id.toString(),
|
|
||||||
avatar: serverAccount.avatar || '',
|
|
||||||
nickname: serverAccount.nickname || serverAccount.accountNickname || '未命名',
|
|
||||||
wechatId: serverAccount.wechatId || '',
|
|
||||||
deviceId: serverAccount.deviceId || '',
|
|
||||||
deviceName,
|
|
||||||
friendCount: serverAccount.totalFriend || 0,
|
|
||||||
todayAdded,
|
|
||||||
remainingAdds: serverAccount.canAddFriendCount || (maxDailyAdds - todayAdded),
|
|
||||||
maxDailyAdds,
|
|
||||||
status: serverAccount.wechatStatus === 1 ? "normal" : "abnormal" as "normal" | "abnormal",
|
|
||||||
lastActive: new Date().toLocaleString() // 服务端未提供,使用当前时间
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取微信好友列表
|
|
||||||
* @param wechatId 微信账号ID
|
|
||||||
* @param page 页码
|
|
||||||
* @param pageSize 每页数量
|
|
||||||
* @param searchQuery 搜索关键词
|
|
||||||
* @returns 好友列表数据
|
|
||||||
*/
|
|
||||||
export const fetchWechatFriends = async (wechatId: string, page: number = 1, pageSize: number = 20, searchQuery: string = ''): Promise<ApiResponse<{
|
|
||||||
list: any[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}>> => {
|
|
||||||
try {
|
|
||||||
const queryParams = new URLSearchParams();
|
|
||||||
queryParams.append('page', page.toString());
|
|
||||||
queryParams.append('limit', pageSize.toString());
|
|
||||||
if (searchQuery) {
|
|
||||||
queryParams.append('search', searchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
return get<ApiResponse<{
|
|
||||||
list: any[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
}>>(`/v1/wechats/${wechatId}/friends?${queryParams.toString()}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取好友列表失败:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取微信账号概览信息
|
|
||||||
* @param id 微信账号ID
|
|
||||||
* @returns 微信账号概览信息
|
|
||||||
*/
|
|
||||||
export const fetchWechatAccountSummary = async (wechatId: string): Promise<ApiResponse<WechatAccountSummary>> => {
|
|
||||||
try {
|
|
||||||
return get<ApiResponse<WechatAccountSummary>>(`/v1/wechats/${wechatId}/summary`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取账号概览失败:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取好友详情信息
|
|
||||||
* @param wechatId 微信账号ID
|
|
||||||
* @param friendId 好友ID
|
|
||||||
* @returns 好友详情信息
|
|
||||||
*/
|
|
||||||
export interface WechatFriendDetail {
|
|
||||||
id: number;
|
|
||||||
avatar: string;
|
|
||||||
nickname: string;
|
|
||||||
region: string;
|
|
||||||
wechatId: string;
|
|
||||||
addDate: string;
|
|
||||||
tags: string[];
|
|
||||||
memo: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchWechatFriendDetail = async (wechatId: string): Promise<ApiResponse<WechatFriendDetail>> => {
|
|
||||||
try {
|
|
||||||
return get<ApiResponse<WechatFriendDetail>>(`/v1/wechats/${wechatId}/friend-detail`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取好友详情失败:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
10
Cunkebao/src/components/AccountSelection/api.ts
Normal file
10
Cunkebao/src/components/AccountSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取好友列表
|
||||||
|
export function getAccountList(params: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/workbench/account-list", params, "GET");
|
||||||
|
}
|
||||||
34
Cunkebao/src/components/AccountSelection/data.ts
Normal file
34
Cunkebao/src/components/AccountSelection/data.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// 账号对象类型
|
||||||
|
export interface AccountItem {
|
||||||
|
id: number;
|
||||||
|
userName: string;
|
||||||
|
realName: string;
|
||||||
|
departmentName: string;
|
||||||
|
avatar?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
//弹窗的
|
||||||
|
export interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
selectedOptions: AccountItem[];
|
||||||
|
onSelect: (options: AccountItem[]) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface AccountSelectionProps {
|
||||||
|
selectedOptions: AccountItem[];
|
||||||
|
onSelect: (options: AccountItem[]) => void;
|
||||||
|
accounts?: AccountItem[]; // 可选:用于在外层显示已选账号详情
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
onVisibleChange?: (visible: boolean) => void;
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (selectedOptions: AccountItem[]) => void;
|
||||||
|
}
|
||||||
231
Cunkebao/src/components/AccountSelection/index.module.scss
Normal file
231
Cunkebao/src/components/AccountSelection/index.module.scss
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.popupHeader {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.searchWrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.searchInput {
|
||||||
|
padding-left: 40px !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
padding-bottom: 8px !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.clearBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.friendListInner {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.friendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.radioWrapper {
|
||||||
|
margin-right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioSelected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #1890ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioUnselected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #e5e6eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioDot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
.friendInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.friendAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.friendDetail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.friendName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.friendId {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.friendCustomer {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
}
|
||||||
|
.confirmBtn {
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
139
Cunkebao/src/components/AccountSelection/index.tsx
Normal file
139
Cunkebao/src/components/AccountSelection/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
import { AccountItem, AccountSelectionProps } from "./data";
|
||||||
|
|
||||||
|
export default function AccountSelection({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
accounts: propAccounts = [],
|
||||||
|
placeholder = "选择账号",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: AccountSelectionProps) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
|
||||||
|
// 受控弹窗逻辑
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个账号`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除已选账号
|
||||||
|
const handleRemoveAccount = (id: number) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(d => d.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选账号列表窗口 */}
|
||||||
|
{showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(acc => (
|
||||||
|
<div
|
||||||
|
key={acc.id}
|
||||||
|
className={style.selectedListRow}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
【{acc.realName}】 {acc.userName}
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveAccount(acc.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible}
|
||||||
|
onVisibleChange={setRealVisible}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
onSelect={onSelect}
|
||||||
|
readonly={readonly}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
Cunkebao/src/components/AccountSelection/selectionPopup.tsx
Normal file
202
Cunkebao/src/components/AccountSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Popup } from "antd-mobile";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import { getAccountList } from "./api";
|
||||||
|
import { AccountItem, SelectionPopupProps } from "./data";
|
||||||
|
|
||||||
|
export default function SelectionPopup({
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: SelectionPopupProps) {
|
||||||
|
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalAccounts, setTotalAccounts] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 累积已加载过的账号,确保确认时能返回更完整的对象
|
||||||
|
const loadedAccountMapRef = useRef<Map<number, AccountItem>>(new Map());
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const fetchAccounts = async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = { page, limit: pageSize };
|
||||||
|
if (keyword.trim()) params.keyword = keyword.trim();
|
||||||
|
|
||||||
|
const response = await getAccountList(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setAccounts(response.list);
|
||||||
|
const total: number = response.total || response.list.length || 0;
|
||||||
|
setTotalAccounts(total);
|
||||||
|
setTotalPages(Math.max(1, Math.ceil(total / pageSize)));
|
||||||
|
|
||||||
|
// 累积到映射表
|
||||||
|
response.list.forEach((acc: AccountItem) => {
|
||||||
|
loadedAccountMapRef.current.set(acc.id, acc);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAccounts([]);
|
||||||
|
setTotalAccounts(0);
|
||||||
|
setTotalPages(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取账号列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountToggle = (account: AccountItem) => {
|
||||||
|
if (readonly || !onSelect) return;
|
||||||
|
const isSelected = selectedOptions.some(opt => opt.id === account.id);
|
||||||
|
const next = isSelected
|
||||||
|
? selectedOptions.filter(opt => opt.id !== account.id)
|
||||||
|
: selectedOptions.concat(account);
|
||||||
|
onSelect(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(selectedOptions);
|
||||||
|
}
|
||||||
|
onVisibleChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗打开时初始化数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery("");
|
||||||
|
loadedAccountMapRef.current.clear();
|
||||||
|
fetchAccounts(1, "");
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 搜索防抖
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
if (searchQuery === "") return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchAccounts(1, searchQuery);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible]);
|
||||||
|
|
||||||
|
// 页码变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || currentPage === 1) return;
|
||||||
|
fetchAccounts(currentPage, searchQuery);
|
||||||
|
}, [currentPage, visible, searchQuery]);
|
||||||
|
|
||||||
|
const selectedIdSet = useMemo(
|
||||||
|
() => new Set(selectedOptions.map(opt => opt.id)),
|
||||||
|
[selectedOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible && !readonly}
|
||||||
|
onMaskClick={() => onVisibleChange(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择账号"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索账号"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchAccounts(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
total={totalAccounts}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={selectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={() => onVisibleChange(false)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.friendList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : accounts.length > 0 ? (
|
||||||
|
<div className={style.friendListInner}>
|
||||||
|
{accounts.map(acc => (
|
||||||
|
<label
|
||||||
|
key={acc.id}
|
||||||
|
className={style.friendItem}
|
||||||
|
onClick={() => !readonly && handleAccountToggle(acc)}
|
||||||
|
>
|
||||||
|
<div className={style.radioWrapper}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
selectedIdSet.has(acc.id)
|
||||||
|
? style.radioSelected
|
||||||
|
: style.radioUnselected
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedIdSet.has(acc.id) && (
|
||||||
|
<div className={style.radioDot}></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.friendInfo}>
|
||||||
|
<div className={style.friendAvatar}>
|
||||||
|
{acc.avatar ? (
|
||||||
|
<img
|
||||||
|
src={acc.avatar}
|
||||||
|
alt={acc.userName}
|
||||||
|
className={style.avatarImg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(acc.userName?.charAt(0) ?? "?")
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.friendDetail}>
|
||||||
|
<div className={style.friendName}>{acc.userName}</div>
|
||||||
|
<div className={style.friendId}>
|
||||||
|
真实姓名: {acc.realName}
|
||||||
|
</div>
|
||||||
|
<div className={style.friendId}>
|
||||||
|
部门: {acc.departmentName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的账号`
|
||||||
|
: "没有找到账号"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { ChevronLeft, ArrowLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
interface BackButtonProps {
|
|
||||||
/** 返回按钮的样式变体 */
|
|
||||||
variant?: 'icon' | 'button' | 'text';
|
|
||||||
/** 自定义返回逻辑,如果不提供则使用navigate(-1) */
|
|
||||||
onBack?: () => void;
|
|
||||||
/** 按钮文本,仅在button和text变体时使用 */
|
|
||||||
text?: string;
|
|
||||||
/** 自定义CSS类名 */
|
|
||||||
className?: string;
|
|
||||||
/** 图标大小 */
|
|
||||||
iconSize?: number;
|
|
||||||
/** 是否显示图标 */
|
|
||||||
showIcon?: boolean;
|
|
||||||
/** 自定义图标 */
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用返回上一页按钮组件
|
|
||||||
* 使用React Router的navigate方法实现返回功能
|
|
||||||
*/
|
|
||||||
export const BackButton: React.FC<BackButtonProps> = ({
|
|
||||||
variant = 'icon',
|
|
||||||
onBack,
|
|
||||||
text = '返回',
|
|
||||||
className = '',
|
|
||||||
iconSize = 6,
|
|
||||||
showIcon = true,
|
|
||||||
icon
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (onBack) {
|
|
||||||
onBack();
|
|
||||||
} else {
|
|
||||||
navigate(-1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultIcon = variant === 'icon' ? (
|
|
||||||
<ChevronLeft className={`h-${iconSize} w-${iconSize}`} />
|
|
||||||
) : (
|
|
||||||
<ArrowLeft className={`h-${iconSize} w-${iconSize}`} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const buttonIcon = icon || (showIcon ? defaultIcon : null);
|
|
||||||
|
|
||||||
switch (variant) {
|
|
||||||
case 'icon':
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
className={`p-2 hover:bg-gray-100 rounded-lg transition-colors ${className}`}
|
|
||||||
title="返回上一页"
|
|
||||||
>
|
|
||||||
{buttonIcon}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'button':
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors ${className}`}
|
|
||||||
>
|
|
||||||
{buttonIcon}
|
|
||||||
{text}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'text':
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
className={`flex items-center gap-2 text-blue-600 hover:text-blue-700 transition-colors ${className}`}
|
|
||||||
>
|
|
||||||
{buttonIcon}
|
|
||||||
{text}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BackButton;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { Home, Users, LayoutGrid, User } from 'lucide-react';
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{
|
|
||||||
id: "home",
|
|
||||||
name: "首页",
|
|
||||||
href: "/",
|
|
||||||
icon: Home,
|
|
||||||
active: (pathname: string) => pathname === "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "scenarios",
|
|
||||||
name: "场景获客",
|
|
||||||
href: "/scenarios",
|
|
||||||
icon: Users,
|
|
||||||
active: (pathname: string) => pathname.startsWith("/scenarios"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "workspace",
|
|
||||||
name: "工作台",
|
|
||||||
href: "/workspace",
|
|
||||||
icon: LayoutGrid,
|
|
||||||
active: (pathname: string) => pathname.startsWith("/workspace"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "profile",
|
|
||||||
name: "我的",
|
|
||||||
href: "/profile",
|
|
||||||
icon: User,
|
|
||||||
active: (pathname: string) => pathname.startsWith("/profile"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface BottomNavProps {
|
|
||||||
activeTab?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BottomNav({ activeTab }: BottomNavProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-area-pb">
|
|
||||||
<div className="flex justify-around items-center h-16 max-w-md mx-auto">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const IconComponent = item.icon;
|
|
||||||
const isActive = activeTab ? activeTab === item.id : item.active(location.pathname);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
to={item.href}
|
|
||||||
className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${
|
|
||||||
isActive ? "text-blue-500" : "text-gray-500 hover:text-gray-900"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<IconComponent className="w-5 h-5" />
|
|
||||||
<span className="text-xs mt-1">{item.name}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Search, RefreshCw, Loader2 } from "lucide-react";
|
|
||||||
import { fetchContentLibraryList } from "@/api/content";
|
|
||||||
import { ContentLibrary } from "@/api/content";
|
|
||||||
import { useToast } from "@/components/ui/toast";
|
|
||||||
|
|
||||||
interface ContentLibrarySelectionDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
selectedLibraries: string[];
|
|
||||||
onSelect: (libraries: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContentLibrarySelectionDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
selectedLibraries,
|
|
||||||
onSelect,
|
|
||||||
}: ContentLibrarySelectionDialogProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
|
|
||||||
const [tempSelected, setTempSelected] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// 获取内容库列表
|
|
||||||
const fetchLibraries = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetchContentLibraryList(1, 100, searchQuery);
|
|
||||||
if (response.code === 200 && response.data) {
|
|
||||||
setLibraries(response.data.list);
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "获取内容库列表失败",
|
|
||||||
description: response.msg,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取内容库列表失败:", error);
|
|
||||||
toast({
|
|
||||||
title: "获取内容库列表失败",
|
|
||||||
description: "请检查网络连接",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [searchQuery, toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
fetchLibraries();
|
|
||||||
setTempSelected(selectedLibraries);
|
|
||||||
}
|
|
||||||
}, [open, selectedLibraries, fetchLibraries]);
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
fetchLibraries();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
if (tempSelected.length === libraries.length) {
|
|
||||||
setTempSelected([]);
|
|
||||||
} else {
|
|
||||||
setTempSelected(libraries.map((lib) => lib.id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLibraryToggle = (libraryId: string) => {
|
|
||||||
setTempSelected((prev) =>
|
|
||||||
prev.includes(libraryId)
|
|
||||||
? prev.filter((id) => id !== libraryId)
|
|
||||||
: [...prev, libraryId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDialogOpenChange = (open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
setTempSelected(selectedLibraries);
|
|
||||||
}
|
|
||||||
onOpenChange(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
onSelect(tempSelected);
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
|
||||||
<DialogContent className="flex flex-col bg-white">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>选择内容库</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 my-4">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="搜索内容库"
|
|
||||||
className="pl-9"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
已选择 {tempSelected.length} 个内容库
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
disabled={loading || libraries.length === 0}
|
|
||||||
>
|
|
||||||
{tempSelected.length === libraries.length ? "取消全选" : "全选"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto -mx-6 px-6 max-h-[400px]">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
|
||||||
加载中...
|
|
||||||
</div>
|
|
||||||
) : libraries.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
|
||||||
暂无数据
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
libraries.map((library) => (
|
|
||||||
<label
|
|
||||||
key={library.id}
|
|
||||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={tempSelected.includes(library.id)}
|
|
||||||
onChange={() => handleLibraryToggle(library.id)}
|
|
||||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">{library.name}</span>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{library.sourceType === 1
|
|
||||||
? "文本"
|
|
||||||
: library.sourceType === 2
|
|
||||||
? "图片"
|
|
||||||
: "视频"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 mt-1">
|
|
||||||
<div>创建人: {library.creatorName || "-"}</div>
|
|
||||||
<div>
|
|
||||||
更新时间:{" "}
|
|
||||||
{new Date(library.updateTime).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-4 pt-4 border-t">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
已选择 {tempSelected.length} 个内容库
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleConfirm}>
|
|
||||||
确定{tempSelected.length > 0 ? ` (${tempSelected.length})` : ""}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
5
Cunkebao/src/components/ContentSelection/api.ts
Normal file
5
Cunkebao/src/components/ContentSelection/api.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
export function getContentLibraryList(params: any) {
|
||||||
|
return request("/v1/content/library/list", params, "GET");
|
||||||
|
}
|
||||||
21
Cunkebao/src/components/ContentSelection/data.ts
Normal file
21
Cunkebao/src/components/ContentSelection/data.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// 内容库接口类型
|
||||||
|
export interface ContentItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface ContentSelectionProps {
|
||||||
|
selectedOptions: ContentItem[];
|
||||||
|
onSelect: (selectedItems: ContentItem[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
onVisibleChange?: (visible: boolean) => void;
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (selectedItems: ContentItem[]) => void;
|
||||||
|
}
|
||||||
117
Cunkebao/src/components/ContentSelection/index.module.scss
Normal file
117
Cunkebao/src/components/ContentSelection/index.module.scss
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.selectedListWindow {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedListRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.libraryList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.libraryListInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.libraryItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.checkboxWrapper {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.checkboxSelected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1677ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.checkboxUnselected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.checkboxDot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.libraryInfo {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.libraryHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.libraryName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.typeTag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1677ff;
|
||||||
|
border: 1px solid #1677ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
background: #f4f8ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.libraryMeta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.libraryDesc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
302
Cunkebao/src/components/ContentSelection/index.tsx
Normal file
302
Cunkebao/src/components/ContentSelection/index.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import { Popup, Checkbox } from "antd-mobile";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { getContentLibraryList } from "./api";
|
||||||
|
import { ContentItem, ContentSelectionProps } from "./data";
|
||||||
|
|
||||||
|
// 类型标签文本
|
||||||
|
const getTypeText = (type?: number) => {
|
||||||
|
if (type === 1) return "文本";
|
||||||
|
if (type === 2) return "图片";
|
||||||
|
if (type === 3) return "视频";
|
||||||
|
return "未知";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间格式化
|
||||||
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return "-";
|
||||||
|
return `${d.getFullYear()}/${(d.getMonth() + 1)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d
|
||||||
|
.getHours()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d
|
||||||
|
.getSeconds()
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ContentSelection({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "选择内容库",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: ContentSelectionProps) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
const [libraries, setLibraries] = useState<ContentItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalLibraries, setTotalLibraries] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 删除已选内容库
|
||||||
|
const handleRemoveLibrary = (id: number) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(c => c.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 受控弹窗逻辑
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery("");
|
||||||
|
setRealVisible(true);
|
||||||
|
fetchLibraries(1, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||||
|
useEffect(() => {
|
||||||
|
if (realVisible && currentPage !== 1) {
|
||||||
|
fetchLibraries(currentPage, searchQuery);
|
||||||
|
}
|
||||||
|
}, [currentPage, realVisible, searchQuery]);
|
||||||
|
|
||||||
|
// 搜索防抖
|
||||||
|
useEffect(() => {
|
||||||
|
if (!realVisible) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchLibraries(1, searchQuery);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, realVisible]);
|
||||||
|
|
||||||
|
// 获取内容库列表API
|
||||||
|
const fetchLibraries = async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
};
|
||||||
|
if (keyword.trim()) {
|
||||||
|
params.keyword = keyword.trim();
|
||||||
|
}
|
||||||
|
const response = await getContentLibraryList(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setLibraries(response.list);
|
||||||
|
setTotalLibraries(response.total || 0);
|
||||||
|
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取内容库列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理内容库选择
|
||||||
|
const handleLibraryToggle = (library: ContentItem) => {
|
||||||
|
if (readonly) return;
|
||||||
|
const newSelected = selectedOptions.some(c => c.id === library.id)
|
||||||
|
? selectedOptions.filter(c => c.id !== library.id)
|
||||||
|
: [...selectedOptions, library];
|
||||||
|
onSelect(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个内容库`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(selectedOptions);
|
||||||
|
}
|
||||||
|
setRealVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选内容库列表窗口 */}
|
||||||
|
{showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={style.selectedListRow}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name || item.id}
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveLibrary(item.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<Popup
|
||||||
|
visible={realVisible && !readonly}
|
||||||
|
onMaskClick={() => setRealVisible(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择内容库"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索内容库"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchLibraries(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
total={totalLibraries}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={selectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={() => setRealVisible(false)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.libraryList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : libraries.length > 0 ? (
|
||||||
|
<div className={style.libraryListInner}>
|
||||||
|
{libraries.map(item => (
|
||||||
|
<label key={item.id} className={style.libraryItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedOptions.map(c => c.id).includes(item.id)}
|
||||||
|
onChange={() => !readonly && handleLibraryToggle(item)}
|
||||||
|
disabled={readonly}
|
||||||
|
className={style.checkboxWrapper}
|
||||||
|
/>
|
||||||
|
<div className={style.libraryInfo}>
|
||||||
|
<div className={style.libraryHeader}>
|
||||||
|
<span className={style.libraryName}>{item.name}</span>
|
||||||
|
<span className={style.typeTag}>
|
||||||
|
{getTypeText(item.sourceType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.libraryMeta}>
|
||||||
|
<div>创建人: {item.creatorName || "-"}</div>
|
||||||
|
<div>更新时间: {formatDate(item.updateTime)}</div>
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<div className={style.libraryDesc}>
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的内容库`
|
||||||
|
: "没有找到内容库"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { fetchDeviceList } from "@/api/devices";
|
|
||||||
|
|
||||||
// 设备选择项接口
|
|
||||||
interface DeviceSelectionItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
imei: string;
|
|
||||||
wechatId: string;
|
|
||||||
status: "online" | "offline";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件属性接口
|
|
||||||
interface DeviceSelectionProps {
|
|
||||||
selectedDevices: string[];
|
|
||||||
onSelect: (devices: string[]) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeviceSelection({
|
|
||||||
selectedDevices,
|
|
||||||
onSelect,
|
|
||||||
placeholder = "选择设备",
|
|
||||||
className = "",
|
|
||||||
}: DeviceSelectionProps) {
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 获取设备列表,支持keyword
|
|
||||||
const fetchDevices = async (keyword: string = "") => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetchDeviceList(1, 100, keyword.trim() || undefined);
|
|
||||||
if (res && res.data && Array.isArray(res.data.list)) {
|
|
||||||
setDevices(
|
|
||||||
res.data.list.map((d) => ({
|
|
||||||
id: d.id?.toString() || "",
|
|
||||||
name: d.memo || d.imei || "",
|
|
||||||
imei: d.imei || "",
|
|
||||||
wechatId: d.wechatId || "",
|
|
||||||
status: d.alive === 1 ? "online" : "offline",
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取设备列表失败:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 打开弹窗时获取设备列表
|
|
||||||
const openDialog = () => {
|
|
||||||
setSearchQuery("");
|
|
||||||
setDialogOpen(true);
|
|
||||||
fetchDevices("");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 搜索防抖
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dialogOpen) return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
fetchDevices(searchQuery);
|
|
||||||
}, 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchQuery, dialogOpen]);
|
|
||||||
|
|
||||||
// 过滤设备(只保留状态过滤)
|
|
||||||
const filteredDevices = devices.filter((device) => {
|
|
||||||
const matchesStatus =
|
|
||||||
statusFilter === "all" ||
|
|
||||||
(statusFilter === "online" && device.status === "online") ||
|
|
||||||
(statusFilter === "offline" && device.status === "offline");
|
|
||||||
return matchesStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理设备选择
|
|
||||||
const handleDeviceToggle = (deviceId: string) => {
|
|
||||||
if (selectedDevices.includes(deviceId)) {
|
|
||||||
onSelect(selectedDevices.filter((id) => id !== deviceId));
|
|
||||||
} else {
|
|
||||||
onSelect([...selectedDevices, deviceId]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取显示文本
|
|
||||||
const getDisplayText = () => {
|
|
||||||
if (selectedDevices.length === 0) return "";
|
|
||||||
return `已选择 ${selectedDevices.length} 个设备`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 输入框 */}
|
|
||||||
<div className={`relative ${className}`}>
|
|
||||||
<Search className="absolute left-3 top-4 h-5 w-5 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="pl-10 h-14 rounded-xl border-gray-200 text-base"
|
|
||||||
readOnly
|
|
||||||
onClick={openDialog}
|
|
||||||
value={getDisplayText()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 设备选择弹窗 */}
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent
|
|
||||||
className="w-full h-full max-w-none max-h-none flex flex-col bg-white"
|
|
||||||
aria-describedby="device-selection-description"
|
|
||||||
>
|
|
||||||
<div id="device-selection-description" className="sr-only">
|
|
||||||
请选择一个或多个设备,支持搜索和筛选。
|
|
||||||
</div>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>选择设备</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 my-4">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="搜索设备IMEI/备注/微信号"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
className="w-32 h-10 rounded border border-gray-300 px-2 text-base"
|
|
||||||
>
|
|
||||||
<option value="all">全部状态</option>
|
|
||||||
<option value="online">在线</option>
|
|
||||||
<option value="offline">离线</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-gray-500">加载中...</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{filteredDevices.map((device) => (
|
|
||||||
<label
|
|
||||||
key={device.id}
|
|
||||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedDevices.includes(device.id)}
|
|
||||||
onCheckedChange={() => handleDeviceToggle(device.id)}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">{device.name}</span>
|
|
||||||
<div
|
|
||||||
className={`w-16 h-6 flex items-center justify-center text-xs ${
|
|
||||||
device.status === "online"
|
|
||||||
? "bg-green-500 text-white"
|
|
||||||
: "bg-gray-200 text-gray-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{device.status === "online" ? "在线" : "离线"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 mt-1">
|
|
||||||
<div>IMEI: {device.imei}</div>
|
|
||||||
<div>微信号: {device.wechatId}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-4 border-t">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
已选择 {selectedDevices.length} 个设备
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setDialogOpen(false)}>确定</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
10
Cunkebao/src/components/DeviceSelection/api.ts
Normal file
10
Cunkebao/src/components/DeviceSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
export function getDeviceList(params: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/devices", params, "GET");
|
||||||
|
}
|
||||||
26
Cunkebao/src/components/DeviceSelection/data.ts
Normal file
26
Cunkebao/src/components/DeviceSelection/data.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// 设备选择项接口
|
||||||
|
export interface DeviceSelectionItem {
|
||||||
|
id: number;
|
||||||
|
memo: string;
|
||||||
|
imei: string;
|
||||||
|
wechatId: string;
|
||||||
|
status: "online" | "offline";
|
||||||
|
wxid?: string;
|
||||||
|
nickname?: string;
|
||||||
|
usedInPlans?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface DeviceSelectionProps {
|
||||||
|
selectedOptions: DeviceSelectionItem[];
|
||||||
|
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
mode?: "input" | "dialog"; // 新增,默认input
|
||||||
|
open?: boolean; // 仅mode=dialog时生效
|
||||||
|
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
|
||||||
|
selectedListMaxHeight?: number; // 新增,已选列表最大高度,默认500
|
||||||
|
showInput?: boolean; // 新增
|
||||||
|
showSelectedList?: boolean; // 新增
|
||||||
|
readonly?: boolean; // 新增
|
||||||
|
}
|
||||||
182
Cunkebao/src/components/DeviceSelection/index.module.scss
Normal file
182
Cunkebao/src/components/DeviceSelection/index.module.scss
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupHeader {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.popupSearchRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.popupSearchInputWrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.popupSearchInput {
|
||||||
|
padding-left: 36px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 15px;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.statusSelect {
|
||||||
|
width: 120px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.deviceList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.deviceListInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.deviceItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.deviceCheckbox {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.deviceInfo {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.deviceInfoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.deviceName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.statusOnline {
|
||||||
|
width: 56px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #52c41a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.statusOffline {
|
||||||
|
width: 56px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #e5e6eb;
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.deviceInfoDetail {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.refreshBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
139
Cunkebao/src/components/DeviceSelection/index.tsx
Normal file
139
Cunkebao/src/components/DeviceSelection/index.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined } from "@ant-design/icons";
|
||||||
|
import { Input, Button } from "antd";
|
||||||
|
import { DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { DeviceSelectionProps } from "./data";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
|
||||||
|
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "选择设备",
|
||||||
|
className = "",
|
||||||
|
mode = "input",
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selectedListMaxHeight = 300, // 默认300
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
}) => {
|
||||||
|
// 弹窗控制
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
const isDialog = mode === "dialog";
|
||||||
|
const realVisible = isDialog ? !!open : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (isDialog && onOpenChange) onOpenChange(v);
|
||||||
|
if (!isDialog) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个设备`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除已选设备
|
||||||
|
const handleRemoveDevice = (id: number) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(v => v.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* mode=input 显示输入框,mode=dialog不显示 */}
|
||||||
|
{mode === "input" && showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选设备列表窗口 */}
|
||||||
|
{mode === "input" && showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(device => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className={style.selectedListRow}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderBottom: "1px solid #f0f0f0",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
【 {device.memo}】 - {device.wechatId}
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveDevice(device.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible && !readonly}
|
||||||
|
onClose={() => setRealVisible(false)}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeviceSelection;
|
||||||
198
Cunkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
198
Cunkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Checkbox, Popup } from "antd-mobile";
|
||||||
|
import { getDeviceList } from "./api";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { DeviceSelectionItem } from "./data";
|
||||||
|
|
||||||
|
interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedOptions: DeviceSelectionItem[];
|
||||||
|
onSelect: (devices: DeviceSelectionItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
// 设备数据
|
||||||
|
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
// 获取设备列表,支持keyword和分页
|
||||||
|
const fetchDevices = useCallback(
|
||||||
|
async (keyword: string = "", page: number = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getDeviceList({
|
||||||
|
page,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
keyword: keyword.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (res && Array.isArray(res.list)) {
|
||||||
|
setDevices(
|
||||||
|
res.list.map((d: any) => ({
|
||||||
|
id: d.id?.toString() || "",
|
||||||
|
memo: d.memo || d.imei || "",
|
||||||
|
imei: d.imei || "",
|
||||||
|
wechatId: d.wechatId || "",
|
||||||
|
status: d.alive === 1 ? "online" : "offline",
|
||||||
|
wxid: d.wechatId || "",
|
||||||
|
nickname: d.nickname || "",
|
||||||
|
usedInPlans: d.usedInPlans || 0,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取设备列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 打开弹窗时获取第一页
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setSearchQuery("");
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchDevices("", 1);
|
||||||
|
}
|
||||||
|
}, [visible, fetchDevices]);
|
||||||
|
|
||||||
|
// 搜索防抖
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchDevices(searchQuery, 1);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible, fetchDevices]);
|
||||||
|
|
||||||
|
// 翻页时重新请求
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
fetchDevices(searchQuery, currentPage);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
// 过滤设备(只保留状态过滤)
|
||||||
|
const filteredDevices = devices.filter(device => {
|
||||||
|
const matchesStatus =
|
||||||
|
statusFilter === "all" ||
|
||||||
|
(statusFilter === "online" && device.status === "online") ||
|
||||||
|
(statusFilter === "offline" && device.status === "offline");
|
||||||
|
return matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
// 处理设备选择
|
||||||
|
const handleDeviceToggle = (device: DeviceSelectionItem) => {
|
||||||
|
if (selectedOptions.some(v => v.id === device.id)) {
|
||||||
|
onSelect(selectedOptions.filter(v => v.id !== device.id));
|
||||||
|
} else {
|
||||||
|
const newSelectedOptions = [...selectedOptions, device];
|
||||||
|
onSelect(newSelectedOptions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible}
|
||||||
|
onMaskClick={onClose}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
closeOnMaskClick={false}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择设备"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索设备IMEI/备注/微信号"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchDevices(searchQuery, currentPage)}
|
||||||
|
showTabs={true}
|
||||||
|
tabsConfig={{
|
||||||
|
activeKey: statusFilter,
|
||||||
|
onChange: setStatusFilter,
|
||||||
|
tabs: [
|
||||||
|
{ title: "全部", key: "all" },
|
||||||
|
{ title: "在线", key: "online" },
|
||||||
|
{ title: "离线", key: "offline" },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
total={total}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={selectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={onClose}
|
||||||
|
onConfirm={onClose}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.deviceList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.deviceListInner}>
|
||||||
|
{filteredDevices.map(device => (
|
||||||
|
<label key={device.id} className={style.deviceItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedOptions.some(v => v.id === device.id)}
|
||||||
|
onChange={() => handleDeviceToggle(device)}
|
||||||
|
className={style.deviceCheckbox}
|
||||||
|
/>
|
||||||
|
<div className={style.deviceInfo}>
|
||||||
|
<div className={style.deviceInfoRow}>
|
||||||
|
<span className={style.deviceName}>{device.memo}</span>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
device.status === "online"
|
||||||
|
? style.statusOnline
|
||||||
|
: style.statusOffline
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{device.status === "online" ? "在线" : "离线"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.deviceInfoDetail}>
|
||||||
|
<div>IMEI: {device.imei}</div>
|
||||||
|
<div>微信号: {device.wechatId}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionPopup;
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Search, RefreshCw, Loader2 } from "lucide-react";
|
|
||||||
import { fetchDeviceList } from "@/api/devices";
|
|
||||||
import { ServerDevice } from "@/types/device";
|
|
||||||
import { useToast } from "@/components/ui/toast";
|
|
||||||
|
|
||||||
interface Device {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
imei: string;
|
|
||||||
wxid: string;
|
|
||||||
status: "online" | "offline";
|
|
||||||
usedInPlans: number;
|
|
||||||
nickname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeviceSelectionDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
selectedDevices: string[];
|
|
||||||
onSelect: (devices: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeviceSelectionDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
selectedDevices,
|
|
||||||
onSelect,
|
|
||||||
}: DeviceSelectionDialogProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
|
||||||
|
|
||||||
// 获取设备列表,支持keyword
|
|
||||||
const fetchDevices = useCallback(
|
|
||||||
async (keyword: string = "") => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetchDeviceList(
|
|
||||||
1,
|
|
||||||
100,
|
|
||||||
keyword.trim() || undefined
|
|
||||||
);
|
|
||||||
if (response.code === 200 && response.data) {
|
|
||||||
// 转换服务端数据格式为组件需要的格式
|
|
||||||
const convertedDevices: Device[] = response.data.list.map(
|
|
||||||
(serverDevice: ServerDevice) => ({
|
|
||||||
id: serverDevice.id.toString(),
|
|
||||||
name: serverDevice.memo || `设备 ${serverDevice.id}`,
|
|
||||||
imei: serverDevice.imei,
|
|
||||||
wxid: serverDevice.wechatId || "",
|
|
||||||
status: serverDevice.alive === 1 ? "online" : "offline",
|
|
||||||
usedInPlans: 0, // 这个字段需要从其他API获取
|
|
||||||
nickname: serverDevice.nickname || "",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setDevices(convertedDevices);
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "获取设备列表失败",
|
|
||||||
description: response.msg,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取设备列表失败:", error);
|
|
||||||
toast({
|
|
||||||
title: "获取设备列表失败",
|
|
||||||
description: "请检查网络连接",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[toast]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 打开弹窗时获取设备列表
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
fetchDevices("");
|
|
||||||
}
|
|
||||||
}, [open, fetchDevices]);
|
|
||||||
|
|
||||||
// 搜索防抖
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
fetchDevices(searchQuery);
|
|
||||||
}, 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchQuery, open, fetchDevices]);
|
|
||||||
|
|
||||||
// 过滤设备(只保留状态过滤)
|
|
||||||
const filteredDevices = devices.filter((device) => {
|
|
||||||
const matchesStatus =
|
|
||||||
statusFilter === "all" ||
|
|
||||||
(statusFilter === "online" && device.status === "online") ||
|
|
||||||
(statusFilter === "offline" && device.status === "offline");
|
|
||||||
return matchesStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDeviceSelect = (deviceId: string) => {
|
|
||||||
if (selectedDevices.includes(deviceId)) {
|
|
||||||
onSelect(selectedDevices.filter((id) => id !== deviceId));
|
|
||||||
} else {
|
|
||||||
onSelect([...selectedDevices, deviceId]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent
|
|
||||||
className="w-full h-full max-w-none max-h-none flex flex-col bg-white"
|
|
||||||
aria-describedby="device-selection-dialog-description"
|
|
||||||
>
|
|
||||||
<div id="device-selection-dialog-description" className="sr-only">
|
|
||||||
请选择一个或多个设备,支持搜索和筛选。
|
|
||||||
</div>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>选择设备</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 my-4">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder="搜索设备IMEI/备注"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
className="w-32 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">全部状态</option>
|
|
||||||
<option value="online">在线</option>
|
|
||||||
<option value="offline">离线</option>
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => fetchDevices(searchQuery)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
|
||||||
加载中...
|
|
||||||
</div>
|
|
||||||
) : filteredDevices.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-500">
|
|
||||||
暂无数据
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredDevices.map((device) => (
|
|
||||||
<label
|
|
||||||
key={device.id}
|
|
||||||
className="flex items-start space-x-3 p-4 rounded-lg hover:bg-gray-50 cursor-pointer border"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedDevices.includes(device.id)}
|
|
||||||
onChange={() => handleDeviceSelect(device.id)}
|
|
||||||
className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-medium">{device.name}</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
device.status === "online" ? "default" : "secondary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{device.status === "online" ? "在线" : "离线"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 mt-1">
|
|
||||||
<div>IMEI: {device.imei}</div>
|
|
||||||
<div>微信号: {device.wxid || "-"}</div>
|
|
||||||
<div>昵称: {device.nickname || "-"}</div>
|
|
||||||
</div>
|
|
||||||
{device.usedInPlans > 0 && (
|
|
||||||
<div className="text-sm text-orange-500 mt-1">
|
|
||||||
已用于 {device.usedInPlans} 个计划
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-4 pt-4 border-t">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
已选择 {selectedDevices.length} 个设备
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => onOpenChange(false)}>
|
|
||||||
确定
|
|
||||||
{selectedDevices.length > 0 ? ` (${selectedDevices.length})` : ""}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Search, X } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { get } from "@/api/request";
|
|
||||||
|
|
||||||
// 微信好友接口类型
|
|
||||||
interface WechatFriend {
|
|
||||||
id: string;
|
|
||||||
nickname: string;
|
|
||||||
wechatId: string;
|
|
||||||
avatar: string;
|
|
||||||
customer: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 好友列表API响应类型
|
|
||||||
interface FriendsResponse {
|
|
||||||
code: number;
|
|
||||||
msg: string;
|
|
||||||
data: {
|
|
||||||
list: Array<{
|
|
||||||
id: number;
|
|
||||||
nickname: string;
|
|
||||||
wechatId: string;
|
|
||||||
avatar?: string;
|
|
||||||
customer?: string;
|
|
||||||
}>;
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取好友列表API函数 - 添加 keyword 参数
|
|
||||||
const fetchFriendsList = async (params: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
deviceIds?: string[];
|
|
||||||
keyword?: string;
|
|
||||||
}): Promise<FriendsResponse> => {
|
|
||||||
if (params.deviceIds && params.deviceIds.length === 0) {
|
|
||||||
return {
|
|
||||||
code: 200,
|
|
||||||
msg: "success",
|
|
||||||
data: {
|
|
||||||
list: [],
|
|
||||||
total: 0,
|
|
||||||
page: params.page,
|
|
||||||
limit: params.limit,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const deviceIdsParam = params?.deviceIds?.join(",") || "";
|
|
||||||
const keywordParam = params?.keyword
|
|
||||||
? `&keyword=${encodeURIComponent(params.keyword)}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return get<FriendsResponse>(
|
|
||||||
`/v1/friend?page=${params.page}&limit=${params.limit}&deviceIds=${deviceIdsParam}${keywordParam}`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 组件属性接口
|
|
||||||
interface FriendSelectionProps {
|
|
||||||
selectedFriends: string[];
|
|
||||||
onSelect: (friends: string[]) => void;
|
|
||||||
onSelectDetail?: (friends: WechatFriend[]) => void; // 新增
|
|
||||||
deviceIds?: string[];
|
|
||||||
enableDeviceFilter?: boolean; // 新增开关,默认true
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FriendSelection({
|
|
||||||
selectedFriends,
|
|
||||||
onSelect,
|
|
||||||
onSelectDetail,
|
|
||||||
deviceIds = [],
|
|
||||||
enableDeviceFilter = true,
|
|
||||||
placeholder = "选择微信好友",
|
|
||||||
className = "",
|
|
||||||
}: FriendSelectionProps) {
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [friends, setFriends] = useState<WechatFriend[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [totalFriends, setTotalFriends] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 打开弹窗并请求第一页好友
|
|
||||||
const openDialog = () => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
setSearchQuery(""); // 重置搜索关键词
|
|
||||||
setDialogOpen(true);
|
|
||||||
fetchFriends(1, "");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
|
||||||
useEffect(() => {
|
|
||||||
if (dialogOpen && currentPage !== 1) {
|
|
||||||
fetchFriends(currentPage, searchQuery);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// 搜索防抖
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dialogOpen) return;
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setCurrentPage(1); // 重置到第一页
|
|
||||||
fetchFriends(1, searchQuery);
|
|
||||||
}, 500); // 500 防抖
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchQuery, dialogOpen]);
|
|
||||||
|
|
||||||
// 获取好友列表API - 添加 keyword 参数
|
|
||||||
const fetchFriends = async (page: number, keyword: string = "") => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
let res;
|
|
||||||
if (enableDeviceFilter) {
|
|
||||||
if (deviceIds.length === 0) {
|
|
||||||
setFriends([]);
|
|
||||||
setTotalFriends(0);
|
|
||||||
setTotalPages(1);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res = await fetchFriendsList({
|
|
||||||
page,
|
|
||||||
limit: 20,
|
|
||||||
deviceIds: deviceIds,
|
|
||||||
keyword: keyword.trim() || undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res = await fetchFriendsList({
|
|
||||||
page,
|
|
||||||
limit: 20,
|
|
||||||
keyword: keyword.trim() || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res && res.code === 200 && res.data) {
|
|
||||||
setFriends(
|
|
||||||
res.data.list.map((friend) => ({
|
|
||||||
id: friend.id?.toString() || "",
|
|
||||||
nickname: friend.nickname || "",
|
|
||||||
wechatId: friend.wechatId || "",
|
|
||||||
avatar: friend.avatar || "",
|
|
||||||
customer: friend.customer || "",
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
setTotalFriends(res.data.total || 0);
|
|
||||||
setTotalPages(Math.ceil((res.data.total || 0) / 20));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取好友列表失败:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理好友选择
|
|
||||||
const handleFriendToggle = (friendId: string) => {
|
|
||||||
let newIds: string[];
|
|
||||||
if (selectedFriends.includes(friendId)) {
|
|
||||||
newIds = selectedFriends.filter((id) => id !== friendId);
|
|
||||||
} else {
|
|
||||||
newIds = [...selectedFriends, friendId];
|
|
||||||
}
|
|
||||||
onSelect(newIds);
|
|
||||||
if (onSelectDetail) {
|
|
||||||
const selectedObjs = friends.filter((f) => newIds.includes(f.id));
|
|
||||||
onSelectDetail(selectedObjs);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取显示文本
|
|
||||||
const getDisplayText = () => {
|
|
||||||
if (selectedFriends.length === 0) return "";
|
|
||||||
return `已选择 ${selectedFriends.length} 个好友`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清空搜索
|
|
||||||
const handleClearSearch = () => {
|
|
||||||
setSearchQuery("");
|
|
||||||
setCurrentPage(1);
|
|
||||||
fetchFriends(1, "");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 输入框 */}
|
|
||||||
<div className={`relative ${className}`}>
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
|
||||||
readOnly
|
|
||||||
onClick={openDialog}
|
|
||||||
value={getDisplayText()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 微信好友选择弹窗 */}
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent
|
|
||||||
className="w-full h-full max-w-none max-h-none flex flex-col p-0 gap-0 overflow-hidden bg-white"
|
|
||||||
aria-describedby="friend-selection-description"
|
|
||||||
>
|
|
||||||
<div id="friend-selection-description" className="sr-only">
|
|
||||||
请选择一个或多个微信好友,支持搜索和分页。
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<DialogTitle className="text-center text-xl font-medium mb-6">
|
|
||||||
选择微信好友
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<div className="relative mb-4">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索好友"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10 py-2 rounded-full border-gray-200"
|
|
||||||
/>
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
{searchQuery && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
|
|
||||||
onClick={handleClearSearch}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-gray-500">加载中...</div>
|
|
||||||
</div>
|
|
||||||
) : friends.length > 0 ? (
|
|
||||||
<div className="divide-y">
|
|
||||||
{friends.map((friend) => (
|
|
||||||
<label
|
|
||||||
key={friend.id}
|
|
||||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
|
||||||
onClick={() => handleFriendToggle(friend.id)}
|
|
||||||
>
|
|
||||||
<div className="mr-3 flex items-center justify-center">
|
|
||||||
<div
|
|
||||||
className={`w-5 h-5 rounded-full border ${
|
|
||||||
selectedFriends.includes(friend.id)
|
|
||||||
? "border-blue-600"
|
|
||||||
: "border-gray-300"
|
|
||||||
} flex items-center justify-center`}
|
|
||||||
>
|
|
||||||
{selectedFriends.includes(friend.id) && (
|
|
||||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3 flex-1">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
|
|
||||||
{friend.avatar ? (
|
|
||||||
<img
|
|
||||||
src={friend.avatar}
|
|
||||||
alt={friend.nickname}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
friend.nickname.charAt(0)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">{friend.nickname}</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
微信ID: {friend.wechatId}
|
|
||||||
</div>
|
|
||||||
{friend.customer && (
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
归属客户: {friend.customer}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-gray-500">
|
|
||||||
{deviceIds.length === 0
|
|
||||||
? "请先选择设备"
|
|
||||||
: searchQuery
|
|
||||||
? `没有找到包含"${searchQuery}"的好友`
|
|
||||||
: "没有找到好友"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
总计 {totalFriends} 个好友
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1 || loading}
|
|
||||||
className="px-2 py-0 h-8 min-w-0"
|
|
||||||
>
|
|
||||||
<
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm">
|
|
||||||
{currentPage} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage(Math.min(totalPages, currentPage + 1))
|
|
||||||
}
|
|
||||||
disabled={currentPage === totalPages || loading}
|
|
||||||
className="px-2 py-0 h-8 min-w-0"
|
|
||||||
>
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDialogOpen(false)}
|
|
||||||
className="px-6 rounded-full border-gray-300"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
|
|
||||||
>
|
|
||||||
确定 ({selectedFriends.length})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
11
Cunkebao/src/components/FriendSelection/api.ts
Normal file
11
Cunkebao/src/components/FriendSelection/api.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取好友列表
|
||||||
|
export function getFriendList(params: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
deviceIds?: string; // 逗号分隔
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/friend", params, "GET");
|
||||||
|
}
|
||||||
27
Cunkebao/src/components/FriendSelection/data.ts
Normal file
27
Cunkebao/src/components/FriendSelection/data.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface FriendSelectionItem {
|
||||||
|
id: number;
|
||||||
|
wechatId: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface FriendSelectionProps {
|
||||||
|
selectedOptions?: FriendSelectionItem[];
|
||||||
|
onSelect: (friends: FriendSelectionItem[]) => void;
|
||||||
|
deviceIds?: string[];
|
||||||
|
enableDeviceFilter?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean; // 新增
|
||||||
|
onVisibleChange?: (visible: boolean) => void; // 新增
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: number[],
|
||||||
|
selectedItems: FriendSelectionItem[],
|
||||||
|
) => void; // 新增
|
||||||
|
}
|
||||||
246
Cunkebao/src/components/FriendSelection/index.module.scss
Normal file
246
Cunkebao/src/components/FriendSelection/index.module.scss
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.selectedListRow {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.selectedListRowContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.selectedListRowContentText {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.popupHeader {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.searchWrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.searchInput {
|
||||||
|
padding-left: 40px !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
padding-bottom: 8px !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.clearBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friendList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.friendListInner {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.friendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.radioWrapper {
|
||||||
|
margin-right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioSelected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #1890ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioUnselected {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #e5e6eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.radioDot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
.friendInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.friendAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.friendDetail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.friendName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.friendId {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.friendCustomer {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
}
|
||||||
|
.confirmBtn {
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
140
Cunkebao/src/components/FriendSelection/index.tsx
Normal file
140
Cunkebao/src/components/FriendSelection/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import { Avatar } from "antd-mobile";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import { FriendSelectionProps } from "./data";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
|
||||||
|
export default function FriendSelection({
|
||||||
|
selectedOptions = [],
|
||||||
|
onSelect,
|
||||||
|
deviceIds = [],
|
||||||
|
enableDeviceFilter = true,
|
||||||
|
placeholder = "选择微信好友",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: FriendSelectionProps) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
// 内部弹窗交给 selectionPopup 处理
|
||||||
|
|
||||||
|
// 受控弹窗逻辑
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (!selectedOptions || selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个好友`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除已选好友
|
||||||
|
const handleRemoveFriend = (id: number) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect((selectedOptions || []).filter(v => v.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗确认回调
|
||||||
|
const handleConfirm = (
|
||||||
|
selectedIds: number[],
|
||||||
|
selectedItems: typeof selectedOptions,
|
||||||
|
) => {
|
||||||
|
onSelect(selectedItems);
|
||||||
|
if (onConfirm) onConfirm(selectedIds, selectedItems);
|
||||||
|
setRealVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选好友列表窗口 */}
|
||||||
|
{showSelectedList && (selectedOptions || []).length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(selectedOptions || []).map(friend => (
|
||||||
|
<div key={friend.id} className={style.selectedListRow}>
|
||||||
|
<div className={style.selectedListRowContent}>
|
||||||
|
<Avatar src={friend.avatar} />
|
||||||
|
<div className={style.selectedListRowContentText}>
|
||||||
|
<div>{friend.nickname}</div>
|
||||||
|
<div>{friend.wechatId}</div>
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveFriend(friend.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible && !readonly}
|
||||||
|
onVisibleChange={setRealVisible}
|
||||||
|
selectedOptions={selectedOptions || []}
|
||||||
|
onSelect={onSelect}
|
||||||
|
deviceIds={deviceIds}
|
||||||
|
enableDeviceFilter={enableDeviceFilter}
|
||||||
|
readonly={readonly}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
Cunkebao/src/components/FriendSelection/selectionPopup.tsx
Normal file
213
Cunkebao/src/components/FriendSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Popup, Checkbox } from "antd-mobile";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { getFriendList } from "./api";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import type { FriendSelectionItem } from "./data";
|
||||||
|
|
||||||
|
interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
selectedOptions: FriendSelectionItem[];
|
||||||
|
onSelect: (friends: FriendSelectionItem[]) => void;
|
||||||
|
deviceIds?: string[];
|
||||||
|
enableDeviceFilter?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: number[],
|
||||||
|
selectedItems: FriendSelectionItem[],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
deviceIds = [],
|
||||||
|
enableDeviceFilter = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const [friends, setFriends] = useState<FriendSelectionItem[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalFriends, setTotalFriends] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 获取好友列表API
|
||||||
|
const fetchFriends = useCallback(
|
||||||
|
async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (keyword.trim()) {
|
||||||
|
params.keyword = keyword.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableDeviceFilter && deviceIds.length > 0) {
|
||||||
|
params.deviceIds = deviceIds.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getFriendList(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setFriends(response.list);
|
||||||
|
setTotalFriends(response.total || 0);
|
||||||
|
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取好友列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deviceIds, enableDeviceFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理好友选择
|
||||||
|
const handleFriendToggle = (friend: FriendSelectionItem) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
const newSelectedFriends = selectedOptions.some(f => f.id === friend.id)
|
||||||
|
? selectedOptions.filter(f => f.id !== friend.id)
|
||||||
|
: selectedOptions.concat(friend);
|
||||||
|
|
||||||
|
onSelect(newSelectedFriends);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(
|
||||||
|
selectedOptions.map(v => v.id),
|
||||||
|
selectedOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onVisibleChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗打开时初始化
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery("");
|
||||||
|
fetchFriends(1, "");
|
||||||
|
}
|
||||||
|
}, [visible]); // 只在弹窗开启时请求
|
||||||
|
|
||||||
|
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || searchQuery === "") return; // 弹窗关闭或搜索词为空时不请求
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchFriends(1, searchQuery);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible]);
|
||||||
|
|
||||||
|
// 页码变化时请求数据(只在弹窗打开且页码不是1时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || currentPage === 1) return; // 弹窗关闭或第一页时不请求
|
||||||
|
fetchFriends(currentPage, searchQuery);
|
||||||
|
}, [currentPage, visible, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible && !readonly}
|
||||||
|
onMaskClick={() => onVisibleChange(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择微信好友"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索好友"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchFriends(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
total={totalFriends}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={selectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={() => onVisibleChange(false)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.friendList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : friends.length > 0 ? (
|
||||||
|
<div className={style.friendListInner}>
|
||||||
|
{friends.map(friend => (
|
||||||
|
<div key={friend.id} className={style.friendItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedOptions.some(f => f.id === friend.id)}
|
||||||
|
onChange={() => !readonly && handleFriendToggle(friend)}
|
||||||
|
disabled={readonly}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<div className={style.friendInfo}>
|
||||||
|
<div className={style.friendAvatar}>
|
||||||
|
{friend.avatar ? (
|
||||||
|
<img
|
||||||
|
src={friend.avatar}
|
||||||
|
alt={friend.nickname}
|
||||||
|
className={style.avatarImg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
friend.nickname.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.friendDetail}>
|
||||||
|
<div className={style.friendName}>{friend.nickname}</div>
|
||||||
|
<div className={style.friendId}>
|
||||||
|
微信ID: {friend.wechatId}
|
||||||
|
</div>
|
||||||
|
{friend.customer && (
|
||||||
|
<div className={style.friendCustomer}>
|
||||||
|
归属客户: {friend.customer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{deviceIds.length === 0
|
||||||
|
? "请先选择设备"
|
||||||
|
: searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的好友`
|
||||||
|
: "没有找到好友"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectionPopup;
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Search, X } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { get } from "@/api/request";
|
|
||||||
|
|
||||||
// 群组接口类型
|
|
||||||
interface WechatGroup {
|
|
||||||
id: string;
|
|
||||||
chatroomId: string;
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
ownerWechatId: string;
|
|
||||||
ownerNickname: string;
|
|
||||||
ownerAvatar: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupsResponse {
|
|
||||||
code: number;
|
|
||||||
msg: string;
|
|
||||||
data: {
|
|
||||||
list: Array<{
|
|
||||||
id: number;
|
|
||||||
chatroomId: string;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
ownerWechatId?: string;
|
|
||||||
ownerNickname?: string;
|
|
||||||
ownerAvatar?: string;
|
|
||||||
}>;
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改:支持keyword参数
|
|
||||||
const fetchGroupsList = async (params: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
keyword?: string;
|
|
||||||
}): Promise<GroupsResponse> => {
|
|
||||||
const keywordParam = params.keyword
|
|
||||||
? `&keyword=${encodeURIComponent(params.keyword)}`
|
|
||||||
: "";
|
|
||||||
return get<GroupsResponse>(
|
|
||||||
`/v1/chatroom?page=${params.page}&limit=${params.limit}${keywordParam}`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GroupSelectionProps {
|
|
||||||
selectedGroups: string[];
|
|
||||||
onSelect: (groups: string[]) => void;
|
|
||||||
onSelectDetail?: (groups: WechatGroup[]) => void; // 新增
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GroupSelection({
|
|
||||||
selectedGroups,
|
|
||||||
onSelect,
|
|
||||||
onSelectDetail,
|
|
||||||
placeholder = "选择群聊",
|
|
||||||
className = "",
|
|
||||||
}: GroupSelectionProps) {
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [groups, setGroups] = useState<WechatGroup[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [totalGroups, setTotalGroups] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// 打开弹窗并请求第一页群组
|
|
||||||
const openDialog = () => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
setSearchQuery(""); // 重置搜索关键词
|
|
||||||
setDialogOpen(true);
|
|
||||||
fetchGroups(1, "");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
|
||||||
useEffect(() => {
|
|
||||||
if (dialogOpen && currentPage !== 1) {
|
|
||||||
fetchGroups(currentPage, searchQuery);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// 搜索防抖
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dialogOpen) return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
fetchGroups(1, searchQuery);
|
|
||||||
}, 500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [searchQuery, dialogOpen]);
|
|
||||||
|
|
||||||
// 获取群组列表API - 支持keyword
|
|
||||||
const fetchGroups = async (page: number, keyword: string = "") => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetchGroupsList({
|
|
||||||
page,
|
|
||||||
limit: 20,
|
|
||||||
keyword: keyword.trim() || undefined,
|
|
||||||
});
|
|
||||||
if (res && res.code === 200 && res.data) {
|
|
||||||
setGroups(
|
|
||||||
res.data.list.map((group) => ({
|
|
||||||
id: group.id?.toString() || "",
|
|
||||||
chatroomId: group.chatroomId || "",
|
|
||||||
name: group.name || "",
|
|
||||||
avatar: group.avatar || "",
|
|
||||||
ownerWechatId: group.ownerWechatId || "",
|
|
||||||
ownerNickname: group.ownerNickname || "",
|
|
||||||
ownerAvatar: group.ownerAvatar || "",
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
setTotalGroups(res.data.total || 0);
|
|
||||||
setTotalPages(Math.ceil((res.data.total || 0) / 20));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取群组列表失败:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理群组选择
|
|
||||||
const handleGroupToggle = (groupId: string) => {
|
|
||||||
let newIds: string[];
|
|
||||||
if (selectedGroups.includes(groupId)) {
|
|
||||||
newIds = selectedGroups.filter((id) => id !== groupId);
|
|
||||||
} else {
|
|
||||||
newIds = [...selectedGroups, groupId];
|
|
||||||
}
|
|
||||||
onSelect(newIds);
|
|
||||||
if (onSelectDetail) {
|
|
||||||
const selectedObjs = groups.filter((g) => newIds.includes(g.id));
|
|
||||||
onSelectDetail(selectedObjs);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取显示文本
|
|
||||||
const getDisplayText = () => {
|
|
||||||
if (selectedGroups.length === 0) return "";
|
|
||||||
return `已选择 ${selectedGroups.length} 个群聊`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清空搜索
|
|
||||||
const handleClearSearch = () => {
|
|
||||||
setSearchQuery("");
|
|
||||||
setCurrentPage(1);
|
|
||||||
fetchGroups(1, "");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 输入框 */}
|
|
||||||
<div className={`relative ${className}`}>
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
|
||||||
readOnly
|
|
||||||
onClick={openDialog}
|
|
||||||
value={getDisplayText()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 群组选择弹窗 */}
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent
|
|
||||||
className="w-full h-full max-w-none max-h-none flex flex-col p-0 gap-0 overflow-hidden bg-white"
|
|
||||||
aria-describedby="group-selection-description"
|
|
||||||
>
|
|
||||||
<div id="group-selection-description" className="sr-only">
|
|
||||||
请选择一个或多个群聊,支持搜索和分页。
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<DialogTitle className="text-center text-xl font-medium mb-6">
|
|
||||||
选择群聊
|
|
||||||
</DialogTitle>
|
|
||||||
<div className="relative mb-4">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索群聊"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10 py-2 rounded-full border-gray-200"
|
|
||||||
/>
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
{searchQuery && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
|
|
||||||
onClick={handleClearSearch}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-gray-500">加载中...</div>
|
|
||||||
</div>
|
|
||||||
) : groups.length > 0 ? (
|
|
||||||
<div className="divide-y">
|
|
||||||
{groups.map((group) => (
|
|
||||||
<label
|
|
||||||
key={group.id}
|
|
||||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
|
||||||
onClick={() => handleGroupToggle(group.id)}
|
|
||||||
>
|
|
||||||
<div className="mr-3 flex items-center justify-center">
|
|
||||||
<div
|
|
||||||
className={`w-5 h-5 rounded-full border ${
|
|
||||||
selectedGroups.includes(group.id)
|
|
||||||
? "border-blue-600"
|
|
||||||
: "border-gray-300"
|
|
||||||
} flex items-center justify-center`}
|
|
||||||
>
|
|
||||||
{selectedGroups.includes(group.id) && (
|
|
||||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3 flex-1">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
|
|
||||||
{group.avatar ? (
|
|
||||||
<img
|
|
||||||
src={group.avatar}
|
|
||||||
alt={group.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
group.name.charAt(0)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">{group.name}</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
群ID: {group.chatroomId}
|
|
||||||
</div>
|
|
||||||
{group.ownerNickname && (
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
群主: {group.ownerNickname}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-gray-500">
|
|
||||||
{searchQuery
|
|
||||||
? `没有找到包含"${searchQuery}"的群聊`
|
|
||||||
: "没有找到群聊"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
总计 {totalGroups} 个群聊
|
|
||||||
{searchQuery && ` (搜索: "${searchQuery}")`}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1 || loading}
|
|
||||||
className="px-2 py-0 h-8 min-w-0"
|
|
||||||
>
|
|
||||||
<
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm">
|
|
||||||
{currentPage} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage(Math.min(totalPages, currentPage + 1))
|
|
||||||
}
|
|
||||||
disabled={currentPage === totalPages || loading}
|
|
||||||
className="px-2 py-0 h-8 min-w-0"
|
|
||||||
>
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDialogOpen(false)}
|
|
||||||
className="px-6 rounded-full border-gray-300"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
|
|
||||||
>
|
|
||||||
确定 ({selectedGroups.length})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
10
Cunkebao/src/components/GroupSelection/api.ts
Normal file
10
Cunkebao/src/components/GroupSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request from "@/api/request";
|
||||||
|
|
||||||
|
// 获取群组列表
|
||||||
|
export function getGroupList(params: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) {
|
||||||
|
return request("/v1/chatroom", params, "GET");
|
||||||
|
}
|
||||||
43
Cunkebao/src/components/GroupSelection/data.ts
Normal file
43
Cunkebao/src/components/GroupSelection/data.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// 群组接口类型
|
||||||
|
export interface WechatGroup {
|
||||||
|
id: string;
|
||||||
|
chatroomId: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
ownerWechatId: string;
|
||||||
|
ownerNickname: string;
|
||||||
|
ownerAvatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSelectionItem {
|
||||||
|
id: string;
|
||||||
|
avatar: string;
|
||||||
|
chatroomId?: string;
|
||||||
|
createTime?: number;
|
||||||
|
identifier?: string;
|
||||||
|
name: string;
|
||||||
|
ownerAlias?: string;
|
||||||
|
ownerAvatar?: string;
|
||||||
|
ownerNickname?: string;
|
||||||
|
ownerWechatId?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件属性接口
|
||||||
|
export interface GroupSelectionProps {
|
||||||
|
selectedOptions: GroupSelectionItem[];
|
||||||
|
onSelect: (groups: GroupSelectionItem[]) => void;
|
||||||
|
onSelectDetail?: (groups: WechatGroup[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
onVisibleChange?: (visible: boolean) => void;
|
||||||
|
selectedListMaxHeight?: number;
|
||||||
|
showInput?: boolean;
|
||||||
|
showSelectedList?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: string[],
|
||||||
|
selectedItems: GroupSelectionItem[],
|
||||||
|
) => void; // 新增
|
||||||
|
}
|
||||||
206
Cunkebao/src/components/GroupSelection/index.module.scss
Normal file
206
Cunkebao/src/components/GroupSelection/index.module.scss
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.selectedListRow {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.selectedListRowContent {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.selectedListRowContentText {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.popupHeader {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.popupTitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.searchWrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.searchInput {
|
||||||
|
padding-left: 40px !important;
|
||||||
|
padding-top: 8px !important;
|
||||||
|
padding-bottom: 8px !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
border: 1px solid #e5e6eb !important;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.clearBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupList {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.groupListInner {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.groupItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.groupInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.groupAvatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatarImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.groupDetail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.groupName {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.groupId {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.groupOwner {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.loadingText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.emptyBox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.emptyText {
|
||||||
|
color: #888;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
126
Cunkebao/src/components/GroupSelection/index.tsx
Normal file
126
Cunkebao/src/components/GroupSelection/index.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import { Avatar } from "antd-mobile";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import SelectionPopup from "./selectionPopup";
|
||||||
|
import { GroupSelectionProps } from "./data";
|
||||||
|
export default function GroupSelection({
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
onSelectDetail,
|
||||||
|
placeholder = "选择群聊",
|
||||||
|
className = "",
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedListMaxHeight = 300,
|
||||||
|
showInput = true,
|
||||||
|
showSelectedList = true,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: GroupSelectionProps) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
|
||||||
|
// 删除已选群聊
|
||||||
|
const handleRemoveGroup = (id: string) => {
|
||||||
|
if (readonly) return;
|
||||||
|
onSelect(selectedOptions.filter(g => g.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 受控弹窗逻辑
|
||||||
|
const realVisible = visible !== undefined ? visible : popupVisible;
|
||||||
|
const setRealVisible = (v: boolean) => {
|
||||||
|
if (onVisibleChange) onVisibleChange(v);
|
||||||
|
if (visible === undefined) setPopupVisible(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开弹窗
|
||||||
|
const openPopup = () => {
|
||||||
|
if (readonly) return;
|
||||||
|
setRealVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取显示文本
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedOptions.length === 0) return "";
|
||||||
|
return `已选择 ${selectedOptions.length} 个群聊`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 输入框 */}
|
||||||
|
{showInput && (
|
||||||
|
<div className={`${style.inputWrapper} ${className}`}>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={getDisplayText()}
|
||||||
|
onClick={openPopup}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear={!readonly}
|
||||||
|
size="large"
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={readonly}
|
||||||
|
style={
|
||||||
|
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 已选群聊列表窗口 */}
|
||||||
|
{showSelectedList && selectedOptions.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={style.selectedListWindow}
|
||||||
|
style={{
|
||||||
|
maxHeight: selectedListMaxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
marginTop: 8,
|
||||||
|
border: "1px solid #e5e6eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOptions.map(group => (
|
||||||
|
<div key={group.id} className={style.selectedListRow}>
|
||||||
|
<div className={style.selectedListRowContent}>
|
||||||
|
<Avatar src={group.avatar} />
|
||||||
|
<div className={style.selectedListRowContentText}>
|
||||||
|
<div>{group.name}</div>
|
||||||
|
<div>{group.chatroomId}</div>
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
border: "none",
|
||||||
|
background: "none",
|
||||||
|
minWidth: 24,
|
||||||
|
height: 24,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={() => handleRemoveGroup(group.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 弹窗 */}
|
||||||
|
<SelectionPopup
|
||||||
|
visible={realVisible}
|
||||||
|
onVisibleChange={setRealVisible}
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onSelectDetail={onSelectDetail}
|
||||||
|
readonly={readonly}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
Cunkebao/src/components/GroupSelection/selectionPopup.tsx
Normal file
220
Cunkebao/src/components/GroupSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Popup, Checkbox } from "antd-mobile";
|
||||||
|
|
||||||
|
import { getGroupList } from "./api";
|
||||||
|
import style from "./index.module.scss";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import PopupHeader from "@/components/PopuLayout/header";
|
||||||
|
import PopupFooter from "@/components/PopuLayout/footer";
|
||||||
|
import { GroupSelectionItem } from "./data";
|
||||||
|
// 群组接口类型
|
||||||
|
interface WechatGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
chatroomId?: string;
|
||||||
|
ownerWechatId?: string;
|
||||||
|
ownerNickname?: string;
|
||||||
|
ownerAvatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗属性接口
|
||||||
|
interface SelectionPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
selectedOptions: GroupSelectionItem[];
|
||||||
|
onSelect: (groups: GroupSelectionItem[]) => void;
|
||||||
|
onSelectDetail?: (groups: WechatGroup[]) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
onConfirm?: (
|
||||||
|
selectedIds: string[],
|
||||||
|
selectedItems: GroupSelectionItem[],
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectionPopup({
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
selectedOptions,
|
||||||
|
onSelect,
|
||||||
|
onSelectDetail,
|
||||||
|
readonly = false,
|
||||||
|
onConfirm,
|
||||||
|
}: SelectionPopupProps) {
|
||||||
|
const [groups, setGroups] = useState<WechatGroup[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalGroups, setTotalGroups] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 获取群聊列表API
|
||||||
|
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (keyword.trim()) {
|
||||||
|
params.keyword = keyword.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getGroupList(params);
|
||||||
|
if (response && response.list) {
|
||||||
|
setGroups(response.list);
|
||||||
|
setTotalGroups(response.total || 0);
|
||||||
|
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取群聊列表失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理群聊选择
|
||||||
|
const handleGroupToggle = (group: GroupSelectionItem) => {
|
||||||
|
if (readonly) return;
|
||||||
|
|
||||||
|
const newSelectedGroups = selectedOptions.some(g => g.id === group.id)
|
||||||
|
? selectedOptions.filter(g => g.id !== group.id)
|
||||||
|
: selectedOptions.concat(group);
|
||||||
|
|
||||||
|
onSelect(newSelectedGroups);
|
||||||
|
|
||||||
|
// 如果有 onSelectDetail 回调,传递完整的群聊对象
|
||||||
|
if (onSelectDetail) {
|
||||||
|
const selectedGroupObjs = groups.filter(group =>
|
||||||
|
newSelectedGroups.some(g => g.id === group.id),
|
||||||
|
);
|
||||||
|
onSelectDetail(selectedGroupObjs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm(
|
||||||
|
selectedOptions.map(g => g.id),
|
||||||
|
selectedOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onVisibleChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗打开时初始化数据(只执行一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchQuery("");
|
||||||
|
fetchGroups(1, "");
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || searchQuery === "") return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchGroups(1, searchQuery);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, visible]);
|
||||||
|
|
||||||
|
// 页码变化时请求数据(只在弹窗打开且页码不是1时执行)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || currentPage === 1) return;
|
||||||
|
fetchGroups(currentPage, searchQuery);
|
||||||
|
}, [currentPage, visible, searchQuery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
visible={visible && !readonly}
|
||||||
|
onMaskClick={() => onVisibleChange(false)}
|
||||||
|
position="bottom"
|
||||||
|
bodyStyle={{ height: "100vh" }}
|
||||||
|
>
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<PopupHeader
|
||||||
|
title="选择群聊"
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
searchPlaceholder="搜索群聊"
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={() => fetchGroups(currentPage, searchQuery)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<PopupFooter
|
||||||
|
total={totalGroups}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
loading={loading}
|
||||||
|
selectedCount={selectedOptions.length}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onCancel={() => onVisibleChange(false)}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={style.groupList}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingBox}>
|
||||||
|
<div className={style.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : groups.length > 0 ? (
|
||||||
|
<div className={style.groupListInner}>
|
||||||
|
{groups.map(group => (
|
||||||
|
<div key={group.id} className={style.groupItem}>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedOptions.some(g => g.id === group.id)}
|
||||||
|
onChange={() => !readonly && handleGroupToggle(group)}
|
||||||
|
disabled={readonly}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<div className={style.groupInfo}>
|
||||||
|
<div className={style.groupAvatar}>
|
||||||
|
{group.avatar ? (
|
||||||
|
<img
|
||||||
|
src={group.avatar}
|
||||||
|
alt={group.name}
|
||||||
|
className={style.avatarImg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
group.name.charAt(0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.groupDetail}>
|
||||||
|
<div className={style.groupName}>{group.name}</div>
|
||||||
|
<div className={style.groupId}>
|
||||||
|
群ID: {group.chatroomId}
|
||||||
|
</div>
|
||||||
|
{group.ownerNickname && (
|
||||||
|
<div className={style.groupOwner}>
|
||||||
|
群主: {group.ownerNickname}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.emptyBox}>
|
||||||
|
<div className={style.emptyText}>
|
||||||
|
{searchQuery
|
||||||
|
? `没有找到包含"${searchQuery}"的群聊`
|
||||||
|
: "没有找到群聊"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
.listContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreButtonContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noMoreText {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #999;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pullToRefresh {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义滚动条样式
|
||||||
|
.listContainer::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listContainer::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listContainer::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listContainer::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.listContainer {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreButtonContainer {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noMoreText {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
195
Cunkebao/src/components/InfiniteList/InfiniteList.tsx
Normal file
195
Cunkebao/src/components/InfiniteList/InfiniteList.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
PullToRefresh,
|
||||||
|
InfiniteScroll,
|
||||||
|
Button,
|
||||||
|
SpinLoading,
|
||||||
|
} from "antd-mobile";
|
||||||
|
import styles from "./InfiniteList.module.scss";
|
||||||
|
|
||||||
|
interface InfiniteListProps<T> {
|
||||||
|
// 数据相关
|
||||||
|
data: T[];
|
||||||
|
loading?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingText?: string;
|
||||||
|
noMoreText?: string;
|
||||||
|
|
||||||
|
// 渲染相关
|
||||||
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
|
keyExtractor?: (item: T, index: number) => string | number;
|
||||||
|
|
||||||
|
// 事件回调
|
||||||
|
onLoadMore?: () => Promise<void> | void;
|
||||||
|
onRefresh?: () => Promise<void> | void;
|
||||||
|
|
||||||
|
// 样式相关
|
||||||
|
className?: string;
|
||||||
|
itemClassName?: string;
|
||||||
|
containerStyle?: React.CSSProperties;
|
||||||
|
|
||||||
|
// 功能开关
|
||||||
|
enablePullToRefresh?: boolean;
|
||||||
|
enableInfiniteScroll?: boolean;
|
||||||
|
enableLoadMoreButton?: boolean;
|
||||||
|
|
||||||
|
// 自定义高度
|
||||||
|
height?: string | number;
|
||||||
|
minHeight?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfiniteList = <T extends any>({
|
||||||
|
data,
|
||||||
|
loading = false,
|
||||||
|
hasMore = true,
|
||||||
|
loadingText = "加载中...",
|
||||||
|
noMoreText = "没有更多了",
|
||||||
|
|
||||||
|
renderItem,
|
||||||
|
keyExtractor = (_, index) => index,
|
||||||
|
|
||||||
|
onLoadMore,
|
||||||
|
onRefresh,
|
||||||
|
|
||||||
|
className = "",
|
||||||
|
itemClassName = "",
|
||||||
|
containerStyle = {},
|
||||||
|
|
||||||
|
enablePullToRefresh = true,
|
||||||
|
enableInfiniteScroll = true,
|
||||||
|
enableLoadMoreButton = false,
|
||||||
|
|
||||||
|
height = "100%",
|
||||||
|
minHeight = "200px",
|
||||||
|
}: InfiniteListProps<T>) => {
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 处理下拉刷新
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
if (!onRefresh) return;
|
||||||
|
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await onRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Refresh failed:", error);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [onRefresh]);
|
||||||
|
|
||||||
|
// 处理加载更多
|
||||||
|
const handleLoadMore = useCallback(async () => {
|
||||||
|
if (!onLoadMore || loadingMore || !hasMore) return;
|
||||||
|
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
await onLoadMore();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Load more failed:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [onLoadMore, loadingMore, hasMore]);
|
||||||
|
|
||||||
|
// 点击加载更多按钮
|
||||||
|
const handleLoadMoreClick = useCallback(() => {
|
||||||
|
handleLoadMore();
|
||||||
|
}, [handleLoadMore]);
|
||||||
|
|
||||||
|
// 容器样式
|
||||||
|
const containerStyles: React.CSSProperties = {
|
||||||
|
height,
|
||||||
|
minHeight,
|
||||||
|
...containerStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染列表项
|
||||||
|
const renderListItems = () => {
|
||||||
|
return data.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={keyExtractor(item, index)}
|
||||||
|
className={`${styles.listItem} ${itemClassName}`}
|
||||||
|
>
|
||||||
|
{renderItem(item, index)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染加载更多按钮
|
||||||
|
const renderLoadMoreButton = () => {
|
||||||
|
if (!enableLoadMoreButton || !hasMore) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.loadMoreButtonContainer}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
loading={loadingMore}
|
||||||
|
onClick={handleLoadMoreClick}
|
||||||
|
disabled={loading || !hasMore}
|
||||||
|
>
|
||||||
|
{loadingMore ? loadingText : "点击加载更多"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染无更多数据提示
|
||||||
|
const renderNoMoreText = () => {
|
||||||
|
if (hasMore || data.length === 0) return null;
|
||||||
|
|
||||||
|
return <div className={styles.noMoreText}>{noMoreText}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染空状态
|
||||||
|
const renderEmptyState = () => {
|
||||||
|
if (data.length > 0 || loading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyIcon}>📝</div>
|
||||||
|
<div className={styles.emptyText}>暂无数据</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`${styles.listContainer} ${className}`}
|
||||||
|
style={containerStyles}
|
||||||
|
>
|
||||||
|
{renderListItems()}
|
||||||
|
{renderLoadMoreButton()}
|
||||||
|
{renderNoMoreText()}
|
||||||
|
{renderEmptyState()}
|
||||||
|
|
||||||
|
{/* 无限滚动组件 */}
|
||||||
|
{enableInfiniteScroll && (
|
||||||
|
<InfiniteScroll
|
||||||
|
loadMore={handleLoadMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
threshold={100}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果启用下拉刷新,包装PullToRefresh
|
||||||
|
if (enablePullToRefresh && onRefresh) {
|
||||||
|
return (
|
||||||
|
<PullToRefresh
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
refreshing={refreshing}
|
||||||
|
className={styles.pullToRefresh}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfiniteList;
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container main {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
loading?: boolean;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
header?: React.ReactNode;
|
|
||||||
footer?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({
|
|
||||||
loading,
|
|
||||||
children,
|
|
||||||
header,
|
|
||||||
footer,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="container">
|
|
||||||
{header && <header>{header}</header>}
|
|
||||||
<main className="bg-gray-50">{children}</main>
|
|
||||||
{footer && <footer>{footer}</footer>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
52
Cunkebao/src/components/Layout/Layout.tsx
Normal file
52
Cunkebao/src/components/Layout/Layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { SpinLoading } from "antd-mobile";
|
||||||
|
import styles from "./layout.module.scss";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
loading?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout: React.FC<LayoutProps> = ({
|
||||||
|
children,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
// 移动端100vh兼容
|
||||||
|
useEffect(() => {
|
||||||
|
const setRealHeight = () => {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--real-vh",
|
||||||
|
`${window.innerHeight * 0.01}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
setRealHeight();
|
||||||
|
window.addEventListener("resize", setRealHeight);
|
||||||
|
return () => window.removeEventListener("resize", setRealHeight);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.container}
|
||||||
|
style={{ height: "calc(var(--real-vh, 1vh) * 100)" }}
|
||||||
|
>
|
||||||
|
{header && <header>{header}</header>}
|
||||||
|
<main>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||||
|
<div className={styles.loadingText}>加载中...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
{footer && <footer>{footer}</footer>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
28
Cunkebao/src/components/Layout/layout.module.scss
Normal file
28
Cunkebao/src/components/Layout/layout.module.scss
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container main {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingText {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import BottomNav from './BottomNav';
|
|
||||||
|
|
||||||
// 配置需要底部导航的页面路径(白名单)
|
|
||||||
const BOTTOM_NAV_CONFIG = [
|
|
||||||
'/', // 首页
|
|
||||||
'/scenarios', // 场景获客
|
|
||||||
'/workspace', // 工作台
|
|
||||||
'/profile', // 我的
|
|
||||||
];
|
|
||||||
|
|
||||||
interface LayoutWrapperProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LayoutWrapper({ children }: LayoutWrapperProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// 检查当前路径是否需要底部导航
|
|
||||||
const shouldShowBottomNav = BOTTOM_NAV_CONFIG.some(path => {
|
|
||||||
// 特殊处理首页路由 '/'
|
|
||||||
if (path === '/') {
|
|
||||||
return location.pathname === '/';
|
|
||||||
}
|
|
||||||
return location.pathname === path;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果是登录页面,直接渲染内容(不显示底部导航)
|
|
||||||
if (location.pathname === '/login') {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只有在配置列表中的页面才显示底部导航
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-screen">
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
{shouldShowBottomNav && <BottomNav />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
53
Cunkebao/src/components/LineChart.tsx
Normal file
53
Cunkebao/src/components/LineChart.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactECharts from "echarts-for-react";
|
||||||
|
|
||||||
|
interface LineChartProps {
|
||||||
|
title?: string;
|
||||||
|
xData: string[];
|
||||||
|
yData: number[];
|
||||||
|
height?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LineChart: React.FC<LineChartProps> = ({
|
||||||
|
title = "",
|
||||||
|
xData,
|
||||||
|
yData,
|
||||||
|
height = 200,
|
||||||
|
}) => {
|
||||||
|
const option = {
|
||||||
|
title: {
|
||||||
|
text: title,
|
||||||
|
left: "center",
|
||||||
|
textStyle: { fontSize: 16 },
|
||||||
|
},
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: xData,
|
||||||
|
boundaryGap: false,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
boundaryGap: ["10%", "10%"], // 上下留白
|
||||||
|
min: (value: any) => value.min - 10, // 下方多留一点空间
|
||||||
|
max: (value: any) => value.max + 10, // 上方多留一点空间
|
||||||
|
minInterval: 1,
|
||||||
|
axisLabel: { margin: 12 },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
data: yData,
|
||||||
|
type: "line",
|
||||||
|
smooth: true,
|
||||||
|
symbol: "circle",
|
||||||
|
lineStyle: { color: "#1677ff" },
|
||||||
|
itemStyle: { color: "#1677ff" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grid: { left: 40, right: 24, top: 40, bottom: 32 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ReactECharts option={option} style={{ height, width: "100%" }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LineChart;
|
||||||
57
Cunkebao/src/components/MeauMobile/MeauMoible.tsx
Normal file
57
Cunkebao/src/components/MeauMobile/MeauMoible.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { TabBar } from "antd-mobile";
|
||||||
|
import { PieOutline, UserOutline } from "antd-mobile-icons";
|
||||||
|
import { HomeOutlined, TeamOutlined } from "@ant-design/icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
key: "home",
|
||||||
|
title: "首页",
|
||||||
|
icon: <HomeOutlined />,
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scenarios",
|
||||||
|
title: "场景获客",
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
path: "/scenarios",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workspace",
|
||||||
|
title: "工作台",
|
||||||
|
icon: <PieOutline />,
|
||||||
|
path: "/workspace",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mine",
|
||||||
|
title: "我的",
|
||||||
|
icon: <UserOutline />,
|
||||||
|
path: "/mine",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MeauMobileProps {
|
||||||
|
activeKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabBar
|
||||||
|
style={{ background: "#fff" }}
|
||||||
|
activeKey={activeKey}
|
||||||
|
onChange={key => {
|
||||||
|
const tab = tabs.find(t => t.key === key);
|
||||||
|
if (tab && tab.path) navigate(tab.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map(item => (
|
||||||
|
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MeauMobile;
|
||||||
62
Cunkebao/src/components/NavCommon/index.tsx
Normal file
62
Cunkebao/src/components/NavCommon/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { NavBar } from "antd-mobile";
|
||||||
|
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getSafeAreaHeight } from "@/utils/common";
|
||||||
|
interface NavCommonProps {
|
||||||
|
title: string;
|
||||||
|
backFn?: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
left?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavCommon: React.FC<NavCommonProps> = ({
|
||||||
|
title,
|
||||||
|
backFn,
|
||||||
|
right,
|
||||||
|
left,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [paddingTop, setPaddingTop] = useState("0px");
|
||||||
|
useEffect(() => {
|
||||||
|
setPaddingTop(getSafeAreaHeight() + "px");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingTop: paddingTop,
|
||||||
|
background: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NavBar
|
||||||
|
back={null}
|
||||||
|
left={
|
||||||
|
left ? (
|
||||||
|
left
|
||||||
|
) : (
|
||||||
|
<div className="nav-title">
|
||||||
|
<ArrowLeftOutlined
|
||||||
|
twoToneColor="#1677ff"
|
||||||
|
onClick={() => {
|
||||||
|
if (backFn) {
|
||||||
|
backFn();
|
||||||
|
} else {
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
right={right}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</NavBar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavCommon;
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import BackButton from './BackButton';
|
|
||||||
import { useSimpleBack } from '@/hooks/useBackNavigation';
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
/** 页面标题 */
|
|
||||||
title: string;
|
|
||||||
/** 返回按钮文本 */
|
|
||||||
backText?: string;
|
|
||||||
/** 自定义返回逻辑 */
|
|
||||||
onBack?: () => void;
|
|
||||||
/** 默认返回路径 */
|
|
||||||
defaultBackPath?: string;
|
|
||||||
/** 是否显示返回按钮 */
|
|
||||||
showBack?: boolean;
|
|
||||||
/** 右侧扩展内容 */
|
|
||||||
rightContent?: React.ReactNode;
|
|
||||||
/** 自定义CSS类名 */
|
|
||||||
className?: string;
|
|
||||||
/** 标题样式类名 */
|
|
||||||
titleClassName?: string;
|
|
||||||
/** 返回按钮样式变体 */
|
|
||||||
backButtonVariant?: 'icon' | 'button' | 'text';
|
|
||||||
/** 返回按钮自定义样式类名 */
|
|
||||||
backButtonClassName?: string;
|
|
||||||
/** 是否显示底部边框 */
|
|
||||||
showBorder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用页面Header组件
|
|
||||||
* 支持返回按钮、标题和右侧扩展插槽
|
|
||||||
*/
|
|
||||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
|
||||||
title,
|
|
||||||
backText = '返回',
|
|
||||||
onBack,
|
|
||||||
defaultBackPath = '/',
|
|
||||||
showBack = true,
|
|
||||||
rightContent,
|
|
||||||
className = '',
|
|
||||||
titleClassName = '',
|
|
||||||
backButtonVariant = 'icon',
|
|
||||||
backButtonClassName = '',
|
|
||||||
showBorder = true
|
|
||||||
}) => {
|
|
||||||
const { goBack } = useSimpleBack(defaultBackPath);
|
|
||||||
|
|
||||||
const handleBack = onBack || goBack;
|
|
||||||
|
|
||||||
const baseClasses = `bg-white ${showBorder ? 'border-b border-gray-200' : ''}`;
|
|
||||||
const headerClasses = `${baseClasses} ${className}`;
|
|
||||||
// 默认小号按钮样式
|
|
||||||
const defaultBackBtnClass = 'text-sm px-2 py-1 h-8 min-h-0';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className={headerClasses}>
|
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
|
||||||
<div className="flex items-center ">
|
|
||||||
{showBack && (
|
|
||||||
<BackButton
|
|
||||||
variant={backButtonVariant}
|
|
||||||
text={backText}
|
|
||||||
onBack={handleBack}
|
|
||||||
className={`${defaultBackBtnClass} ${backButtonClassName}`.trim()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<h1 className={`text-lg font-semibold ${titleClassName}`}>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rightContent && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{rightContent}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageHeader;
|
|
||||||
56
Cunkebao/src/components/PlaceholderPage.tsx
Normal file
56
Cunkebao/src/components/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { NavBar, Button } from "antd-mobile";
|
||||||
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
|
import Layout from "@/components/Layout/Layout";
|
||||||
|
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||||
|
|
||||||
|
interface PlaceholderPageProps {
|
||||||
|
title: string;
|
||||||
|
showBack?: boolean;
|
||||||
|
showAddButton?: boolean;
|
||||||
|
addButtonText?: string;
|
||||||
|
showFooter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({
|
||||||
|
title,
|
||||||
|
showBack = true,
|
||||||
|
showAddButton = false,
|
||||||
|
addButtonText = "新建",
|
||||||
|
showFooter = true,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
header={
|
||||||
|
<NavBar
|
||||||
|
backArrow={showBack}
|
||||||
|
style={{ background: "#fff" }}
|
||||||
|
onBack={showBack ? () => window.history.back() : undefined}
|
||||||
|
left={
|
||||||
|
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
showAddButton ? (
|
||||||
|
<Button size="small" color="primary">
|
||||||
|
<PlusOutlined />
|
||||||
|
<span style={{ marginLeft: 4, fontSize: 12 }}>
|
||||||
|
{addButtonText}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={showFooter ? <MeauMobile /> : undefined}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
|
||||||
|
<h3>{title}页面</h3>
|
||||||
|
<p>此页面正在开发中...</p>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaceholderPage;
|
||||||
71
Cunkebao/src/components/PopuLayout/footer.module.scss
Normal file
71
Cunkebao/src/components/PopuLayout/footer.module.scss
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
.popupFooter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBtnGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationRow {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalCount {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: #1677ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #222;
|
||||||
|
margin: 0 8px;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
67
Cunkebao/src/components/PopuLayout/footer.tsx
Normal file
67
Cunkebao/src/components/PopuLayout/footer.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "antd";
|
||||||
|
import style from "./footer.module.scss";
|
||||||
|
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
interface PopupFooterProps {
|
||||||
|
total: number;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
loading: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||||
|
total,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
selectedCount,
|
||||||
|
onPageChange,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 分页栏 */}
|
||||||
|
<div className={style.paginationRow}>
|
||||||
|
<div className={style.totalCount}>总计 {total} 条记录</div>
|
||||||
|
<div className={style.paginationControls}>
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage === 1 || loading}
|
||||||
|
className={style.pageBtn}
|
||||||
|
>
|
||||||
|
<ArrowLeftOutlined />
|
||||||
|
</Button>
|
||||||
|
<span className={style.pageInfo}>
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage === totalPages || loading}
|
||||||
|
className={style.pageBtn}
|
||||||
|
>
|
||||||
|
<ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={style.popupFooter}>
|
||||||
|
<div className={style.selectedCount}>已选择 {selectedCount} 条记录</div>
|
||||||
|
<div className={style.footerBtnGroup}>
|
||||||
|
<Button color="primary" variant="filled" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={onConfirm}>
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopupFooter;
|
||||||
51
Cunkebao/src/components/PopuLayout/header.module.scss
Normal file
51
Cunkebao/src/components/PopuLayout/header.module.scss
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
.popupHeader {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupSearchRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popupSearchInputWrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #bdbdbd;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshBtn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingIcon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
Cunkebao/src/components/PopuLayout/header.tsx
Normal file
86
Cunkebao/src/components/PopuLayout/header.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
|
import { Input, Button } from "antd";
|
||||||
|
import { Tabs } from "antd-mobile";
|
||||||
|
import style from "./header.module.scss";
|
||||||
|
|
||||||
|
interface PopupHeaderProps {
|
||||||
|
title: string;
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (value: string) => void;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
showRefresh?: boolean;
|
||||||
|
showSearch?: boolean;
|
||||||
|
showTabs?: boolean;
|
||||||
|
tabsConfig?: {
|
||||||
|
activeKey: string;
|
||||||
|
onChange: (key: string) => void;
|
||||||
|
tabs: Array<{ title: string; key: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopupHeader: React.FC<PopupHeaderProps> = ({
|
||||||
|
title,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
searchPlaceholder = "搜索...",
|
||||||
|
loading = false,
|
||||||
|
onRefresh,
|
||||||
|
showRefresh = true,
|
||||||
|
showSearch = true,
|
||||||
|
showTabs = false,
|
||||||
|
tabsConfig,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={style.popupHeader}>
|
||||||
|
<div className={style.popupTitle}>{title}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div className={style.popupSearchRow}>
|
||||||
|
<div className={style.popupSearchInputWrap}>
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRefresh && onRefresh && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className={style.refreshBtn}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className={style.loadingIcon}>⟳</div>
|
||||||
|
) : (
|
||||||
|
<ReloadOutlined />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTabs && tabsConfig && (
|
||||||
|
<Tabs
|
||||||
|
activeKey={tabsConfig.activeKey}
|
||||||
|
onChange={tabsConfig.onChange}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
{tabsConfig.tabs.map(tab => (
|
||||||
|
<Tabs.Tab key={tab.key} title={tab.title} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopupHeader;
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
|
|
||||||
// 不需要登录的公共页面路径
|
|
||||||
const PUBLIC_PATHS = [
|
|
||||||
'/login',
|
|
||||||
'/register',
|
|
||||||
'/forgot-password',
|
|
||||||
'/reset-password',
|
|
||||||
'/404',
|
|
||||||
'/500'
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
// 检查当前路径是否是公共页面
|
|
||||||
const isPublicPath = PUBLIC_PATHS.some(path =>
|
|
||||||
location.pathname.startsWith(path)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 如果正在加载,不进行任何跳转
|
|
||||||
if (isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果未登录且不是公共页面,重定向到登录页面
|
|
||||||
if (!isAuthenticated && !isPublicPath) {
|
|
||||||
// 保存当前URL,登录后可以重定向回来
|
|
||||||
const returnUrl = encodeURIComponent(window.location.href);
|
|
||||||
navigate(`/login?returnUrl=${returnUrl}`, { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果已登录且在登录页面,重定向到首页
|
|
||||||
if (isAuthenticated && location.pathname === '/login') {
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, isLoading, location.pathname, navigate, isPublicPath]);
|
|
||||||
|
|
||||||
// 如果正在加载,显示加载状态
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen w-screen items-center justify-center">
|
|
||||||
<div className="text-gray-500">加载中...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果未登录且不是公共页面,不渲染内容(等待重定向)
|
|
||||||
if (!isAuthenticated && !isPublicPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果已登录且在登录页面,不渲染内容(等待重定向)
|
|
||||||
if (isAuthenticated && location.pathname === '/login') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他情况正常渲染
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { Card } from './ui/card';
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { Badge } from './ui/badge';
|
|
||||||
import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from 'lucide-react';
|
|
||||||
|
|
||||||
interface Task {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: "running" | "paused" | "completed";
|
|
||||||
stats: {
|
|
||||||
devices: number;
|
|
||||||
acquired: number;
|
|
||||||
added: number;
|
|
||||||
};
|
|
||||||
lastUpdated: string;
|
|
||||||
executionTime: string;
|
|
||||||
nextExecutionTime: string;
|
|
||||||
trend: { date: string; customers: number }[];
|
|
||||||
reqConf?: {
|
|
||||||
device?: string[];
|
|
||||||
selectedDevices?: string[];
|
|
||||||
};
|
|
||||||
acquiredCount?: number;
|
|
||||||
addedCount?: number;
|
|
||||||
passRate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScenarioAcquisitionCardProps {
|
|
||||||
task: Task;
|
|
||||||
channel: string;
|
|
||||||
onEdit: (taskId: string) => void;
|
|
||||||
onCopy: (taskId: string) => void;
|
|
||||||
onDelete: (taskId: string) => void;
|
|
||||||
onOpenSettings?: (taskId: string) => void;
|
|
||||||
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScenarioAcquisitionCard({
|
|
||||||
task,
|
|
||||||
channel,
|
|
||||||
onEdit,
|
|
||||||
onCopy,
|
|
||||||
onDelete,
|
|
||||||
onOpenSettings,
|
|
||||||
onStatusChange,
|
|
||||||
}: ScenarioAcquisitionCardProps) {
|
|
||||||
// 兼容后端真实数据结构
|
|
||||||
const deviceCount = Array.isArray(task.reqConf?.device)
|
|
||||||
? task.reqConf!.device.length
|
|
||||||
: Array.isArray(task.reqConf?.selectedDevices)
|
|
||||||
? task.reqConf!.selectedDevices.length
|
|
||||||
: 0;
|
|
||||||
// 获客数和已添加数可根据 msgConf 或其它字段自定义
|
|
||||||
const acquiredCount = task.acquiredCount ?? 0;
|
|
||||||
const addedCount = task.addedCount ?? 0;
|
|
||||||
const passRate = task.passRate ?? 0;
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const isActive = task.status === "running";
|
|
||||||
|
|
||||||
const handleStatusChange = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onStatusChange) {
|
|
||||||
onStatusChange(task.id, task.status === "running" ? "paused" : "running");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMenuOpen(false);
|
|
||||||
onEdit(task.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMenuOpen(false);
|
|
||||||
onCopy(task.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenSettings = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMenuOpen(false);
|
|
||||||
if (onOpenSettings) {
|
|
||||||
onOpenSettings(task.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setMenuOpen(false);
|
|
||||||
onDelete(task.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMenu = (e?: React.MouseEvent) => {
|
|
||||||
if (e) e.stopPropagation();
|
|
||||||
setMenuOpen(!menuOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 点击外部关闭菜单
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
||||||
setMenuOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<h3 className="font-medium text-lg">{task.name}</h3>
|
|
||||||
<Badge
|
|
||||||
variant={isActive ? "success" : "secondary"}
|
|
||||||
className="cursor-pointer hover:opacity-80"
|
|
||||||
onClick={handleStatusChange}
|
|
||||||
>
|
|
||||||
{isActive ? "进行中" : "已暂停"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="relative z-20" ref={menuRef}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{menuOpen && (
|
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
|
|
||||||
<button
|
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
onClick={handleEdit}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
|
||||||
编辑计划
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
onClick={handleCopy}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4 mr-2" />
|
|
||||||
复制计划
|
|
||||||
</button>
|
|
||||||
{onOpenSettings && (
|
|
||||||
<button
|
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
onClick={handleOpenSettings}
|
|
||||||
>
|
|
||||||
<Link className="w-4 h-4 mr-2" />
|
|
||||||
计划接口
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
删除计划
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
|
||||||
<div className="block">
|
|
||||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
|
||||||
<div className="text-sm text-gray-500 mb-1">设备数</div>
|
|
||||||
<div className="text-2xl font-semibold">{deviceCount}</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="block">
|
|
||||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
|
||||||
<div className="text-sm text-gray-500 mb-1">已获客</div>
|
|
||||||
<div className="text-2xl font-semibold">{acquiredCount}</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="block">
|
|
||||||
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
|
|
||||||
<div className="text-sm text-gray-500 mb-1">已添加</div>
|
|
||||||
<div className="text-2xl font-semibold">{addedCount}</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="text-sm text-gray-500 mb-1">通过率</div>
|
|
||||||
<div className="text-2xl font-semibold">{passRate}%</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
<span>上次执行:{task.lastUpdated}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
43
Cunkebao/src/components/StepIndicator/index.tsx
Normal file
43
Cunkebao/src/components/StepIndicator/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Steps } from "antd-mobile";
|
||||||
|
|
||||||
|
interface StepIndicatorProps {
|
||||||
|
currentStep: number;
|
||||||
|
steps: { id: number; title: string; subtitle: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepIndicator: React.FC<StepIndicatorProps> = ({
|
||||||
|
currentStep,
|
||||||
|
steps,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
|
||||||
|
<Steps current={currentStep - 1}>
|
||||||
|
{steps.map((step, idx) => (
|
||||||
|
<Steps.Step
|
||||||
|
key={step.id}
|
||||||
|
title={step.subtitle}
|
||||||
|
icon={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
|
||||||
|
color: "#fff",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step.id}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Steps>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepIndicator;
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { cn } from '@/utils';
|
|
||||||
|
|
||||||
interface TestComponentProps {
|
|
||||||
title: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TestComponent: React.FC<TestComponentProps> = ({ title, className }) => {
|
|
||||||
return (
|
|
||||||
<div className={cn('p-4 border rounded-lg', className)}>
|
|
||||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
这是一个测试组件,用于验证路径别名 @/ 是否正常工作。
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 text-sm text-blue-600">
|
|
||||||
✓ 成功使用 @/utils 导入工具函数
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TestComponent;
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
useThrottledRequestWithLoading,
|
|
||||||
useThrottledRequestWithError,
|
|
||||||
useRequestWithRetry,
|
|
||||||
useCancellableRequest
|
|
||||||
} from '../hooks/useThrottledRequest';
|
|
||||||
|
|
||||||
interface ThrottledButtonProps {
|
|
||||||
onClick: () => Promise<any>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
delay?: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
className?: string;
|
|
||||||
variant?: 'throttle' | 'debounce' | 'retry' | 'cancellable';
|
|
||||||
maxRetries?: number;
|
|
||||||
retryDelay?: number;
|
|
||||||
showLoadingText?: boolean;
|
|
||||||
loadingText?: string;
|
|
||||||
errorText?: string;
|
|
||||||
onSuccess?: (result: any) => void;
|
|
||||||
onError?: (error: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThrottledButton: React.FC<ThrottledButtonProps> = ({
|
|
||||||
onClick,
|
|
||||||
children,
|
|
||||||
delay = 1000,
|
|
||||||
disabled = false,
|
|
||||||
className = '',
|
|
||||||
variant = 'throttle',
|
|
||||||
maxRetries = 3,
|
|
||||||
retryDelay = 1000,
|
|
||||||
showLoadingText = true,
|
|
||||||
loadingText = '处理中...',
|
|
||||||
errorText,
|
|
||||||
onSuccess,
|
|
||||||
onError
|
|
||||||
}) => {
|
|
||||||
// 处理请求结果
|
|
||||||
const handleRequest = async () => {
|
|
||||||
try {
|
|
||||||
const result = await onClick();
|
|
||||||
onSuccess?.(result);
|
|
||||||
} catch (error) {
|
|
||||||
onError?.(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据variant渲染不同的按钮
|
|
||||||
const renderButton = () => {
|
|
||||||
switch (variant) {
|
|
||||||
case 'retry':
|
|
||||||
return <RetryButtonContent
|
|
||||||
onClick={handleRequest}
|
|
||||||
maxRetries={maxRetries}
|
|
||||||
retryDelay={retryDelay}
|
|
||||||
loadingText={loadingText}
|
|
||||||
showLoadingText={showLoadingText}
|
|
||||||
disabled={disabled}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</RetryButtonContent>;
|
|
||||||
|
|
||||||
case 'cancellable':
|
|
||||||
return <CancellableButtonContent
|
|
||||||
onClick={handleRequest}
|
|
||||||
loadingText={loadingText}
|
|
||||||
showLoadingText={showLoadingText}
|
|
||||||
disabled={disabled}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</CancellableButtonContent>;
|
|
||||||
|
|
||||||
case 'debounce':
|
|
||||||
return <DebounceButtonContent
|
|
||||||
onClick={handleRequest}
|
|
||||||
delay={delay}
|
|
||||||
loadingText={loadingText}
|
|
||||||
showLoadingText={showLoadingText}
|
|
||||||
disabled={disabled}
|
|
||||||
className={className}
|
|
||||||
errorText={errorText}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DebounceButtonContent>;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return <ThrottleButtonContent
|
|
||||||
onClick={handleRequest}
|
|
||||||
delay={delay}
|
|
||||||
loadingText={loadingText}
|
|
||||||
showLoadingText={showLoadingText}
|
|
||||||
disabled={disabled}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ThrottleButtonContent>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return renderButton();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 节流按钮内容组件
|
|
||||||
const ThrottleButtonContent: React.FC<{
|
|
||||||
onClick: () => Promise<any>;
|
|
||||||
delay: number;
|
|
||||||
loadingText: string;
|
|
||||||
showLoadingText: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
className: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, children }) => {
|
|
||||||
const { throttledRequest, loading } = useThrottledRequestWithLoading(onClick, delay);
|
|
||||||
|
|
||||||
const getButtonText = () => {
|
|
||||||
return loading && showLoadingText ? loadingText : children;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonClassName = () => {
|
|
||||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
|
||||||
const variantClasses = loading
|
|
||||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
|
||||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
|
||||||
|
|
||||||
return `${baseClasses} ${variantClasses} ${className}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={throttledRequest}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
className={getButtonClassName()}
|
|
||||||
>
|
|
||||||
{getButtonText()}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 防抖按钮内容组件
|
|
||||||
const DebounceButtonContent: React.FC<{
|
|
||||||
onClick: () => Promise<any>;
|
|
||||||
delay: number;
|
|
||||||
loadingText: string;
|
|
||||||
showLoadingText: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
className: string;
|
|
||||||
errorText?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ onClick, delay, loadingText, showLoadingText, disabled, className, errorText, children }) => {
|
|
||||||
const { throttledRequest, loading, error } = useThrottledRequestWithError(onClick, delay);
|
|
||||||
|
|
||||||
const getButtonText = () => {
|
|
||||||
return loading && showLoadingText ? loadingText : children;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonClassName = () => {
|
|
||||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
|
||||||
let variantClasses = '';
|
|
||||||
if (loading) {
|
|
||||||
variantClasses = 'bg-gray-400 text-white cursor-not-allowed';
|
|
||||||
} else if (error) {
|
|
||||||
variantClasses = 'bg-red-500 text-white hover:bg-red-600';
|
|
||||||
} else {
|
|
||||||
variantClasses = 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${baseClasses} ${variantClasses} ${className}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={throttledRequest}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
className={getButtonClassName()}
|
|
||||||
>
|
|
||||||
{getButtonText()}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && errorText && (
|
|
||||||
<span className="text-red-500 text-sm">{errorText}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重试按钮内容组件
|
|
||||||
const RetryButtonContent: React.FC<{
|
|
||||||
onClick: () => Promise<any>;
|
|
||||||
maxRetries: number;
|
|
||||||
retryDelay: number;
|
|
||||||
loadingText: string;
|
|
||||||
showLoadingText: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
className: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ onClick, maxRetries, retryDelay, loadingText, showLoadingText, disabled, className, children }) => {
|
|
||||||
const { requestWithRetry, loading, retryCount } = useRequestWithRetry(onClick, maxRetries, retryDelay);
|
|
||||||
|
|
||||||
const getButtonText = () => {
|
|
||||||
if (loading) {
|
|
||||||
if (retryCount > 0) {
|
|
||||||
return `${loadingText} (重试 ${retryCount}/${maxRetries})`;
|
|
||||||
}
|
|
||||||
return showLoadingText ? loadingText : children;
|
|
||||||
}
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonClassName = () => {
|
|
||||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
|
||||||
const variantClasses = loading
|
|
||||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
|
||||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
|
||||||
|
|
||||||
return `${baseClasses} ${variantClasses} ${className}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={requestWithRetry}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
className={getButtonClassName()}
|
|
||||||
>
|
|
||||||
{getButtonText()}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 可取消按钮内容组件
|
|
||||||
const CancellableButtonContent: React.FC<{
|
|
||||||
onClick: () => Promise<any>;
|
|
||||||
loadingText: string;
|
|
||||||
showLoadingText: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
className: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ onClick, loadingText, showLoadingText, disabled, className, children }) => {
|
|
||||||
const { cancellableRequest, loading, cancelRequest } = useCancellableRequest(onClick);
|
|
||||||
|
|
||||||
const getButtonText = () => {
|
|
||||||
return loading && showLoadingText ? loadingText : children;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getButtonClassName = () => {
|
|
||||||
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors duration-200 disabled:cursor-not-allowed';
|
|
||||||
const variantClasses = loading
|
|
||||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
|
||||||
: 'bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400';
|
|
||||||
|
|
||||||
return `${baseClasses} ${variantClasses} ${className}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={cancellableRequest}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
className={getButtonClassName()}
|
|
||||||
>
|
|
||||||
{getButtonText()}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{loading && cancelRequest && (
|
|
||||||
<button
|
|
||||||
onClick={cancelRequest}
|
|
||||||
className="px-3 py-2 rounded bg-red-500 text-white hover:bg-red-600 text-sm"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 导出其他类型的按钮组件
|
|
||||||
export const DebouncedButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { delay?: number }> = (props) => (
|
|
||||||
<ThrottledButton {...props} variant="debounce" delay={props.delay || 300} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const RetryButton: React.FC<Omit<ThrottledButtonProps, 'variant'> & { maxRetries?: number; retryDelay?: number }> = (props) => (
|
|
||||||
<ThrottledButton {...props} variant="retry" maxRetries={props.maxRetries || 3} retryDelay={props.retryDelay || 1000} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CancellableButton: React.FC<Omit<ThrottledButtonProps, 'variant'>> = (props) => (
|
|
||||||
<ThrottledButton {...props} variant="cancellable" />
|
|
||||||
);
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Search, Database } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import { useToast } from '@/components/ui/toast';
|
|
||||||
import { fetchDeviceLabels, type TrafficPool } from '@/api/trafficDistribution';
|
|
||||||
|
|
||||||
// 组件属性接口
|
|
||||||
interface TrafficPoolSelectionProps {
|
|
||||||
selectedPools: string[];
|
|
||||||
onSelect: (pools: string[]) => void;
|
|
||||||
deviceIds: string[];
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TrafficPoolSelection({
|
|
||||||
selectedPools,
|
|
||||||
onSelect,
|
|
||||||
deviceIds,
|
|
||||||
placeholder = "选择流量池",
|
|
||||||
className = ""
|
|
||||||
}: TrafficPoolSelectionProps) {
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [pools, setPools] = useState<TrafficPool[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [totalPools, setTotalPools] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// 获取流量池列表API
|
|
||||||
const fetchPools = useCallback(async (page: number, keyword: string = '') => {
|
|
||||||
if (deviceIds.length === 0) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetchDeviceLabels({
|
|
||||||
deviceIds,
|
|
||||||
page,
|
|
||||||
pageSize: 10,
|
|
||||||
keyword
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res && res.code === 200 && res.data) {
|
|
||||||
setPools(res.data.list || []);
|
|
||||||
setTotalPools(res.data.total || 0);
|
|
||||||
setTotalPages(Math.ceil((res.data.total || 0) / 10));
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "获取流量池列表失败",
|
|
||||||
description: res?.msg || "请稍后重试",
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
|
|
||||||
// 使用模拟数据作为降级处理
|
|
||||||
const mockData: TrafficPool[] = [
|
|
||||||
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
|
|
||||||
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
|
|
||||||
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
|
|
||||||
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
|
|
||||||
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
|
|
||||||
{ id: "6", name: "VIP客户池", count: 156, description: "VIP等级客户", deviceIds },
|
|
||||||
{ id: "7", name: "潜在客户池", count: 3200, description: "有潜在购买可能的客户", deviceIds },
|
|
||||||
{ id: "8", name: "游戏玩家池", count: 890, description: "游戏类产品感兴趣客户", deviceIds },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 根据关键词过滤模拟数据
|
|
||||||
const filteredData = keyword
|
|
||||||
? mockData.filter(pool =>
|
|
||||||
pool.name.toLowerCase().includes(keyword.toLowerCase()) ||
|
|
||||||
(pool.description && pool.description.toLowerCase().includes(keyword.toLowerCase()))
|
|
||||||
)
|
|
||||||
: mockData;
|
|
||||||
|
|
||||||
// 分页处理模拟数据
|
|
||||||
const startIndex = (page - 1) * 10;
|
|
||||||
const endIndex = startIndex + 10;
|
|
||||||
const paginatedData = filteredData.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
setPools(paginatedData);
|
|
||||||
setTotalPools(filteredData.length);
|
|
||||||
setTotalPages(Math.ceil(filteredData.length / 10));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取流量池列表失败:', error);
|
|
||||||
toast({
|
|
||||||
title: "网络错误",
|
|
||||||
description: "请检查网络连接后重试",
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
|
|
||||||
// 网络错误时使用模拟数据
|
|
||||||
const mockData: TrafficPool[] = [
|
|
||||||
{ id: "1", name: "新客流量池", count: 1250, description: "新获取的客户流量", deviceIds },
|
|
||||||
{ id: "2", name: "高意向流量池", count: 850, description: "有购买意向的客户", deviceIds },
|
|
||||||
{ id: "3", name: "复购流量池", count: 620, description: "已购买过产品的客户", deviceIds },
|
|
||||||
{ id: "4", name: "活跃流量池", count: 1580, description: "近期活跃的客户", deviceIds },
|
|
||||||
{ id: "5", name: "沉睡流量池", count: 2300, description: "长期未活跃的客户", deviceIds },
|
|
||||||
];
|
|
||||||
|
|
||||||
setPools(mockData);
|
|
||||||
setTotalPools(mockData.length);
|
|
||||||
setTotalPages(1);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [deviceIds, toast]);
|
|
||||||
|
|
||||||
// 当弹窗打开时获取流量池列表
|
|
||||||
useEffect(() => {
|
|
||||||
if (dialogOpen && deviceIds.length > 0) {
|
|
||||||
// 弹窗打开时重置搜索和页码,然后立即请求第一页数据
|
|
||||||
setSearchQuery('');
|
|
||||||
setCurrentPage(1);
|
|
||||||
fetchPools(1, '');
|
|
||||||
}
|
|
||||||
}, [dialogOpen, deviceIds, fetchPools]);
|
|
||||||
|
|
||||||
// 监听页码变化,重新请求数据
|
|
||||||
useEffect(() => {
|
|
||||||
if (dialogOpen && deviceIds.length > 0 && currentPage > 1) {
|
|
||||||
fetchPools(currentPage, searchQuery);
|
|
||||||
}
|
|
||||||
}, [currentPage, dialogOpen, deviceIds.length, fetchPools, searchQuery]);
|
|
||||||
|
|
||||||
// 当设备ID变化时,清空已选择的流量池(如果需要的话)
|
|
||||||
useEffect(() => {
|
|
||||||
if (deviceIds.length === 0) {
|
|
||||||
setPools([]);
|
|
||||||
setTotalPools(0);
|
|
||||||
setTotalPages(1);
|
|
||||||
}
|
|
||||||
}, [deviceIds]);
|
|
||||||
|
|
||||||
// 处理搜索
|
|
||||||
const handleSearch = (keyword: string) => {
|
|
||||||
setSearchQuery(keyword);
|
|
||||||
setCurrentPage(1);
|
|
||||||
// 立即搜索,不管弹窗是否打开(因为这个函数只在弹窗内调用)
|
|
||||||
if (deviceIds.length > 0) {
|
|
||||||
fetchPools(1, keyword);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理流量池选择
|
|
||||||
const handlePoolToggle = (poolId: string) => {
|
|
||||||
if (selectedPools.includes(poolId)) {
|
|
||||||
onSelect(selectedPools.filter(id => id !== poolId));
|
|
||||||
} else {
|
|
||||||
onSelect([...selectedPools, poolId]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取显示文本
|
|
||||||
const getDisplayText = () => {
|
|
||||||
if (selectedPools.length === 0) return '';
|
|
||||||
return `已选择 ${selectedPools.length} 个流量池`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理输入框点击
|
|
||||||
const handleInputClick = () => {
|
|
||||||
if (deviceIds.length === 0) {
|
|
||||||
toast({
|
|
||||||
title: "请先选择设备",
|
|
||||||
description: "需要先选择设备才能选择流量池",
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 输入框 */}
|
|
||||||
<div className={`relative ${className}`}>
|
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
|
||||||
<Database className="w-5 h-5" />
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="pl-10 h-12 rounded-xl border-gray-200 text-base"
|
|
||||||
readOnly
|
|
||||||
onClick={handleInputClick}
|
|
||||||
value={getDisplayText()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 流量池选择弹窗 */}
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
||||||
<DialogContent className="max-w-sm w-[90vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
|
|
||||||
<div>
|
|
||||||
<DialogTitle className="text-center text-xl font-medium mb-6">选择流量池</DialogTitle>
|
|
||||||
|
|
||||||
<div className="relative mb-4">
|
|
||||||
<Input
|
|
||||||
placeholder="搜索流量池"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
|
||||||
className="pl-10 py-2 rounded-full border-gray-200"
|
|
||||||
/>
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 overflow-y-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-gray-500">加载中...</div>
|
|
||||||
</div>
|
|
||||||
) : pools.length > 0 ? (
|
|
||||||
<div className="divide-y">
|
|
||||||
{pools.map((pool) => (
|
|
||||||
<label
|
|
||||||
key={pool.id}
|
|
||||||
className="flex items-center px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
|
||||||
onClick={() => handlePoolToggle(pool.id)}
|
|
||||||
>
|
|
||||||
<div className="mr-3 flex items-center justify-center">
|
|
||||||
<div className={`w-5 h-5 rounded-full border ${selectedPools.includes(pool.id) ? 'border-blue-600' : 'border-gray-300'} flex items-center justify-center`}>
|
|
||||||
{selectedPools.includes(pool.id) && (
|
|
||||||
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3 flex-1">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
|
|
||||||
<Database className="h-5 w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">{pool.name}</div>
|
|
||||||
{pool.description && (
|
|
||||||
<div className="text-sm text-gray-500">{pool.description}</div>
|
|
||||||
)}
|
|
||||||
<div className="text-sm text-gray-400">{pool.count} 人</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-gray-500">
|
|
||||||
{deviceIds.length === 0 ? '请先选择设备' : '没有找到流量池'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
总计 {totalPools} 个流量池
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
||||||
disabled={currentPage === 1 || loading}
|
|
||||||
className="px-2 py-0 h-8 min-w-0"
|
|
||||||
>
|
|
||||||
<
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm">{currentPage} / {totalPages}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
|
||||||
disabled={currentPage === totalPages || loading}
|
|
||||||
className="px-2 py-0 h-8 min-w-0"
|
|
||||||
>
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t p-4 flex items-center justify-between bg-white">
|
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)} className="px-6 rounded-full border-gray-300">
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleConfirm} className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full">
|
|
||||||
确定 ({selectedPools.length})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ChevronLeft, Settings, Bell, Search, RefreshCw, Filter, Plus, MoreVertical } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
interface HeaderAction {
|
|
||||||
type: 'button' | 'icon' | 'search' | 'custom';
|
|
||||||
icon?: React.ComponentType<any>;
|
|
||||||
label?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
variant?: 'default' | 'ghost' | 'outline' | 'destructive' | 'secondary';
|
|
||||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
|
||||||
className?: string;
|
|
||||||
content?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UnifiedHeaderProps {
|
|
||||||
/** 页面标题 */
|
|
||||||
title: string;
|
|
||||||
/** 是否显示返回按钮 */
|
|
||||||
showBack?: boolean;
|
|
||||||
/** 返回按钮文本 */
|
|
||||||
backText?: string;
|
|
||||||
/** 自定义返回逻辑 */
|
|
||||||
onBack?: () => void;
|
|
||||||
/** 默认返回路径 */
|
|
||||||
defaultBackPath?: string;
|
|
||||||
/** 右侧操作按钮 */
|
|
||||||
actions?: HeaderAction[];
|
|
||||||
/** 自定义右侧内容 */
|
|
||||||
rightContent?: React.ReactNode;
|
|
||||||
/** 是否显示搜索框 */
|
|
||||||
showSearch?: boolean;
|
|
||||||
/** 搜索框占位符 */
|
|
||||||
searchPlaceholder?: string;
|
|
||||||
/** 搜索值 */
|
|
||||||
searchValue?: string;
|
|
||||||
/** 搜索回调 */
|
|
||||||
onSearchChange?: (value: string) => void;
|
|
||||||
/** 是否显示底部边框 */
|
|
||||||
showBorder?: boolean;
|
|
||||||
/** 背景样式 */
|
|
||||||
background?: 'white' | 'transparent' | 'blur';
|
|
||||||
/** 自定义CSS类名 */
|
|
||||||
className?: string;
|
|
||||||
/** 标题样式类名 */
|
|
||||||
titleClassName?: string;
|
|
||||||
/** 标题颜色 */
|
|
||||||
titleColor?: 'default' | 'blue' | 'gray';
|
|
||||||
/** 是否居中标题 */
|
|
||||||
centerTitle?: boolean;
|
|
||||||
/** 头部高度 */
|
|
||||||
height?: 'default' | 'compact' | 'tall';
|
|
||||||
}
|
|
||||||
|
|
||||||
const UnifiedHeader: React.FC<UnifiedHeaderProps> = ({
|
|
||||||
title,
|
|
||||||
showBack = true,
|
|
||||||
backText = '返回',
|
|
||||||
onBack,
|
|
||||||
defaultBackPath = '/',
|
|
||||||
actions = [],
|
|
||||||
rightContent,
|
|
||||||
showSearch = false,
|
|
||||||
searchPlaceholder = '搜索...',
|
|
||||||
searchValue = '',
|
|
||||||
onSearchChange,
|
|
||||||
showBorder = true,
|
|
||||||
background = 'white',
|
|
||||||
className = '',
|
|
||||||
titleClassName = '',
|
|
||||||
titleColor = 'default',
|
|
||||||
centerTitle = false,
|
|
||||||
height = 'default',
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (onBack) {
|
|
||||||
onBack();
|
|
||||||
} else if (defaultBackPath) {
|
|
||||||
navigate(defaultBackPath);
|
|
||||||
} else {
|
|
||||||
if (window.history.length > 1) {
|
|
||||||
navigate(-1);
|
|
||||||
} else {
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 背景样式
|
|
||||||
const backgroundClasses = {
|
|
||||||
white: 'bg-white',
|
|
||||||
transparent: 'bg-transparent',
|
|
||||||
blur: 'bg-white/80 backdrop-blur-sm',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 高度样式
|
|
||||||
const heightClasses = {
|
|
||||||
default: 'h-14',
|
|
||||||
compact: 'h-12',
|
|
||||||
tall: 'h-16',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 标题颜色样式
|
|
||||||
const titleColorClasses = {
|
|
||||||
default: 'text-gray-900',
|
|
||||||
blue: 'text-blue-600',
|
|
||||||
gray: 'text-gray-600',
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerClasses = [
|
|
||||||
backgroundClasses[background],
|
|
||||||
heightClasses[height],
|
|
||||||
showBorder ? 'border-b border-gray-200' : '',
|
|
||||||
'sticky top-0 z-50',
|
|
||||||
className,
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
|
|
||||||
const titleClasses = [
|
|
||||||
'text-lg font-semibold',
|
|
||||||
titleColorClasses[titleColor],
|
|
||||||
centerTitle ? 'text-center' : '',
|
|
||||||
titleClassName,
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
|
|
||||||
// 渲染操作按钮
|
|
||||||
const renderAction = (action: HeaderAction, index: number) => {
|
|
||||||
if (action.type === 'custom' && action.content) {
|
|
||||||
return <div key={index}>{action.content}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === 'search') {
|
|
||||||
return (
|
|
||||||
<div key={index} className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
value={searchValue}
|
|
||||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
|
||||||
className="pl-9 w-48"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const IconComponent = action.icon || MoreVertical;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={index}
|
|
||||||
variant={action.variant || 'ghost'}
|
|
||||||
size={action.size || 'icon'}
|
|
||||||
onClick={action.onClick}
|
|
||||||
className={action.className}
|
|
||||||
>
|
|
||||||
<IconComponent className="h-5 w-5" />
|
|
||||||
{action.label && action.size !== 'icon' && (
|
|
||||||
<span className="ml-2">{action.label}</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className={headerClasses}>
|
|
||||||
<div className="flex items-center justify-between px-4 h-full">
|
|
||||||
{/* 左侧:返回按钮和标题 */}
|
|
||||||
<div className="flex items-center space-x-3 flex-1">
|
|
||||||
{showBack && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleBack}
|
|
||||||
className="h-8 w-8 hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!centerTitle && (
|
|
||||||
<h1 className={titleClasses}>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 中间:居中标题 */}
|
|
||||||
{centerTitle && (
|
|
||||||
<div className="flex-1 flex justify-center">
|
|
||||||
<h1 className={titleClasses}>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 右侧:搜索框、操作按钮、自定义内容 */}
|
|
||||||
<div className="flex items-center space-x-2 flex-1 justify-end">
|
|
||||||
{showSearch && !actions.some(a => a.type === 'search') && (
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
placeholder={searchPlaceholder}
|
|
||||||
value={searchValue}
|
|
||||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
|
||||||
className="pl-9 w-48"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actions.map((action, index) => renderAction(action, index))}
|
|
||||||
|
|
||||||
{rightContent && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{rightContent}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 预设的常用Header配置
|
|
||||||
export const HeaderPresets = {
|
|
||||||
// 基础页面Header(有返回按钮)
|
|
||||||
basic: (title: string, onBack?: () => void): UnifiedHeaderProps => ({
|
|
||||||
title,
|
|
||||||
showBack: true,
|
|
||||||
onBack,
|
|
||||||
titleColor: 'blue',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 主页Header(无返回按钮)
|
|
||||||
main: (title: string, actions?: HeaderAction[]): UnifiedHeaderProps => ({
|
|
||||||
title,
|
|
||||||
showBack: false,
|
|
||||||
titleColor: 'blue',
|
|
||||||
actions: actions || [
|
|
||||||
{
|
|
||||||
type: 'icon',
|
|
||||||
icon: Bell,
|
|
||||||
onClick: () => console.log('Notifications'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 搜索页面Header
|
|
||||||
search: (title: string, searchValue: string, onSearchChange: (value: string) => void): UnifiedHeaderProps => ({
|
|
||||||
title,
|
|
||||||
showBack: true,
|
|
||||||
showSearch: true,
|
|
||||||
searchValue,
|
|
||||||
onSearchChange,
|
|
||||||
titleColor: 'blue',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 列表页面Header(带刷新和添加)
|
|
||||||
list: (title: string, onRefresh?: () => void, onAdd?: () => void): UnifiedHeaderProps => ({
|
|
||||||
title,
|
|
||||||
showBack: true,
|
|
||||||
titleColor: 'blue',
|
|
||||||
actions: [
|
|
||||||
...(onRefresh ? [{
|
|
||||||
type: 'icon' as const,
|
|
||||||
icon: RefreshCw,
|
|
||||||
onClick: onRefresh,
|
|
||||||
}] : []),
|
|
||||||
...(onAdd ? [{
|
|
||||||
type: 'button' as const,
|
|
||||||
icon: Plus,
|
|
||||||
label: '新建',
|
|
||||||
size: 'sm' as const,
|
|
||||||
onClick: onAdd,
|
|
||||||
}] : []),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 设置页面Header
|
|
||||||
settings: (title: string): UnifiedHeaderProps => ({
|
|
||||||
title,
|
|
||||||
showBack: true,
|
|
||||||
titleColor: 'blue',
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
type: 'icon',
|
|
||||||
icon: Settings,
|
|
||||||
onClick: () => console.log('Settings'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UnifiedHeader;
|
|
||||||
180
Cunkebao/src/components/UpdateNotification/index.tsx
Normal file
180
Cunkebao/src/components/UpdateNotification/index.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "antd-mobile";
|
||||||
|
import { updateChecker } from "@/utils/updateChecker";
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
CloudDownloadOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
|
interface UpdateNotificationProps {
|
||||||
|
position?: "top" | "bottom";
|
||||||
|
autoReload?: boolean;
|
||||||
|
showToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
|
||||||
|
position = "top",
|
||||||
|
autoReload = false,
|
||||||
|
showToast = true,
|
||||||
|
}) => {
|
||||||
|
const [hasUpdate, setHasUpdate] = useState(false);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 注册更新检测回调
|
||||||
|
const handleUpdate = (info: { hasUpdate: boolean }) => {
|
||||||
|
if (info.hasUpdate) {
|
||||||
|
setHasUpdate(true);
|
||||||
|
setIsVisible(true);
|
||||||
|
|
||||||
|
if (autoReload) {
|
||||||
|
// 自动刷新
|
||||||
|
setTimeout(() => {
|
||||||
|
updateChecker.forceReload();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateChecker.onUpdate(handleUpdate);
|
||||||
|
|
||||||
|
// 启动更新检测
|
||||||
|
updateChecker.start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
updateChecker.offUpdate(handleUpdate);
|
||||||
|
updateChecker.stop();
|
||||||
|
};
|
||||||
|
}, [autoReload, showToast]);
|
||||||
|
const handleReload = () => {
|
||||||
|
updateChecker.forceReload();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible || !hasUpdate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 99999,
|
||||||
|
background: "linear-gradient(135deg, #1890ff 0%, #096dd9 100%)",
|
||||||
|
color: "white",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 背景装饰 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "10%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
fontSize: "120px",
|
||||||
|
opacity: 0.1,
|
||||||
|
animation: "float 3s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RocketOutlined />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主要内容 */}
|
||||||
|
<div style={{ position: "relative", zIndex: 1 }}>
|
||||||
|
{/* 图标 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "80px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
animation: "pulse 2s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloudDownloadOutlined />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "28px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "12px",
|
||||||
|
textShadow: "0 2px 4px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
发现新版本
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "16px",
|
||||||
|
opacity: 0.9,
|
||||||
|
marginBottom: "40px",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
maxWidth: "300px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
为了给您提供更好的体验,请更新到最新版本
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 更新按钮 */}
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.9)",
|
||||||
|
border: "2px solid rgba(255,255,255,0.5)",
|
||||||
|
color: "#1890ff",
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
padding: "12px 40px",
|
||||||
|
borderRadius: "50px",
|
||||||
|
backdropFilter: "blur(10px)",
|
||||||
|
boxShadow: "0 8px 32px rgba(24,144,255,0.3)",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
}}
|
||||||
|
onClick={handleReload}
|
||||||
|
>
|
||||||
|
<ReloadOutlined style={{ marginRight: "8px" }} />
|
||||||
|
立即更新
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 提示文字 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
opacity: 0.7,
|
||||||
|
marginTop: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
更新将自动重启应用
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动画样式 */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateX(-50%) translateY(0px); }
|
||||||
|
50% { transform: translateX(-50%) translateY(-20px); }
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateNotification;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user