Merge branch 'yongpxu-dev' into develop
This commit is contained in:
@@ -602,7 +602,7 @@ const ScenarioList: React.FC = () => {
|
||||
<div className={style["link-label"]}>小程序链接</div>
|
||||
<div className={style["link-input-wrapper"]}>
|
||||
<Input
|
||||
value={`https://h5.ckb.quwanzhi.com/#/pages/form/input?id=${currentTaskId}`}
|
||||
value={`https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`}
|
||||
readOnly
|
||||
className={style["link-input"]}
|
||||
placeholder="小程序链接"
|
||||
@@ -610,7 +610,7 @@ const ScenarioList: React.FC = () => {
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const link = `https://h5.ckb.quwanzhi.com/#/pages/form/input?id=${currentTaskId}`;
|
||||
const link = `https://h5.ckb.quwanzhi.com/#/pages/form/input2?id=${currentTaskId}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
Toast.show({
|
||||
content: "链接已复制到剪贴板",
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# 存客宝项目 - 浏览器兼容性说明
|
||||
|
||||
## 🎯 **兼容性目标**
|
||||
|
||||
本项目已配置为支持以下浏览器版本:
|
||||
|
||||
- **Chrome**: 50+
|
||||
- **Firefox**: 50+
|
||||
- **Safari**: 10+
|
||||
- **Edge**: 12+
|
||||
- **Internet Explorer**: 11+ (部分功能受限)
|
||||
- **Android**: 4.4+ (特别优化Android 7)
|
||||
- **iOS**: 9+
|
||||
|
||||
## 🔧 **兼容性配置**
|
||||
|
||||
### 1. **Polyfill 支持**
|
||||
|
||||
项目已集成以下 polyfill 来确保低版本浏览器兼容性:
|
||||
|
||||
- **core-js**: ES6+ 特性支持
|
||||
- **regenerator-runtime**: async/await 支持
|
||||
- **whatwg-fetch**: fetch API 支持
|
||||
- **Android专用polyfill**: 针对Android 7等低版本系统优化
|
||||
|
||||
### 2. **构建配置**
|
||||
|
||||
- 使用 **terser** 进行代码压缩
|
||||
- 配置了 **browserslist** 目标浏览器
|
||||
- 添加了兼容性检测组件
|
||||
- 特别针对Android设备优化
|
||||
|
||||
### 3. **特性支持**
|
||||
|
||||
项目通过 polyfill 支持以下 ES6+ 特性:
|
||||
|
||||
- ✅ Promise
|
||||
- ✅ fetch API
|
||||
- ✅ Array.from, Array.find, Array.includes, Array.findIndex
|
||||
- ✅ Object.assign, Object.entries, Object.values, Object.keys
|
||||
- ✅ String.includes, String.startsWith, String.endsWith
|
||||
- ✅ Map, Set, WeakMap, WeakSet
|
||||
- ✅ Symbol
|
||||
- ✅ requestAnimationFrame
|
||||
- ✅ IntersectionObserver
|
||||
- ✅ ResizeObserver
|
||||
- ✅ URLSearchParams
|
||||
- ✅ AbortController
|
||||
|
||||
## 🚀 **使用方法**
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 生产构建
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 预览构建结果
|
||||
|
||||
```bash
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 📱 **Android 特别优化**
|
||||
|
||||
### **Android 7 兼容性**
|
||||
|
||||
Android 7 (API 24) 系统对ES6+特性支持不完整,项目已特别优化:
|
||||
|
||||
#### **问题解决:**
|
||||
|
||||
- ✅ Array.prototype.includes 方法缺失
|
||||
- ✅ String.prototype.includes 方法缺失
|
||||
- ✅ Object.assign 方法缺失
|
||||
- ✅ Array.from 方法缺失
|
||||
- ✅ requestAnimationFrame 缺失
|
||||
- ✅ IntersectionObserver 缺失
|
||||
- ✅ URLSearchParams 缺失
|
||||
|
||||
#### **解决方案:**
|
||||
|
||||
- 使用自定义polyfill补充缺失方法
|
||||
- 提供降级实现确保功能可用
|
||||
- 自动检测Android版本并启用相应polyfill
|
||||
|
||||
### **Android WebView 优化**
|
||||
|
||||
- 针对系统WebView进行特别优化
|
||||
- 支持微信、QQ等内置浏览器
|
||||
- 提供降级方案确保基本功能可用
|
||||
|
||||
## ⚠️ **注意事项**
|
||||
|
||||
1. **Android 7 支持**
|
||||
- 已启用兼容模式,基本功能可用
|
||||
- 建议升级到Android 8+或使用最新版Chrome
|
||||
- 部分高级特性可能受限
|
||||
|
||||
2. **Android 6 及以下**
|
||||
- 支持有限,建议升级系统
|
||||
- 使用最新版Chrome浏览器
|
||||
- 部分功能可能不可用
|
||||
|
||||
3. **移动端兼容性**
|
||||
- iOS Safari 10+
|
||||
- Android Chrome 50+
|
||||
- 微信内置浏览器 (部分功能受限)
|
||||
- QQ内置浏览器 (部分功能受限)
|
||||
|
||||
4. **性能考虑**
|
||||
- polyfill 会增加包体积
|
||||
- 现代浏览器会自动忽略不需要的 polyfill
|
||||
- Android设备上会有额外的兼容性检测
|
||||
|
||||
## 🔍 **兼容性检测**
|
||||
|
||||
项目包含自动兼容性检测功能:
|
||||
|
||||
### **通用检测**
|
||||
|
||||
- 在低版本浏览器中会显示警告提示
|
||||
- 控制台会输出兼容性信息
|
||||
- 建议用户升级浏览器
|
||||
|
||||
### **Android专用检测**
|
||||
|
||||
- 自动检测Android系统版本
|
||||
- 检测Chrome和WebView版本
|
||||
- 识别微信、QQ等内置浏览器
|
||||
- 提供针对性的解决方案建议
|
||||
|
||||
## 📝 **更新日志**
|
||||
|
||||
### v3.0.0
|
||||
|
||||
- ✅ 添加 ES5 兼容性支持
|
||||
- ✅ 集成 core-js polyfill
|
||||
- ✅ 添加兼容性检测组件
|
||||
- ✅ 优化构建配置
|
||||
- ✅ **新增Android 7专用polyfill**
|
||||
- ✅ **新增Android兼容性检测**
|
||||
- ✅ **优化移动端体验**
|
||||
|
||||
## 🛠️ **故障排除**
|
||||
|
||||
如果遇到兼容性问题:
|
||||
|
||||
1. **Android设备问题**
|
||||
- 检查Android系统版本
|
||||
- 确认Chrome浏览器版本
|
||||
- 查看控制台错误信息
|
||||
- 尝试使用系统浏览器而非内置浏览器
|
||||
|
||||
2. **通用问题**
|
||||
- 检查浏览器版本是否在支持范围内
|
||||
- 查看控制台是否有错误信息
|
||||
- 确认 polyfill 是否正确加载
|
||||
- 尝试清除浏览器缓存
|
||||
|
||||
3. **性能问题**
|
||||
- 在低版本设备上可能加载较慢
|
||||
- 建议使用WiFi网络
|
||||
- 关闭不必要的浏览器扩展
|
||||
|
||||
## 📞 **技术支持**
|
||||
|
||||
如有兼容性问题,请联系开发团队。
|
||||
|
||||
### **特别说明**
|
||||
|
||||
本项目已针对Android 7等低版本系统进行了特别优化,通过代码弥补了系统内核对ES6+特性支持不完整的问题。虽然不能完全替代系统升级,但可以确保应用在低版本Android设备上正常运行。
|
||||
@@ -1,26 +0,0 @@
|
||||
## 使用技术栈
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite(新一代前端构建工具)
|
||||
- axios
|
||||
- sass (scss)
|
||||
- React Router v6
|
||||
- antd-mobile
|
||||
- antd(已设置基础单位为 rem,配合 postcss-pxtorem)
|
||||
- postcss-pxtorem(px 转 rem,移动端适配)
|
||||
- ESLint + Prettier(代码规范与自动格式化)
|
||||
- 路径别名 @ 指向 src 目录
|
||||
|
||||
## 关于兼容与工程化
|
||||
- 自动化脚本(yarn lint、yarn dev 等)
|
||||
- 移动端 rem 适配(html 根字体 + pxtorem)
|
||||
- iOS 浏览器滚动回弹兼容问题已通过全局样式处理
|
||||
- 支持 VS Code 编辑器自动格式化(推荐配合 ESLint/Prettier 插件)
|
||||
|
||||
## 目录结构简要
|
||||
- src/ 业务源码(pages、api、styles、App.tsx、main.tsx 等)
|
||||
- public/ 静态资源目录
|
||||
- index.html 项目入口(根目录)
|
||||
- vite.config.ts 构建与路径别名配置
|
||||
- tsconfig.json TypeScript 配置
|
||||
- .eslintrc.js 代码规范配置
|
||||
@@ -11,6 +11,7 @@
|
||||
align-items: center;
|
||||
min-height: 64px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
justify-content: space-between;
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PowerNavigationProps {
|
||||
onBackClick?: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
rightContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PowerNavigation: React.FC<PowerNavigationProps> = ({
|
||||
@@ -23,6 +24,7 @@ const PowerNavigation: React.FC<PowerNavigationProps> = ({
|
||||
onBackClick,
|
||||
className,
|
||||
style,
|
||||
rightContent,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -57,6 +59,7 @@ const PowerNavigation: React.FC<PowerNavigationProps> = ({
|
||||
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerRight}>{rightContent}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
243
Touchkebao/src/components/Upload/AudioUpload/index.module.scss
Normal file
243
Touchkebao/src/components/Upload/AudioUpload/index.module.scss
Normal file
@@ -0,0 +1,243 @@
|
||||
.videoUploadContainer {
|
||||
width: 100%;
|
||||
|
||||
// 覆盖 antd Upload 组件的默认样式
|
||||
:global {
|
||||
.ant-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-upload-list-text .ant-upload-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.videoUploadButton {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: clamp(90px, 20vw, 180px);
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.uploadingContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.uploadingIcon {
|
||||
font-size: clamp(24px, 4vw, 32px);
|
||||
color: #1890ff;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.uploadingText {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uploadProgress {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
||||
.uploadIcon {
|
||||
font-size: clamp(50px, 6vw, 48px);
|
||||
color: #1890ff;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.uploadText {
|
||||
.uploadTitle {
|
||||
font-size: clamp(14px, 2.5vw, 16px);
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.uploadSubtitle {
|
||||
font-size: clamp(10px, 1.5vw, 14px);
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .uploadIcon {
|
||||
transform: scale(1.1);
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoItem {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.videoItemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.videoIcon {
|
||||
width: clamp(28px, 5vw, 40px);
|
||||
height: clamp(28px, 5vw, 40px);
|
||||
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: clamp(14px, 2.5vw, 18px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.videoInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.videoName {
|
||||
font-size: clamp(11px, 2vw, 14px);
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.videoSize {
|
||||
font-size: clamp(10px, 1.5vw, 12px);
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.videoActions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.previewBtn,
|
||||
.deleteBtn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.previewBtn {
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
color: #ff7875;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemProgress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoPreview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
video {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
.videoUploadContainer.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.videoUploadContainer.error {
|
||||
.videoUploadButton {
|
||||
border-color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 动画效果
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
385
Touchkebao/src/components/Upload/AudioUpload/index.tsx
Normal file
385
Touchkebao/src/components/Upload/AudioUpload/index.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import React, { useState } from "react";
|
||||
import { Upload, message, Progress, Button, Modal } from "antd";
|
||||
import {
|
||||
LoadingOutlined,
|
||||
SoundOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
FileOutlined,
|
||||
CloudUploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
interface AudioUploadProps {
|
||||
value?: string | string[]; // 支持单个字符串或字符串数组
|
||||
onChange?: (url: string | string[]) => void; // 支持单个字符串或字符串数组
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
maxSize?: number; // 最大文件大小(MB)
|
||||
showPreview?: boolean; // 是否显示预览
|
||||
maxCount?: number; // 最大上传数量,默认为1
|
||||
}
|
||||
|
||||
const AudioUpload: React.FC<AudioUploadProps> = ({
|
||||
value = "",
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
maxSize = 50,
|
||||
showPreview = true,
|
||||
maxCount = 1,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
// 处理单个字符串或字符串数组
|
||||
const urls = Array.isArray(value) ? value : [value];
|
||||
const files: UploadFile[] = urls.map((url, index) => ({
|
||||
uid: `file-${index}`,
|
||||
name: `audio-${index + 1}`,
|
||||
status: "done",
|
||||
url: url || "",
|
||||
}));
|
||||
setFileList(files);
|
||||
} else {
|
||||
setFileList([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 文件验证
|
||||
const beforeUpload = (file: File) => {
|
||||
const isAudio = file.type.startsWith("audio/");
|
||||
if (!isAudio) {
|
||||
message.error("只能上传音频文件!");
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(`音频大小不能超过${maxSize}MB!`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 处理文件变化
|
||||
const handleChange: UploadProps["onChange"] = info => {
|
||||
// 更新 fileList,确保所有 URL 都是字符串
|
||||
const updatedFileList = info.fileList.map(file => {
|
||||
let url = "";
|
||||
|
||||
if (file.url) {
|
||||
url = file.url;
|
||||
} else if (file.response) {
|
||||
// 处理响应对象
|
||||
if (typeof file.response === "string") {
|
||||
url = file.response;
|
||||
} else if (file.response.data) {
|
||||
url =
|
||||
typeof file.response.data === "string"
|
||||
? file.response.data
|
||||
: file.response.data.url || "";
|
||||
} else if (file.response.url) {
|
||||
url = file.response.url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...file,
|
||||
url: url,
|
||||
};
|
||||
});
|
||||
|
||||
setFileList(updatedFileList);
|
||||
|
||||
// 处理上传状态
|
||||
if (info.file.status === "uploading") {
|
||||
setLoading(true);
|
||||
// 模拟上传进度
|
||||
const progress = Math.min(99, Math.random() * 100);
|
||||
setUploadProgress(progress);
|
||||
} else if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
setUploadProgress(100);
|
||||
|
||||
// 从响应中获取上传后的URL
|
||||
let uploadedUrl = "";
|
||||
|
||||
if (info.file.response) {
|
||||
// 检查响应是否成功
|
||||
if (info.file.response.code && info.file.response.code !== 200) {
|
||||
message.error(info.file.response.message || "上传失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof info.file.response === "string") {
|
||||
uploadedUrl = info.file.response;
|
||||
} else if (info.file.response.data) {
|
||||
uploadedUrl =
|
||||
typeof info.file.response.data === "string"
|
||||
? info.file.response.data
|
||||
: info.file.response.data.url || "";
|
||||
} else if (info.file.response.url) {
|
||||
uploadedUrl = info.file.response.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
message.success("音频上传成功!");
|
||||
if (maxCount === 1) {
|
||||
// 单个音频模式
|
||||
onChange?.(uploadedUrl);
|
||||
} else {
|
||||
// 多个音频模式
|
||||
const currentUrls = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const newUrls = [...currentUrls, uploadedUrl];
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
} else {
|
||||
message.error("上传失败,未获取到音频URL");
|
||||
}
|
||||
} else if (info.file.status === "error") {
|
||||
setLoading(false);
|
||||
setUploadProgress(0);
|
||||
message.error("上传失败,请重试");
|
||||
} else if (info.file.status === "removed") {
|
||||
if (maxCount === 1) {
|
||||
onChange?.("");
|
||||
} else {
|
||||
// 多个音频模式,移除对应的音频
|
||||
const currentUrls = Array.isArray(value) ? value : value ? [value] : [];
|
||||
const removedIndex = info.fileList.findIndex(
|
||||
f => f.uid === info.file.uid,
|
||||
);
|
||||
if (removedIndex !== -1) {
|
||||
const newUrls = currentUrls.filter(
|
||||
(_, index) => index !== removedIndex,
|
||||
);
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 删除文件
|
||||
const handleRemove = (file?: UploadFile) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "确定要删除这个音频文件吗?",
|
||||
okText: "确定",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
if (maxCount === 1) {
|
||||
setFileList([]);
|
||||
onChange?.("");
|
||||
} else if (file) {
|
||||
// 多个音频模式,删除指定音频
|
||||
const currentUrls = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const fileIndex = fileList.findIndex(f => f.uid === file.uid);
|
||||
if (fileIndex !== -1) {
|
||||
const newUrls = currentUrls.filter(
|
||||
(_, index) => index !== fileIndex,
|
||||
);
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
}
|
||||
message.success("音频已删除");
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
// 预览音频
|
||||
const handlePreview = (url: string) => {
|
||||
setPreviewUrl(url);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
// 获取文件大小显示
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
// 自定义上传按钮
|
||||
const uploadButton = (
|
||||
<div className={style.videoUploadButton}>
|
||||
{loading ? (
|
||||
<div className={style.uploadingContainer}>
|
||||
<div className={style.uploadingIcon}>
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
<div className={style.uploadingText}>上传中...</div>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#1890ff"
|
||||
className={style.uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.uploadContent}>
|
||||
<div className={style.uploadIcon}>
|
||||
<CloudUploadOutlined />
|
||||
</div>
|
||||
<div className={style.uploadText}>
|
||||
<div className={style.uploadTitle}>
|
||||
{maxCount === 1
|
||||
? "上传音频"
|
||||
: `上传音频 (${fileList.length}/${maxCount})`}
|
||||
</div>
|
||||
<div className={style.uploadSubtitle}>
|
||||
支持 MP3、WAV、AAC 等格式,最大 {maxSize}MB
|
||||
{maxCount > 1 && `,最多上传 ${maxCount} 个音频`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 自定义文件列表项
|
||||
const customItemRender = (
|
||||
originNode: React.ReactElement,
|
||||
file: UploadFile,
|
||||
) => {
|
||||
if (file.status === "uploading") {
|
||||
return (
|
||||
<div className={style.videoItem}>
|
||||
<div className={style.videoItemContent}>
|
||||
<div className={style.videoIcon}>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className={style.videoInfo}>
|
||||
<div className={style.videoName}>{file.name}</div>
|
||||
<div className={style.videoSize}>
|
||||
{file.size ? formatFileSize(file.size) : "计算中..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.videoActions}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(file)}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
size="small"
|
||||
strokeColor="#1890ff"
|
||||
className={style.itemProgress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.status === "done") {
|
||||
return (
|
||||
<div className={style.videoItem}>
|
||||
<div className={style.videoItemContent}>
|
||||
<div className={style.videoIcon}>
|
||||
<SoundOutlined />
|
||||
</div>
|
||||
<div className={style.videoInfo}>
|
||||
<div className={style.videoName}>{file.name}</div>
|
||||
<div className={style.videoSize}>
|
||||
{file.size ? formatFileSize(file.size) : "未知大小"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.videoActions}>
|
||||
{showPreview && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handlePreview(file.url || "")}
|
||||
className={style.previewBtn}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(file)}
|
||||
className={style.deleteBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return originNode;
|
||||
};
|
||||
|
||||
const action = import.meta.env.VITE_API_BASE_URL + "/v1/attachment/upload";
|
||||
|
||||
return (
|
||||
<div className={`${style.videoUploadContainer} ${className || ""}`}>
|
||||
<Upload
|
||||
name="file"
|
||||
headers={{
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
}}
|
||||
action={action}
|
||||
multiple={maxCount > 1}
|
||||
fileList={fileList}
|
||||
accept="audio/*"
|
||||
listType="text"
|
||||
showUploadList={{
|
||||
showPreviewIcon: false,
|
||||
showRemoveIcon: false,
|
||||
showDownloadIcon: false,
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
beforeUpload={beforeUpload}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
maxCount={maxCount}
|
||||
itemRender={customItemRender}
|
||||
>
|
||||
{fileList.length >= maxCount ? null : uploadButton}
|
||||
</Upload>
|
||||
|
||||
{/* 音频预览模态框 */}
|
||||
<Modal
|
||||
title="音频预览"
|
||||
open={previewVisible}
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
>
|
||||
<div className={style.videoPreview}>
|
||||
<audio controls style={{ width: "100%" }} src={previewUrl}>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioUpload;
|
||||
@@ -176,12 +176,17 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
} else if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
setUploadProgress(100);
|
||||
message.success("文件上传成功!");
|
||||
|
||||
// 从响应中获取上传后的URL
|
||||
let uploadedUrl = "";
|
||||
|
||||
if (info.file.response) {
|
||||
// 检查响应是否成功
|
||||
if (info.file.response.code && info.file.response.code !== 200) {
|
||||
message.error(info.file.response.message || "上传失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof info.file.response === "string") {
|
||||
uploadedUrl = info.file.response;
|
||||
} else if (info.file.response.data) {
|
||||
@@ -195,6 +200,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
message.success("文件上传成功!");
|
||||
if (maxCount === 1) {
|
||||
// 单个文件模式
|
||||
onChange?.(uploadedUrl);
|
||||
@@ -208,6 +214,8 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
const newUrls = [...currentUrls, uploadedUrl];
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
} else {
|
||||
message.error("上传失败,未获取到文件URL");
|
||||
}
|
||||
} else if (info.file.status === "error") {
|
||||
setLoading(false);
|
||||
|
||||
@@ -108,12 +108,17 @@ const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
} else if (info.file.status === "done") {
|
||||
setLoading(false);
|
||||
setUploadProgress(100);
|
||||
message.success("视频上传成功!");
|
||||
|
||||
// 从响应中获取上传后的URL
|
||||
let uploadedUrl = "";
|
||||
|
||||
if (info.file.response) {
|
||||
// 检查响应是否成功
|
||||
if (info.file.response.code && info.file.response.code !== 200) {
|
||||
message.error(info.file.response.message || "上传失败");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof info.file.response === "string") {
|
||||
uploadedUrl = info.file.response;
|
||||
} else if (info.file.response.data) {
|
||||
@@ -127,6 +132,7 @@ const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
}
|
||||
|
||||
if (uploadedUrl) {
|
||||
message.success("视频上传成功!");
|
||||
if (maxCount === 1) {
|
||||
// 单个视频模式
|
||||
onChange?.(uploadedUrl);
|
||||
@@ -140,6 +146,8 @@ const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
const newUrls = [...currentUrls, uploadedUrl];
|
||||
onChange?.(newUrls);
|
||||
}
|
||||
} else {
|
||||
message.error("上传失败,未获取到视频URL");
|
||||
}
|
||||
} else if (info.file.status === "error") {
|
||||
setLoading(false);
|
||||
|
||||
@@ -18,7 +18,7 @@ const IndexPage: React.FC = () => {
|
||||
if (isMobile()) {
|
||||
navigate("/mobile/dashboard");
|
||||
} else {
|
||||
navigate("/pc/dashboard");
|
||||
navigate("/pc/weChat");
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ export function clearUnreadCount1(params) {
|
||||
return request("/v1/kefu/message/readMessage", params, "GET");
|
||||
}
|
||||
export function clearUnreadCount2(params) {
|
||||
return request("/api/WechatFriend/clearUnreadCount", params, "PUT");
|
||||
return request2("/api/WechatFriend/clearUnreadCount", params, "PUT");
|
||||
}
|
||||
|
||||
//更新配置
|
||||
export function updateConfig(params) {
|
||||
return request("/api/WechatFriend/updateConfig", params, "PUT");
|
||||
return request2("/api/WechatFriend/updateConfig", params, "PUT");
|
||||
}
|
||||
//获取聊天记录-2 获取列表
|
||||
export function getChatMessages(params: {
|
||||
@@ -44,7 +44,7 @@ export function getChatMessages(params: {
|
||||
Count: number;
|
||||
olderData: boolean;
|
||||
}) {
|
||||
return request("/api/FriendMessage/SearchMessage", params, "GET");
|
||||
return request2("/api/FriendMessage/SearchMessage", params, "GET");
|
||||
}
|
||||
export function getChatroomMessages(params: {
|
||||
wechatAccountId: number;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
export const getAiSettings = () => {
|
||||
return request("/v1/kefu/ai/friend/get", "GET");
|
||||
};
|
||||
|
||||
export const setAiSettings = (params: {
|
||||
isUpdata: string;
|
||||
packageId: string[];
|
||||
type: number;
|
||||
}) => {
|
||||
return request("/v1/kefu/ai/friend/setAll", params, "POST");
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
.container {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.tip {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
color: #586069;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.formItem {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #24292e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
margin-top: 20px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
:global(.ant-list-item) {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-list-item-meta-title) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #24292e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
:global(.ant-list-item-meta-description) {
|
||||
font-size: 12px;
|
||||
color: #586069;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
text-align: center;
|
||||
|
||||
.emptyText {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.emptyDesc {
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.left,
|
||||
.right {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, Select, Button, Space, Tag, List, message, Modal } from "antd";
|
||||
import { DatabaseOutlined, ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import PoolSelection from "@/components/PoolSelection";
|
||||
import { PoolSelectionItem } from "@/components/PoolSelection/data";
|
||||
import { setAiSettings } from "./api";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const ReceptionSettings: React.FC = () => {
|
||||
const [selectedPools, setSelectedPools] = useState<PoolSelectionItem[]>([]);
|
||||
const [mode, setMode] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 0, label: "人工接待" },
|
||||
{ value: 1, label: "AI辅助" },
|
||||
{ value: 2, label: "AI接管" },
|
||||
];
|
||||
|
||||
// 处理流量池选择
|
||||
const handlePoolSelect = (pools: PoolSelectionItem[]) => {
|
||||
setSelectedPools(pools);
|
||||
};
|
||||
|
||||
// 处理接待模式选择
|
||||
const handleModeChange = (value: number) => {
|
||||
setMode(value);
|
||||
};
|
||||
|
||||
// 处理批量设置
|
||||
const handleBatchSet = async () => {
|
||||
if (selectedPools.length === 0) {
|
||||
message.warning("请先选择流量池");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedModeLabel =
|
||||
typeOptions.find(opt => opt.value === mode)?.label || "人工接待";
|
||||
const poolNames = selectedPools
|
||||
.map(pool => pool.name || pool.nickname)
|
||||
.join("、");
|
||||
|
||||
Modal.confirm({
|
||||
title: "确认批量设置",
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
<p>您即将对以下流量池进行批量设置:</p>
|
||||
<div
|
||||
style={{
|
||||
margin: "12px 0",
|
||||
padding: "8px 12px",
|
||||
background: "#f5f5f5",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<strong>流量池:</strong>
|
||||
{poolNames}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
margin: "12px 0",
|
||||
padding: "8px 12px",
|
||||
background: "#f5f5f5",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<strong>接待模式:</strong>
|
||||
{selectedModeLabel}
|
||||
</div>
|
||||
<p style={{ color: "#ff4d4f", marginTop: "12px" }}>
|
||||
此操作将影响 {selectedPools.length}{" "}
|
||||
个流量池的接待设置,确定要继续吗?
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: "确认设置",
|
||||
cancelText: "取消",
|
||||
okType: "primary",
|
||||
onOk: async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const packageIds = selectedPools.map(pool => pool.id);
|
||||
const params = {
|
||||
isUpdata: "1", // 1表示更新,0表示新增
|
||||
packageId: packageIds,
|
||||
type: mode,
|
||||
};
|
||||
|
||||
const response = await setAiSettings(params);
|
||||
if (response) {
|
||||
message.success(
|
||||
`成功为 ${selectedPools.length} 个流量池设置接待模式为"${selectedModeLabel}"`,
|
||||
);
|
||||
// 可以在这里刷新流量池状态列表
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("批量设置失败:", error);
|
||||
message.error("批量设置失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.left}>
|
||||
<Card
|
||||
title={
|
||||
<Space size={8}>
|
||||
<DatabaseOutlined />
|
||||
<span>全局接待模式</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className={styles.tip}>
|
||||
支持按流量池批量设置,单个客户设置将覆盖流量池默认配置
|
||||
</div>
|
||||
|
||||
<div className={styles.formItem}>
|
||||
<div className={styles.label}>选择流量池</div>
|
||||
<PoolSelection
|
||||
selectedOptions={selectedPools}
|
||||
onSelect={handlePoolSelect}
|
||||
placeholder="请选择流量池"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formItem}>
|
||||
<div className={styles.label}>接待模式</div>
|
||||
<Select
|
||||
value={mode}
|
||||
onChange={handleModeChange}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{typeOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
className={styles.primaryBtn}
|
||||
onClick={handleBatchSet}
|
||||
loading={loading}
|
||||
>
|
||||
批量设置
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className={styles.right}>
|
||||
<Card
|
||||
title={
|
||||
<Space size={8}>
|
||||
<DatabaseOutlined />
|
||||
<span>流量池状态</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{selectedPools.length > 0 ? (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={selectedPools}
|
||||
renderItem={item => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={item.name || item.nickname}
|
||||
description={`${item.num || 0} 个客户`}
|
||||
/>
|
||||
<Tag color="blue">
|
||||
{typeOptions.find(opt => opt.value === mode)?.label ||
|
||||
"人工接待"}
|
||||
</Tag>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyText}>请先选择流量池</div>
|
||||
<div className={styles.emptyDesc}>
|
||||
选择流量池后将显示其状态信息
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReceptionSettings;
|
||||
78
Touchkebao/src/pages/pc/ckbox/commonConfig/index.module.scss
Normal file
78
Touchkebao/src/pages/pc/ckbox/commonConfig/index.module.scss
Normal file
@@ -0,0 +1,78 @@
|
||||
/* common-config page styles */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tabsBar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 16px;
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: #1890ff;
|
||||
border-bottom-color: #1890ff;
|
||||
background-color: #f0f8ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: #98a2b3;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.formItem {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 8px;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
70
Touchkebao/src/pages/pc/ckbox/commonConfig/index.tsx
Normal file
70
Touchkebao/src/pages/pc/ckbox/commonConfig/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState } from "react";
|
||||
import Layout from "@/components/Layout/LayoutFiexd";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import { Button, Space } from "antd";
|
||||
import ReceptionSettings from "./components/ReceptionSettings";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const CommonConfig: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<string>("reception");
|
||||
|
||||
const tabs = [
|
||||
{ key: "reception", label: "接待设置" },
|
||||
{ key: "notification", label: "通知设置" },
|
||||
{ key: "system", label: "系统设置" },
|
||||
{ key: "security", label: "安全设置" },
|
||||
{ key: "advanced", label: "高级设置" },
|
||||
];
|
||||
|
||||
const handleTabClick = (tabKey: string) => {
|
||||
setActiveTab(tabKey);
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case "reception":
|
||||
return <ReceptionSettings />;
|
||||
case "notification":
|
||||
return <div className={styles.placeholder}>通知设置功能开发中...</div>;
|
||||
case "system":
|
||||
return <div className={styles.placeholder}>系统设置功能开发中...</div>;
|
||||
case "security":
|
||||
return <div className={styles.placeholder}>安全设置功能开发中...</div>;
|
||||
case "advanced":
|
||||
return <div className={styles.placeholder}>高级设置功能开发中...</div>;
|
||||
default:
|
||||
return <ReceptionSettings />;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<PowerNavigation
|
||||
title="全局配置"
|
||||
subtitle="系统全局设置和配置管理"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
/>
|
||||
<div className={styles.tabsBar}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={`${styles.tab} ${
|
||||
activeTab === tab.key ? styles.tabActive : ""
|
||||
}`}
|
||||
onClick={() => handleTabClick(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.content}>{renderTabContent()}</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommonConfig;
|
||||
@@ -79,6 +79,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
gap: 26px;
|
||||
.suanli {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -131,10 +132,20 @@
|
||||
|
||||
.userSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
.userInfo2 {
|
||||
line-height: 1;
|
||||
padding-top: 5px;
|
||||
font-size: 14px;
|
||||
.userNickname {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.userAccount {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.userNickname {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Layout, Drawer, Avatar, Space, Button, Badge, Dropdown } from "antd";
|
||||
import {
|
||||
MenuOutlined,
|
||||
BarChartOutlined,
|
||||
UserOutlined,
|
||||
BellOutlined,
|
||||
LogoutOutlined,
|
||||
@@ -90,24 +90,20 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
||||
<>
|
||||
<Header className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
{!isWeChat() ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="large"
|
||||
icon={<WechatOutlined />}
|
||||
onClick={handleMenuClick}
|
||||
className={styles.menuButton}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
size="large"
|
||||
icon={<MenuOutlined />}
|
||||
onClick={handleMenuClick}
|
||||
className={styles.menuButton}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={<BarChartOutlined />}
|
||||
type={!isWeChat() ? "primary" : "default"}
|
||||
onClick={handleMenuClick}
|
||||
>
|
||||
功能中心
|
||||
</Button>
|
||||
<Button
|
||||
icon={<WechatOutlined />}
|
||||
type={isWeChat() ? "primary" : "default"}
|
||||
onClick={handleMenuClick}
|
||||
>
|
||||
Ai智能客服
|
||||
</Button>
|
||||
<span className={styles.title}>{title}</span>
|
||||
</div>
|
||||
|
||||
@@ -124,9 +120,14 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
||||
<BellOutlined style={{ fontSize: 20 }} />
|
||||
</Badge>
|
||||
</div>
|
||||
<div className={styles.messageButton}>
|
||||
<SettingOutlined style={{ fontSize: 20 }} />
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate("/pc/commonConfig");
|
||||
}}
|
||||
icon={<SettingOutlined />}
|
||||
>
|
||||
全局配置
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems }}
|
||||
placement="bottomRight"
|
||||
@@ -139,6 +140,11 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
|
||||
src={user?.avatar}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
|
||||
<div className={styles.userInfo2}>
|
||||
<div className={styles.userNickname}>{user.username}</div>
|
||||
<div className={styles.userAccount}>高级客服专员</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
|
||||
@@ -4,11 +4,7 @@ import { Outlet } from "react-router-dom";
|
||||
import NavCommon from "./components/NavCommon";
|
||||
const CkboxPage: React.FC = () => {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon title="AI自动聊天,懂业务,会引导,客户不停地聊不停" />
|
||||
}
|
||||
>
|
||||
<Layout header={<NavCommon title="触客宝" />}>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -3,41 +3,426 @@
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// 头部样式
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.headerLeft {
|
||||
.computeBalance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
|
||||
.anticon {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
.startTrainingButton {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.content {
|
||||
.tabs {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 话术投喂样式
|
||||
.utteranceFeeding {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
min-height: 600px;
|
||||
|
||||
.leftPanel {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
|
||||
.addCard {
|
||||
height: fit-content;
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form {
|
||||
.formItem {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-affix-wrapper {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
flex: 2;
|
||||
|
||||
.libraryCard {
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.utteranceList {
|
||||
.utteranceItem {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.utteranceHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.utteranceTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.utteranceCategory {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.utteranceContent {
|
||||
color: #595959;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.utteranceFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.timestamps {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.ant-btn {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模型管理样式
|
||||
.modelManagement {
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.modelList {
|
||||
.modelItem {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.modelInfo {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.modelName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.modelStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.accuracy {
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modelActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ant-btn {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.modelTimestamps {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 训练分析样式
|
||||
.trainingAnalysis {
|
||||
.analysisCards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.analysisCard {
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
.statItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chartCard {
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chartPlaceholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
.chartIcon {
|
||||
font-size: 48px;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #8c8c8c;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.utteranceFeeding {
|
||||
flex-direction: column;
|
||||
|
||||
.leftPanel {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.trainingAnalysis {
|
||||
.analysisCards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
|
||||
.headerLeft,
|
||||
.headerRight {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.utteranceFeeding {
|
||||
.leftPanel,
|
||||
.rightPanel {
|
||||
.addCard,
|
||||
.libraryCard {
|
||||
.cardHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,465 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Input, Tabs, Card, Tag, message, Modal, Tooltip } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
PlayCircleOutlined,
|
||||
EyeOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ThunderboltOutlined,
|
||||
FileTextOutlined,
|
||||
DatabaseOutlined,
|
||||
BarChartOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
// 话术数据类型
|
||||
interface UtteranceData {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
content: string;
|
||||
status: "active" | "pending";
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
// 模型数据类型
|
||||
interface ModelData {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
status: "training" | "completed" | "failed";
|
||||
accuracy: number;
|
||||
createTime: string;
|
||||
lastTraining: string;
|
||||
}
|
||||
|
||||
// 训练分析数据类型
|
||||
interface TrainingAnalysis {
|
||||
totalUtterances: number;
|
||||
activeUtterances: number;
|
||||
pendingUtterances: number;
|
||||
trainingAccuracy: number;
|
||||
lastTrainingTime: string;
|
||||
nextTrainingTime: string;
|
||||
}
|
||||
|
||||
const AiTraining: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState("utterance");
|
||||
const [utteranceForm, setUtteranceForm] = useState({
|
||||
title: "",
|
||||
category: "",
|
||||
content: "",
|
||||
});
|
||||
const [utterances, setUtterances] = useState<UtteranceData[]>([
|
||||
{
|
||||
id: "1",
|
||||
title: "产品介绍话术",
|
||||
category: "产品介绍",
|
||||
content:
|
||||
"我们的AI营销系统具有智能客服、精准营销、自动化运营等核心功能,能够帮助您提升客户满意度和业务效率...",
|
||||
status: "active",
|
||||
createTime: "2024/3/1",
|
||||
updateTime: "2024/3/5",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "价格咨询回复",
|
||||
category: "价格咨询",
|
||||
content:
|
||||
"关于价格方面,我们提供多种套餐选择,可以根据您的具体需求定制。基础版适合小型企业,专业版适合中型企业...",
|
||||
status: "active",
|
||||
createTime: "2024/3/2",
|
||||
updateTime: "2024/3/4",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "技术支持话术",
|
||||
category: "技术支持",
|
||||
content:
|
||||
"我们提供7x24小时技术支持,包括在线客服、电话支持、远程协助等多种方式,确保您的问题得到及时解决...",
|
||||
status: "pending",
|
||||
createTime: "2024/3/3",
|
||||
updateTime: "2024/3/3",
|
||||
},
|
||||
]);
|
||||
const [models] = useState<ModelData[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "智能客服模型",
|
||||
version: "v2.1.0",
|
||||
status: "completed",
|
||||
accuracy: 94.5,
|
||||
createTime: "2024/2/15",
|
||||
lastTraining: "2024/3/5",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "营销推荐模型",
|
||||
version: "v1.8.0",
|
||||
status: "training",
|
||||
accuracy: 89.2,
|
||||
createTime: "2024/2/20",
|
||||
lastTraining: "2024/3/4",
|
||||
},
|
||||
]);
|
||||
const [trainingAnalysis] = useState<TrainingAnalysis>({
|
||||
totalUtterances: 3,
|
||||
activeUtterances: 2,
|
||||
pendingUtterances: 1,
|
||||
trainingAccuracy: 94.5,
|
||||
lastTrainingTime: "2024/3/5 14:30:00",
|
||||
nextTrainingTime: "2024/3/6 09:00:00",
|
||||
});
|
||||
|
||||
// 保存话术
|
||||
const handleSaveUtterance = () => {
|
||||
if (
|
||||
!utteranceForm.title ||
|
||||
!utteranceForm.category ||
|
||||
!utteranceForm.content
|
||||
) {
|
||||
message.warning("请填写完整的话术信息");
|
||||
return;
|
||||
}
|
||||
|
||||
const newUtterance: UtteranceData = {
|
||||
id: Date.now().toString(),
|
||||
title: utteranceForm.title,
|
||||
category: utteranceForm.category,
|
||||
content: utteranceForm.content,
|
||||
status: "pending",
|
||||
createTime: new Date().toLocaleDateString("zh-CN"),
|
||||
updateTime: new Date().toLocaleDateString("zh-CN"),
|
||||
};
|
||||
|
||||
setUtterances([...utterances, newUtterance]);
|
||||
setUtteranceForm({ title: "", category: "", content: "" });
|
||||
message.success("话术保存成功");
|
||||
};
|
||||
|
||||
// 删除话术
|
||||
const handleDeleteUtterance = (id: string) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "确定要删除这条话术吗?",
|
||||
onOk: () => {
|
||||
setUtterances(utterances.filter(item => item.id !== id));
|
||||
message.success("删除成功");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 开始训练
|
||||
const handleStartTraining = () => {
|
||||
Modal.confirm({
|
||||
title: "开始训练",
|
||||
content: "确定要开始AI模型训练吗?训练过程可能需要几分钟时间。",
|
||||
onOk: () => {
|
||||
message.success("训练已开始,请稍候...");
|
||||
// 这里可以添加实际的训练逻辑
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 话术投喂组件
|
||||
const UtteranceFeeding = () => (
|
||||
<div className={styles.utteranceFeeding}>
|
||||
<div className={styles.leftPanel}>
|
||||
<Card className={styles.addCard}>
|
||||
<div className={styles.cardHeader}>
|
||||
<PlusOutlined className={styles.icon} />
|
||||
<span className={styles.title}>添加训练话术</span>
|
||||
</div>
|
||||
<p className={styles.description}>添加高质量的对话内容来训练AI模型</p>
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formItem}>
|
||||
<label>话术标题</label>
|
||||
<Input
|
||||
placeholder="输入话术标题..."
|
||||
value={utteranceForm.title}
|
||||
onChange={e =>
|
||||
setUtteranceForm({ ...utteranceForm, title: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formItem}>
|
||||
<label>分类</label>
|
||||
<Input
|
||||
placeholder="如:产品介绍、价格咨询等"
|
||||
value={utteranceForm.category}
|
||||
onChange={e =>
|
||||
setUtteranceForm({
|
||||
...utteranceForm,
|
||||
category: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formItem}>
|
||||
<label>话术内容</label>
|
||||
<TextArea
|
||||
placeholder="输入详细的话术内容..."
|
||||
rows={6}
|
||||
value={utteranceForm.content}
|
||||
onChange={e =>
|
||||
setUtteranceForm({
|
||||
...utteranceForm,
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSaveUtterance}
|
||||
className={styles.saveButton}
|
||||
>
|
||||
保存话术
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className={styles.rightPanel}>
|
||||
<Card className={styles.libraryCard}>
|
||||
<div className={styles.cardHeader}>
|
||||
<DatabaseOutlined className={styles.icon} />
|
||||
<span className={styles.title}>训练话术库</span>
|
||||
<span className={styles.count}>{utterances.length}条话术</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.utteranceList}>
|
||||
{utterances.map(utterance => (
|
||||
<div key={utterance.id} className={styles.utteranceItem}>
|
||||
<div className={styles.utteranceHeader}>
|
||||
<span className={styles.utteranceTitle}>
|
||||
{utterance.title}
|
||||
</span>
|
||||
<Tag color={utterance.status === "active" ? "green" : "blue"}>
|
||||
{utterance.status === "active" ? "已激活" : "待处理"}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div className={styles.utteranceCategory}>
|
||||
<Tag color="blue">{utterance.category}</Tag>
|
||||
</div>
|
||||
|
||||
<div className={styles.utteranceContent}>
|
||||
{utterance.content}
|
||||
</div>
|
||||
|
||||
<div className={styles.utteranceFooter}>
|
||||
<div className={styles.timestamps}>
|
||||
<span>创建: {utterance.createTime}</span>
|
||||
<span>更新: {utterance.updateTime}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Tooltip title="查看">
|
||||
<Button type="text" icon={<EyeOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleDeleteUtterance(utterance.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 模型管理组件
|
||||
const ModelManagement = () => (
|
||||
<div className={styles.modelManagement}>
|
||||
<Card>
|
||||
<div className={styles.cardHeader}>
|
||||
<FileTextOutlined className={styles.icon} />
|
||||
<span className={styles.title}>模型管理</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.modelList}>
|
||||
{models.map(model => (
|
||||
<div key={model.id} className={styles.modelItem}>
|
||||
<div className={styles.modelInfo}>
|
||||
<div className={styles.modelName}>
|
||||
<span className={styles.name}>{model.name}</span>
|
||||
<Tag color="blue">{model.version}</Tag>
|
||||
</div>
|
||||
<div className={styles.modelStatus}>
|
||||
<Tag
|
||||
color={
|
||||
model.status === "completed"
|
||||
? "green"
|
||||
: model.status === "training"
|
||||
? "orange"
|
||||
: "red"
|
||||
}
|
||||
>
|
||||
{model.status === "completed"
|
||||
? "已完成"
|
||||
: model.status === "training"
|
||||
? "训练中"
|
||||
: "失败"}
|
||||
</Tag>
|
||||
<span className={styles.accuracy}>
|
||||
准确率: {model.accuracy}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modelActions}>
|
||||
<Button type="primary" size="small">
|
||||
部署
|
||||
</Button>
|
||||
<Button size="small">查看详情</Button>
|
||||
<Button size="small">重新训练</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.modelTimestamps}>
|
||||
<span>创建: {model.createTime}</span>
|
||||
<span>最后训练: {model.lastTraining}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 训练分析组件
|
||||
const TrainingAnalysis = () => (
|
||||
<div className={styles.trainingAnalysis}>
|
||||
<div className={styles.analysisCards}>
|
||||
<Card className={styles.analysisCard}>
|
||||
<div className={styles.cardTitle}>话术统计</div>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>总话术数</span>
|
||||
<span className={styles.statValue}>
|
||||
{trainingAnalysis.totalUtterances}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>已激活</span>
|
||||
<span className={styles.statValue}>
|
||||
{trainingAnalysis.activeUtterances}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>待处理</span>
|
||||
<span className={styles.statValue}>
|
||||
{trainingAnalysis.pendingUtterances}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.analysisCard}>
|
||||
<div className={styles.cardTitle}>训练效果</div>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>准确率</span>
|
||||
<span className={styles.statValue}>
|
||||
{trainingAnalysis.trainingAccuracy}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>最后训练</span>
|
||||
<span className={styles.statValue}>
|
||||
{trainingAnalysis.lastTrainingTime}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statLabel}>下次训练</span>
|
||||
<span className={styles.statValue}>
|
||||
{trainingAnalysis.nextTrainingTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className={styles.chartCard}>
|
||||
<div className={styles.cardTitle}>训练趋势图</div>
|
||||
<div className={styles.chartPlaceholder}>
|
||||
<BarChartOutlined className={styles.chartIcon} />
|
||||
<p>训练趋势图表</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PowerNavigation
|
||||
title="AI模型训练"
|
||||
subtitle="自定义AI模型训练,打造专属智能客服助手"
|
||||
subtitle="训练和优化AI模型,提升智能服务质量"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* 功能内容待开发 */}
|
||||
<div className={styles.placeholder}>
|
||||
<p>AI模型训练功能正在开发中...</p>
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<div className={styles.computeBalance}>
|
||||
<ThunderboltOutlined />
|
||||
<span>算力余额: 9307.423</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={handleStartTraining}
|
||||
className={styles.startTrainingButton}
|
||||
>
|
||||
开始训练
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
className={styles.tabs}
|
||||
>
|
||||
<TabPane tab="话术投喂" key="utterance">
|
||||
<UtteranceFeeding />
|
||||
</TabPane>
|
||||
<TabPane tab="模型管理" key="model">
|
||||
<ModelManagement />
|
||||
</TabPane>
|
||||
<TabPane tab="训练分析" key="analysis">
|
||||
<TrainingAnalysis />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,41 +3,268 @@
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// 头部区域样式
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.backButton {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.titleSection {
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.activeRules {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 子导航栏样式
|
||||
.subNav {
|
||||
margin-bottom: 24px;
|
||||
|
||||
:global(.ant-tabs-nav) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 主要内容区域
|
||||
.mainContent {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
// 问候规则内容
|
||||
.rulesContent {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
min-height: 600px;
|
||||
|
||||
.leftPanel {
|
||||
flex: 0 0 400px;
|
||||
|
||||
.createCard {
|
||||
height: fit-content;
|
||||
|
||||
:global(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:global(.ant-card-head-title) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.cardSubtitle {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
:global(.ant-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:global(.ant-form-item-label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
flex: 1;
|
||||
|
||||
.listCard {
|
||||
height: fit-content;
|
||||
|
||||
:global(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:global(.ant-card-head-title) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.ruleList {
|
||||
.ruleItem {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #fafafa;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ruleHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ruleTitle {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.ruleTags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.ruleActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 16px;
|
||||
|
||||
:global(.ant-btn) {
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ruleContent {
|
||||
.ruleDescription {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ruleText {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px 0;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.ruleFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其他标签页内容
|
||||
.templatesContent,
|
||||
.statisticsContent {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.rulesContent {
|
||||
flex-direction: column;
|
||||
|
||||
.leftPanel {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.headerRight {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.rulesContent {
|
||||
.leftPanel,
|
||||
.rightPanel {
|
||||
.createCard,
|
||||
.listCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,344 @@
|
||||
import React from "react";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
Space,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ArrowLeftOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
// 问候规则数据类型
|
||||
interface GreetingRule {
|
||||
id: string;
|
||||
name: string;
|
||||
triggerType: string;
|
||||
triggerCondition: string;
|
||||
content: string;
|
||||
priority: number;
|
||||
isActive: boolean;
|
||||
usageCount: number;
|
||||
createTime: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const AutoGreeting: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [activeTab, setActiveTab] = useState("rules");
|
||||
const [rules, setRules] = useState<GreetingRule[]>([
|
||||
{
|
||||
id: "1",
|
||||
name: "产品咨询自动回复",
|
||||
triggerType: "关键词",
|
||||
triggerCondition: "包含:产品、价格、功能",
|
||||
content:
|
||||
"感谢您对我们产品的关注!我们的AI营销系统具有智能客服、精准营销、自动化运营等核心功能。详细资料我稍后发送给您,请稍等。",
|
||||
priority: 3,
|
||||
isActive: false,
|
||||
usageCount: 234,
|
||||
createTime: "2024/3/3",
|
||||
tags: ["关键词", "优先级3"],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "新好友欢迎",
|
||||
triggerType: "新好友",
|
||||
triggerCondition: "添加好友后",
|
||||
content:
|
||||
"您好!欢迎添加我为好友,我是触客宝AI助手,很高兴为您服务!如有任何问题,随时可以咨询我。",
|
||||
priority: 1,
|
||||
isActive: true,
|
||||
usageCount: 156,
|
||||
createTime: "2024/3/1",
|
||||
tags: ["新好友", "优先级1"],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "工作时间问候",
|
||||
triggerType: "时间触发",
|
||||
triggerCondition: "工作日 9:00-18:00",
|
||||
content: "您好!现在是工作时间,我是触客宝AI助手,很高兴为您服务!",
|
||||
priority: 2,
|
||||
isActive: true,
|
||||
usageCount: 89,
|
||||
createTime: "2024/2/28",
|
||||
tags: ["时间触发", "优先级2"],
|
||||
},
|
||||
]);
|
||||
|
||||
// 计算活跃规则数量
|
||||
const activeRulesCount = rules.filter(rule => rule.isActive).length;
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = (values: any) => {
|
||||
const newRule: GreetingRule = {
|
||||
id: Date.now().toString(),
|
||||
name: values.name,
|
||||
triggerType: values.triggerType,
|
||||
triggerCondition: values.triggerCondition,
|
||||
content: values.content,
|
||||
priority: values.priority,
|
||||
isActive: true,
|
||||
usageCount: 0,
|
||||
createTime: new Date().toLocaleDateString(),
|
||||
tags: [values.triggerType, `优先级${values.priority}`],
|
||||
};
|
||||
|
||||
setRules([...rules, newRule]);
|
||||
form.resetFields();
|
||||
message.success("规则创建成功!");
|
||||
};
|
||||
|
||||
// 切换规则状态
|
||||
const toggleRuleStatus = (id: string) => {
|
||||
setRules(
|
||||
rules.map(rule =>
|
||||
rule.id === id ? { ...rule, isActive: !rule.isActive } : rule,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// 删除规则
|
||||
const deleteRule = (id: string) => {
|
||||
setRules(rules.filter(rule => rule.id !== id));
|
||||
message.success("规则删除成功!");
|
||||
};
|
||||
|
||||
// 编辑规则
|
||||
const editRule = (rule: GreetingRule) => {
|
||||
form.setFieldsValue({
|
||||
name: rule.name,
|
||||
triggerType: rule.triggerType,
|
||||
triggerCondition: rule.triggerCondition,
|
||||
content: rule.content,
|
||||
priority: rule.priority,
|
||||
});
|
||||
message.info("规则已加载到编辑表单");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PowerNavigation
|
||||
title="自动打招呼"
|
||||
subtitle="智能识别新好友,自动发送个性化欢迎消息"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* 功能内容待开发 */}
|
||||
<div className={styles.placeholder}>
|
||||
<p>自动打招呼功能正在开发中...</p>
|
||||
{/* 头部区域 */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Button
|
||||
type="text"
|
||||
size="large"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
className={styles.backButton}
|
||||
>
|
||||
返回功能中心
|
||||
</Button>
|
||||
<div className={styles.titleSection}>
|
||||
<h1>自动问候</h1>
|
||||
<p>设置智能问候规则,提升客户体验</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
<div className={styles.activeRules}>
|
||||
活跃规则: {activeRulesCount}/{rules.length}
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />}>
|
||||
+ 新建规则
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 子导航栏 */}
|
||||
<div className={styles.subNav}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: "rules",
|
||||
label: "问候规则",
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: "话术模板",
|
||||
},
|
||||
{
|
||||
key: "statistics",
|
||||
label: "使用统计",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className={styles.mainContent}>
|
||||
{activeTab === "rules" && (
|
||||
<div className={styles.rulesContent}>
|
||||
{/* 左侧创建规则表单 */}
|
||||
<div className={styles.leftPanel}>
|
||||
<Card title="+ 创建问候规则" className={styles.createCard}>
|
||||
<p className={styles.cardSubtitle}>
|
||||
设置自动问候的触发条件和回复内容
|
||||
</p>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
className={styles.createForm}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="规则名称"
|
||||
rules={[{ required: true, message: "请输入规则名称" }]}
|
||||
>
|
||||
<Input placeholder="输入规则名称..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="triggerType"
|
||||
label="触发条件"
|
||||
rules={[{ required: true, message: "请选择触发条件" }]}
|
||||
>
|
||||
<Select placeholder="选择触发条件">
|
||||
<Option value="新好友添加">新好友添加</Option>
|
||||
<Option value="关键词">关键词</Option>
|
||||
<Option value="时间触发">时间触发</Option>
|
||||
<Option value="群聊">群聊</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="triggerCondition"
|
||||
label="具体条件"
|
||||
rules={[{ required: true, message: "请输入具体条件" }]}
|
||||
>
|
||||
<Input placeholder="如:工作日 9:00-18:00 或包含关键词" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="问候内容"
|
||||
rules={[{ required: true, message: "请输入问候内容" }]}
|
||||
>
|
||||
<TextArea rows={4} placeholder="输入自动问候的内容..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label="优先级"
|
||||
rules={[{ required: true, message: "请选择优先级" }]}
|
||||
>
|
||||
<Select placeholder="选择优先级">
|
||||
<Option value={1}>高优先级</Option>
|
||||
<Option value={2}>中优先级</Option>
|
||||
<Option value={3}>低优先级</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
保存规则
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧规则列表 */}
|
||||
<div className={styles.rightPanel}>
|
||||
<Card title="问候规则列表" className={styles.listCard}>
|
||||
<div className={styles.listHeader}>
|
||||
<span>{rules.length}条规则</span>
|
||||
</div>
|
||||
<div className={styles.ruleList}>
|
||||
{rules.map(rule => (
|
||||
<div key={rule.id} className={styles.ruleItem}>
|
||||
<div className={styles.ruleHeader}>
|
||||
<div className={styles.ruleTitle}>
|
||||
<h4>{rule.name}</h4>
|
||||
<div className={styles.ruleTags}>
|
||||
{rule.tags.map((tag, index) => (
|
||||
<Tag
|
||||
key={index}
|
||||
color={index === 0 ? "blue" : "default"}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.ruleActions}>
|
||||
<Switch
|
||||
checked={rule.isActive}
|
||||
onChange={() => toggleRuleStatus(rule.id)}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => editRule(rule)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定要删除这个规则吗?"
|
||||
onConfirm={() => deleteRule(rule.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.ruleContent}>
|
||||
<p className={styles.ruleDescription}>
|
||||
{rule.triggerCondition}
|
||||
</p>
|
||||
<p className={styles.ruleText}>{rule.content}</p>
|
||||
<div className={styles.ruleFooter}>
|
||||
<span>使用次数:{rule.usageCount}</span>
|
||||
<span>创建时间:{rule.createTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "templates" && (
|
||||
<div className={styles.templatesContent}>
|
||||
<Card>
|
||||
<p>话术模板功能开发中...</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "statistics" && (
|
||||
<div className={styles.statisticsContent}>
|
||||
<Card>
|
||||
<p>使用统计功能开发中...</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,39 +5,289 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
// 导出按钮样式
|
||||
.exportButton {
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
// 顶部搜索和筛选区域
|
||||
.headerSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.filterButtons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.ant-btn {
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 导航栏样式
|
||||
.navigationTabs {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.tabs {
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
.anticon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
color: #1890ff;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录列表样式
|
||||
.recordsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recordCard {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cardLeft {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recordInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nameAndType {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
|
||||
.anticon {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.statusAndTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.dateTime {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
.directionAndSubject {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.subject {
|
||||
font-size: 13px;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
line-height: 1.4;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #8c8c8c;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
|
||||
.attachmentsLabel {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
|
||||
.attachmentIcon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.attachmentName {
|
||||
font-size: 12px;
|
||||
color: #262626;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.downloadIcon {
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cardRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
||||
.viewIcon {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.headerSection {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.filterButtons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cardRight {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,323 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Input, Button, Tabs, Tag, Avatar, Card } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
CalendarOutlined,
|
||||
MessageOutlined,
|
||||
PhoneOutlined,
|
||||
VideoCameraOutlined,
|
||||
MailOutlined,
|
||||
EyeOutlined,
|
||||
DownloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
interface CommunicationRecord {
|
||||
id: string;
|
||||
avatar: string;
|
||||
name: string;
|
||||
type: "chat" | "call" | "video" | "email";
|
||||
status: "completed" | "pending" | "cancelled";
|
||||
dateTime: string;
|
||||
duration?: string;
|
||||
direction: "incoming" | "outgoing";
|
||||
subject?: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
attachments?: Array<{
|
||||
name: string;
|
||||
type: "pdf" | "xlsx" | "doc" | "other";
|
||||
}>;
|
||||
}
|
||||
|
||||
const CommunicationRecord: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState("chat");
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
// 模拟数据
|
||||
const mockData: CommunicationRecord[] = [
|
||||
{
|
||||
id: "1",
|
||||
avatar: "李",
|
||||
name: "李先生",
|
||||
type: "chat",
|
||||
status: "completed",
|
||||
dateTime: "2024/3/5 14:30:00",
|
||||
direction: "incoming",
|
||||
content: "咨询AI营销产品的详细功能和价格",
|
||||
tags: ["产品咨询", "价格询问"],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
avatar: "张",
|
||||
name: "张总",
|
||||
type: "call",
|
||||
status: "completed",
|
||||
dateTime: "2024/3/5 10:15:00",
|
||||
duration: "25分钟",
|
||||
direction: "outgoing",
|
||||
subject: "产品演示预约",
|
||||
content: "与客户确认产品演示时间,讨论具体需求",
|
||||
tags: ["产品演示", "需求确认"],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
avatar: "王",
|
||||
name: "王女士",
|
||||
type: "video",
|
||||
status: "completed",
|
||||
dateTime: "2024/3/4 16:45:00",
|
||||
duration: "45分钟",
|
||||
direction: "incoming",
|
||||
subject: "产品功能演示",
|
||||
content: "详细演示AI客服功能,客户表示很满意",
|
||||
tags: ["产品演示", "功能介绍"],
|
||||
attachments: [
|
||||
{ name: "产品介绍.pdf", type: "pdf" },
|
||||
{ name: "报价单.xlsx", type: "xlsx" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "chat":
|
||||
return <MessageOutlined />;
|
||||
case "call":
|
||||
return <PhoneOutlined />;
|
||||
case "video":
|
||||
return <VideoCameraOutlined />;
|
||||
case "email":
|
||||
return <MailOutlined />;
|
||||
default:
|
||||
return <MessageOutlined />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "pending":
|
||||
return "processing";
|
||||
case "cancelled":
|
||||
return "error";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
const getDirectionColor = (direction: string) => {
|
||||
return direction === "incoming" ? "green" : "blue";
|
||||
};
|
||||
|
||||
const getDirectionText = (direction: string) => {
|
||||
return direction === "incoming" ? "来电/来信" : "去电/去信";
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "已完成";
|
||||
case "pending":
|
||||
return "进行中";
|
||||
case "cancelled":
|
||||
return "已取消";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const getAttachmentIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "pdf":
|
||||
return "📄";
|
||||
case "xlsx":
|
||||
return "📊";
|
||||
case "doc":
|
||||
return "📝";
|
||||
default:
|
||||
return "📎";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = mockData.filter(
|
||||
record =>
|
||||
record.type === activeTab &&
|
||||
(searchValue === "" ||
|
||||
record.name.includes(searchValue) ||
|
||||
record.content.includes(searchValue) ||
|
||||
record.tags.some(tag => tag.includes(searchValue))),
|
||||
);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: "chat",
|
||||
label: (
|
||||
<span>
|
||||
<MessageOutlined />
|
||||
聊天(1)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "call",
|
||||
label: (
|
||||
<span>
|
||||
<PhoneOutlined />
|
||||
通话(2)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "video",
|
||||
label: (
|
||||
<span>
|
||||
<VideoCameraOutlined />
|
||||
视频(1)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: (
|
||||
<span>
|
||||
<MailOutlined />
|
||||
邮件(1)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 导出记录处理函数
|
||||
const handleExportRecords = () => {
|
||||
console.log("导出记录功能");
|
||||
// TODO: 实现导出功能
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PowerNavigation
|
||||
title="沟通记录"
|
||||
subtitle="完整记录客户沟通历史,支持多维度查询分析"
|
||||
subtitle="查看和管理所有客户沟通记录"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
rightContent={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExportRecords}
|
||||
className={styles.exportButton}
|
||||
>
|
||||
导出记录
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* 功能内容待开发 */}
|
||||
<div className={styles.placeholder}>
|
||||
<p>沟通记录功能正在开发中...</p>
|
||||
{/* 顶部搜索和筛选区域 */}
|
||||
<div className={styles.headerSection}>
|
||||
<div className={styles.searchBar}>
|
||||
<Search
|
||||
placeholder="搜索客户、内容或标签..."
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.filterButtons}>
|
||||
<Button icon={<FilterOutlined />}>筛选</Button>
|
||||
<Button icon={<CalendarOutlined />}>日期范围</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通信类型导航栏 */}
|
||||
<div className={styles.navigationTabs}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
className={styles.tabs}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 通信记录列表 */}
|
||||
<div className={styles.recordsList}>
|
||||
{filteredData.map(record => (
|
||||
<Card key={record.id} className={styles.recordCard}>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardLeft}>
|
||||
<Avatar className={styles.avatar}>{record.avatar}</Avatar>
|
||||
<div className={styles.recordInfo}>
|
||||
<div className={styles.nameAndType}>
|
||||
<span className={styles.name}>{record.name}</span>
|
||||
<span className={styles.type}>
|
||||
{getTypeIcon(record.type)}
|
||||
{record.type === "chat"
|
||||
? "聊天"
|
||||
: record.type === "call"
|
||||
? "通话"
|
||||
: record.type === "video"
|
||||
? "视频"
|
||||
: "邮件"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.statusAndTime}>
|
||||
<Tag color={getStatusColor(record.status)}>
|
||||
{getStatusText(record.status)}
|
||||
</Tag>
|
||||
<span className={styles.dateTime}>{record.dateTime}</span>
|
||||
{record.duration && (
|
||||
<span className={styles.duration}>
|
||||
时长:{record.duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.directionAndSubject}>
|
||||
<Tag color={getDirectionColor(record.direction)}>
|
||||
{getDirectionText(record.direction)}
|
||||
</Tag>
|
||||
{record.subject && (
|
||||
<span className={styles.subject}>{record.subject}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.content}>{record.content}</div>
|
||||
<div className={styles.tags}>
|
||||
{record.tags.map((tag, index) => (
|
||||
<Tag key={index} className={styles.tag}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
{record.attachments && record.attachments.length > 0 && (
|
||||
<div className={styles.attachments}>
|
||||
<span className={styles.attachmentsLabel}>附件:</span>
|
||||
{record.attachments.map((attachment, index) => (
|
||||
<div key={index} className={styles.attachment}>
|
||||
<span className={styles.attachmentIcon}>
|
||||
{getAttachmentIcon(attachment.type)}
|
||||
</span>
|
||||
<span className={styles.attachmentName}>
|
||||
{attachment.name}
|
||||
</span>
|
||||
<DownloadOutlined className={styles.downloadIcon} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardRight}>
|
||||
<EyeOutlined className={styles.viewIcon} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 素材管理相关接口
|
||||
export interface MaterialListParams {
|
||||
keyword?: string;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
// 内容项类型定义
|
||||
export interface ContentItem {
|
||||
type: "text" | "image" | "video" | "file" | "audio" | "link";
|
||||
data: string | LinkData;
|
||||
}
|
||||
|
||||
// 链接数据类型
|
||||
export interface LinkData {
|
||||
title: string;
|
||||
url: string;
|
||||
cover: string;
|
||||
}
|
||||
|
||||
export interface MaterialAddRequest {
|
||||
title: string;
|
||||
cover?: string;
|
||||
status: number;
|
||||
content: ContentItem[];
|
||||
}
|
||||
|
||||
export interface MaterialUpdateRequest extends MaterialAddRequest {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface MaterialSetStatusRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 素材管理-列表
|
||||
export function getMaterialList(params: MaterialListParams) {
|
||||
return request("/v1/kefu/content/material/list", params, "GET");
|
||||
}
|
||||
|
||||
// 素材管理-添加
|
||||
export function addMaterial(data: MaterialAddRequest) {
|
||||
return request("/v1/kefu/content/material/add", data, "POST");
|
||||
}
|
||||
|
||||
// 素材管理-详情
|
||||
export function getMaterialDetails(id: string) {
|
||||
return request("/v1/kefu/content/material/details", { id }, "GET");
|
||||
}
|
||||
|
||||
// 素材管理-删除
|
||||
export function deleteMaterial(id: string) {
|
||||
return request("/v1/kefu/content/material/del", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 素材管理-更新
|
||||
export function updateMaterial(data: MaterialUpdateRequest) {
|
||||
return request("/v1/kefu/content/material/update", data, "POST");
|
||||
}
|
||||
|
||||
// 素材管理-修改状态
|
||||
export function setMaterialStatus(data: MaterialSetStatusRequest) {
|
||||
return request("/v1/kefu/content/material/setStatus", data, "POST");
|
||||
}
|
||||
|
||||
// 违禁词管理相关接口
|
||||
export interface SensitiveWordListParams {
|
||||
keyword?: string;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export interface SensitiveWordAddRequest {
|
||||
content: string;
|
||||
keywords: string;
|
||||
/**
|
||||
* 操作 0不操作 1替换 2删除 3警告 4禁止发送
|
||||
*/
|
||||
operation: string;
|
||||
status: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface SensitiveWordUpdateRequest extends SensitiveWordAddRequest {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface SensitiveWordSetStatusRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 违禁词管理-列表
|
||||
export function getSensitiveWordList(params: SensitiveWordListParams) {
|
||||
return request("/v1/kefu/content/sensitiveWord/list", params, "GET");
|
||||
}
|
||||
|
||||
// 违禁词管理-添加
|
||||
export function addSensitiveWord(data: SensitiveWordAddRequest) {
|
||||
return request("/v1/kefu/content/sensitiveWord/add", data, "POST");
|
||||
}
|
||||
|
||||
// 违禁词管理-详情
|
||||
export function getSensitiveWordDetails(id: string) {
|
||||
return request("/v1/kefu/content/sensitiveWord/details", { id }, "GET");
|
||||
}
|
||||
|
||||
// 违禁词管理-删除
|
||||
export function deleteSensitiveWord(id: string) {
|
||||
return request("/v1/kefu/content/sensitiveWord/del", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 违禁词管理-更新
|
||||
export function updateSensitiveWord(data: SensitiveWordUpdateRequest) {
|
||||
return request("/v1/kefu/content/sensitiveWord/update", data, "POST");
|
||||
}
|
||||
|
||||
// 违禁词管理-修改状态
|
||||
export function setSensitiveWordStatus(data: SensitiveWordSetStatusRequest) {
|
||||
return request("/v1/kefu/content/sensitiveWord/setStatus", data, "POST");
|
||||
}
|
||||
|
||||
// 关键词回复管理相关接口
|
||||
export interface KeywordListParams {
|
||||
keyword?: string;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export interface KeywordAddRequest {
|
||||
title: string;
|
||||
keywords: string;
|
||||
content: string;
|
||||
matchType: string; // 匹配类型:模糊匹配、精确匹配
|
||||
priority: string; // 优先级
|
||||
replyType: string; // 回复类型:文本回复、模板回复
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface KeywordUpdateRequest extends KeywordAddRequest {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface KeywordSetStatusRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 关键词回复-列表
|
||||
export function getKeywordList(params: KeywordListParams) {
|
||||
return request("/v1/kefu/content/keywords/list", params, "GET");
|
||||
}
|
||||
|
||||
// 关键词回复-添加
|
||||
export function addKeyword(data: KeywordAddRequest) {
|
||||
return request("/v1/kefu/content/keywords/add", data, "POST");
|
||||
}
|
||||
|
||||
// 关键词回复-详情
|
||||
export function getKeywordDetails(id: string) {
|
||||
return request("/v1/kefu/content/keywords/details", { id }, "GET");
|
||||
}
|
||||
|
||||
// 关键词回复-删除
|
||||
export function deleteKeyword(id: string) {
|
||||
return request("/v1/kefu/content/keywords/del", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 关键词回复-更新
|
||||
export function updateKeyword(data: KeywordUpdateRequest) {
|
||||
return request("/v1/kefu/content/keywords/update", data, "POST");
|
||||
}
|
||||
|
||||
// 关键词回复-修改状态
|
||||
export function setKeywordStatus(data: KeywordSetStatusRequest) {
|
||||
return request("/v1/kefu/content/keywords/setStatus", data, "POST");
|
||||
}
|
||||
|
||||
//获取好友接待配置
|
||||
export function getFriendInjectConfig(params) {
|
||||
return request("/v1/kefu/ai/friend/get", params, "GET");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// 管理组件导出
|
||||
export { default as MaterialManagement } from "./management/MaterialManagement";
|
||||
export { default as SensitiveWordManagement } from "./management/SensitiveWordManagement";
|
||||
export { default as KeywordManagement } from "./management/KeywordManagement";
|
||||
|
||||
// 模态框组件导出
|
||||
export { default as MaterialModal } from "./modals/MaterialModal";
|
||||
export { default as SensitiveWordModal } from "./modals/SensitiveWordModal";
|
||||
export { default as KeywordModal } from "./modals/KeywordModal";
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { Button, Input, Tag, Switch, message } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
FormOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "../../index.module.scss";
|
||||
import {
|
||||
getKeywordList,
|
||||
deleteKeyword,
|
||||
setKeywordStatus,
|
||||
type KeywordListParams,
|
||||
} from "../../api";
|
||||
import KeywordModal from "../modals/KeywordModal";
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
interface KeywordItem {
|
||||
id: string;
|
||||
title: string;
|
||||
keywords: string;
|
||||
content: string;
|
||||
matchType: string;
|
||||
priority: string;
|
||||
replyType: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const KeywordManagement = forwardRef<any, Record<string, never>>(
|
||||
(props, ref) => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [keywordsList, setKeywordsList] = useState<KeywordItem[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
|
||||
const [editingKeywordId, setEditingKeywordId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 回复类型映射
|
||||
const getReplyTypeText = (replyType: string) => {
|
||||
switch (replyType) {
|
||||
case "text":
|
||||
return "文本回复";
|
||||
case "template":
|
||||
return "模板回复";
|
||||
default:
|
||||
return "未知类型";
|
||||
}
|
||||
};
|
||||
|
||||
// 回复类型颜色
|
||||
const getReplyTypeColor = (replyType: string) => {
|
||||
switch (replyType) {
|
||||
case "text":
|
||||
return "#1890ff";
|
||||
case "template":
|
||||
return "#722ed1";
|
||||
default:
|
||||
return "#8c8c8c";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取关键词列表
|
||||
const fetchKeywords = async (params?: KeywordListParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getKeywordList(params || {});
|
||||
if (response) {
|
||||
setKeywordsList(response.list || []);
|
||||
} else {
|
||||
setKeywordsList([]);
|
||||
message.error(response?.message || "获取关键词列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取关键词列表失败:", error);
|
||||
setKeywordsList([]);
|
||||
message.error("获取关键词列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
fetchKeywords,
|
||||
}));
|
||||
|
||||
// 关键词管理相关函数
|
||||
const handleToggleKeyword = async (id: string) => {
|
||||
try {
|
||||
const response = await setKeywordStatus({ id });
|
||||
if (response) {
|
||||
setKeywordsList(prev =>
|
||||
prev.map(item =>
|
||||
item.id === id ? { ...item, enabled: !item.enabled } : item,
|
||||
),
|
||||
);
|
||||
message.success("状态更新成功");
|
||||
} else {
|
||||
message.error(response?.message || "状态更新失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("状态更新失败:", error);
|
||||
message.error("状态更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditKeyword = (id: string) => {
|
||||
setEditingKeywordId(id);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
// 编辑弹窗成功回调
|
||||
const handleEditSuccess = () => {
|
||||
fetchKeywords(); // 重新获取数据
|
||||
};
|
||||
|
||||
const handleDeleteKeyword = async (id: string) => {
|
||||
try {
|
||||
const response = await deleteKeyword(id);
|
||||
if (response) {
|
||||
setKeywordsList(prev => prev.filter(item => item.id !== id));
|
||||
message.success("删除成功");
|
||||
} else {
|
||||
message.error(response?.message || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除失败:", error);
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索和筛选功能
|
||||
const filteredKeywords = keywordsList.filter(item => {
|
||||
if (!searchValue) return true;
|
||||
return (
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
item.keywords.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
item.content.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// 搜索处理函数
|
||||
const handleSearch = (value: string) => {
|
||||
fetchKeywords({ keyword: value });
|
||||
};
|
||||
|
||||
// 组件挂载时获取数据
|
||||
useEffect(() => {
|
||||
fetchKeywords();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.keywordContent}>
|
||||
<div className={styles.searchSection}>
|
||||
<Search
|
||||
placeholder="搜索关键词..."
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 300 }}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Button icon={<FilterOutlined />}>筛选</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.keywordList}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>加载中...</div>
|
||||
) : filteredKeywords.length === 0 ? (
|
||||
<div className={styles.empty}>暂无关键词数据</div>
|
||||
) : (
|
||||
filteredKeywords.map(item => (
|
||||
<div key={item.id} className={styles.keywordItem}>
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.title}>{item.title}</div>
|
||||
<div className={styles.tags}>
|
||||
<Tag className={styles.matchTag}>{item.matchType}</Tag>
|
||||
<Tag className={styles.priorityTag}>
|
||||
优先级{item.priority}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.description}>{item.content}</div>
|
||||
<Tag
|
||||
color={getReplyTypeColor(item.replyType)}
|
||||
className={styles.replyTypeTag}
|
||||
>
|
||||
{getReplyTypeText(item.replyType)}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.itemActions}>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onChange={() => handleToggleKeyword(item.id)}
|
||||
className={styles.toggleSwitch}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FormOutlined className={styles.editIcon} />}
|
||||
onClick={() => handleEditKeyword(item.id)}
|
||||
className={styles.actionBtn}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined className={styles.deleteIcon} />}
|
||||
onClick={() => handleDeleteKeyword(item.id)}
|
||||
className={styles.actionBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<KeywordModal
|
||||
visible={editModalVisible}
|
||||
mode="edit"
|
||||
keywordId={editingKeywordId}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false);
|
||||
setEditingKeywordId(null);
|
||||
}}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
KeywordManagement.displayName = "KeywordManagement";
|
||||
|
||||
export default KeywordManagement;
|
||||
@@ -0,0 +1,273 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { Button, Input, Card, message, Modal } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
FormOutlined,
|
||||
FileTextOutlined,
|
||||
FileImageOutlined,
|
||||
PlayCircleOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "../../index.module.scss";
|
||||
import {
|
||||
getMaterialList,
|
||||
deleteMaterial,
|
||||
type MaterialListParams,
|
||||
} from "../../api";
|
||||
import MaterialModal from "../modals/MaterialModal";
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
interface MaterialItem {
|
||||
id: number;
|
||||
companyId: number;
|
||||
userId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
cover: string;
|
||||
status: number;
|
||||
type: string; // 素材类型:文本、图片、视频
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
isDel: number;
|
||||
delTime: string | null;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
const MaterialManagement = forwardRef<any, Record<string, never>>(
|
||||
(props, ref) => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [materialsList, setMaterialsList] = useState<MaterialItem[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
|
||||
const [editingMaterialId, setEditingMaterialId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 获取类型图标
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "文本":
|
||||
return <FileTextOutlined className={styles.typeIcon} />;
|
||||
case "图片":
|
||||
return <FileImageOutlined className={styles.typeIcon} />;
|
||||
case "视频":
|
||||
return <PlayCircleOutlined className={styles.typeIcon} />;
|
||||
default:
|
||||
return <FileTextOutlined className={styles.typeIcon} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取素材列表
|
||||
const fetchMaterials = async (params?: MaterialListParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getMaterialList(params || {});
|
||||
if (response) {
|
||||
setMaterialsList(response.list || []);
|
||||
} else {
|
||||
setMaterialsList([]);
|
||||
message.error(response?.message || "获取素材列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取素材列表失败:", error);
|
||||
setMaterialsList([]);
|
||||
message.error("获取素材列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
fetchMaterials,
|
||||
}));
|
||||
|
||||
// 素材管理相关函数
|
||||
const handleDeleteMaterial = async (id: number) => {
|
||||
Modal.confirm({
|
||||
title: "确认删除",
|
||||
content: "确定要删除这个素材吗?删除后无法恢复。",
|
||||
okText: "确定",
|
||||
cancelText: "取消",
|
||||
okType: "danger",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteMaterial(id.toString());
|
||||
setMaterialsList(prev => prev.filter(item => item.id !== id));
|
||||
message.success("删除成功");
|
||||
} catch (error) {
|
||||
message.error("删除失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 编辑素材
|
||||
const handleEditMaterial = (id: number) => {
|
||||
setEditingMaterialId(id);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
// 编辑弹窗成功回调
|
||||
const handleEditSuccess = () => {
|
||||
fetchMaterials(); // 重新获取数据
|
||||
};
|
||||
|
||||
// 搜索处理函数
|
||||
const handleSearch = (value: string) => {
|
||||
fetchMaterials({ keyword: value });
|
||||
};
|
||||
|
||||
// 组件挂载时获取数据
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.materialContent}>
|
||||
<div className={styles.searchSection}>
|
||||
<Search
|
||||
placeholder="搜索素材..."
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 300 }}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Button icon={<FilterOutlined />}>筛选</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.materialGrid}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>加载中...</div>
|
||||
) : materialsList.length === 0 ? (
|
||||
<div className={styles.empty}>暂无素材数据</div>
|
||||
) : (
|
||||
materialsList.map(item => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={styles.materialCard}
|
||||
hoverable
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<FormOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleEditMaterial(item.id);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleDeleteMaterial(item.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div
|
||||
className={styles.thumbnail}
|
||||
onClick={() => handleEditMaterial(item.id)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{item.cover ? (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.cover}
|
||||
alt={item.title}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
right: "4px",
|
||||
background: "rgba(0, 0, 0, 0.6)",
|
||||
color: "white",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "10px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{item.type}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f5f5f5",
|
||||
color: "#999",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{getTypeIcon(item.type)}
|
||||
<span style={{ marginTop: "8px", fontSize: "12px" }}>
|
||||
{item.type}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.title}>{item.title}</div>
|
||||
<div className={styles.meta}>
|
||||
<div>创建人: {item.userName}</div>
|
||||
<div>{item.createTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<MaterialModal
|
||||
visible={editModalVisible}
|
||||
mode="edit"
|
||||
materialId={editingMaterialId}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false);
|
||||
setEditingMaterialId(null);
|
||||
}}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MaterialManagement.displayName = "MaterialManagement";
|
||||
|
||||
export default MaterialManagement;
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button, Input, Tag, Switch, message } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
FormOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "../../index.module.scss";
|
||||
import {
|
||||
getSensitiveWordList,
|
||||
deleteSensitiveWord,
|
||||
setSensitiveWordStatus,
|
||||
type SensitiveWordListParams,
|
||||
} from "../../api";
|
||||
import SensitiveWordModal from "../modals/SensitiveWordModal";
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
interface SensitiveWordItem {
|
||||
id: string;
|
||||
title: string;
|
||||
keywords: string;
|
||||
content: string;
|
||||
operation: string;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const SensitiveWordManagement: React.FC = () => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [sensitiveWordsList, setSensitiveWordsList] = useState<
|
||||
SensitiveWordItem[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
|
||||
const [editingSensitiveWordId, setEditingSensitiveWordId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const getTagColor = (tag: string) => {
|
||||
switch (tag) {
|
||||
case "政治":
|
||||
return "#ff4d4f";
|
||||
case "色情":
|
||||
return "#ff4d4f";
|
||||
case "暴力":
|
||||
return "#ff4d4f";
|
||||
default:
|
||||
return "#ff4d4f";
|
||||
}
|
||||
};
|
||||
|
||||
// 操作类型映射
|
||||
const getOperationText = (operation: string) => {
|
||||
switch (operation) {
|
||||
case "0":
|
||||
return "不操作";
|
||||
case "1":
|
||||
return "替换";
|
||||
case "2":
|
||||
return "删除";
|
||||
case "3":
|
||||
return "警告";
|
||||
case "4":
|
||||
return "禁止发送";
|
||||
default:
|
||||
return "未知操作";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取敏感词列表
|
||||
const fetchSensitiveWords = async (params?: SensitiveWordListParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getSensitiveWordList(params || {});
|
||||
if (response) {
|
||||
setSensitiveWordsList(response.list || []);
|
||||
} else {
|
||||
setSensitiveWordsList([]);
|
||||
message.error(response?.message || "获取敏感词列表失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取敏感词列表失败:", error);
|
||||
setSensitiveWordsList([]);
|
||||
message.error("获取敏感词列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 敏感词管理相关函数
|
||||
const handleToggleSensitiveWord = async (id: string) => {
|
||||
try {
|
||||
const response = await setSensitiveWordStatus({ id });
|
||||
if (response) {
|
||||
setSensitiveWordsList(prev =>
|
||||
prev.map(item =>
|
||||
item.id === id ? { ...item, enabled: !item.enabled } : item,
|
||||
),
|
||||
);
|
||||
message.success("状态更新成功");
|
||||
} else {
|
||||
message.error(response?.message || "状态更新失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("状态更新失败:", error);
|
||||
message.error("状态更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSensitiveWord = (id: string) => {
|
||||
setEditingSensitiveWordId(id);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
// 编辑弹窗成功回调
|
||||
const handleEditSuccess = () => {
|
||||
fetchSensitiveWords(); // 重新获取数据
|
||||
};
|
||||
|
||||
const handleDeleteSensitiveWord = async (id: string) => {
|
||||
try {
|
||||
const response = await deleteSensitiveWord(id);
|
||||
if (response) {
|
||||
setSensitiveWordsList(prev => prev.filter(item => item.id !== id));
|
||||
message.success("删除成功");
|
||||
} else {
|
||||
message.error(response?.message || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除失败:", error);
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索和筛选功能
|
||||
const filteredSensitiveWords = sensitiveWordsList.filter(item => {
|
||||
if (!searchValue) return true;
|
||||
return (
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
item.keywords.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
item.content.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// 搜索处理函数
|
||||
const handleSearch = (value: string) => {
|
||||
fetchSensitiveWords({ keyword: value });
|
||||
};
|
||||
|
||||
// 组件挂载时获取数据
|
||||
useEffect(() => {
|
||||
fetchSensitiveWords();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.sensitiveContent}>
|
||||
<div className={styles.searchSection}>
|
||||
<Search
|
||||
placeholder="搜索敏感词..."
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
onSearch={handleSearch}
|
||||
style={{ width: 300 }}
|
||||
prefix={<SearchOutlined />}
|
||||
/>
|
||||
<Button icon={<FilterOutlined />}>筛选</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.sensitiveList}>
|
||||
{loading ? (
|
||||
<div className={styles.loading}>加载中...</div>
|
||||
) : filteredSensitiveWords.length === 0 ? (
|
||||
<div className={styles.empty}>暂无敏感词数据</div>
|
||||
) : (
|
||||
filteredSensitiveWords.map(item => (
|
||||
<div key={item.id} className={styles.sensitiveItem}>
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.categoryName}>{item.title}</div>
|
||||
<Tag
|
||||
color={getTagColor(item.keywords)}
|
||||
className={styles.sensitiveTag}
|
||||
>
|
||||
{item.keywords}
|
||||
</Tag>
|
||||
<div className={styles.actionText}>
|
||||
{getOperationText(item.operation)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.itemActions}>
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onChange={() => handleToggleSensitiveWord(item.id)}
|
||||
className={styles.toggleSwitch}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<FormOutlined className={styles.editIcon} />}
|
||||
onClick={() => handleEditSensitiveWord(item.id)}
|
||||
className={styles.actionBtn}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined className={styles.deleteIcon} />}
|
||||
onClick={() => handleDeleteSensitiveWord(item.id)}
|
||||
className={styles.actionBtn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<SensitiveWordModal
|
||||
visible={editModalVisible}
|
||||
mode="edit"
|
||||
sensitiveWordId={editingSensitiveWordId}
|
||||
onCancel={() => {
|
||||
setEditModalVisible(false);
|
||||
setEditingSensitiveWordId(null);
|
||||
}}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SensitiveWordManagement;
|
||||
@@ -0,0 +1,4 @@
|
||||
// 管理组件统一导出
|
||||
export { default as MaterialManagement } from "./MaterialManagement";
|
||||
export { default as SensitiveWordManagement } from "./SensitiveWordManagement";
|
||||
export { default as KeywordManagement } from "./KeywordManagement";
|
||||
@@ -0,0 +1,276 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button, Input, Select } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
FileImageOutlined,
|
||||
PlayCircleOutlined,
|
||||
FileOutlined,
|
||||
SoundOutlined,
|
||||
LinkOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
|
||||
import VideoUpload from "@/components/Upload/VideoUpload";
|
||||
import FileUpload from "@/components/Upload/FileUpload";
|
||||
import AudioUpload from "@/components/Upload/AudioUpload";
|
||||
import type { ContentItem, LinkData } from "../../api";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
interface ContentManagerProps {
|
||||
value?: ContentItem[];
|
||||
onChange?: (content: ContentItem[]) => void;
|
||||
}
|
||||
|
||||
const ContentManager: React.FC<ContentManagerProps> = ({
|
||||
value = [],
|
||||
onChange,
|
||||
}) => {
|
||||
const [contentItems, setContentItems] = useState<ContentItem[]>(value);
|
||||
|
||||
// 内容类型配置
|
||||
const contentTypes = [
|
||||
{ value: "text", label: "文本", icon: <FileTextOutlined /> },
|
||||
{ value: "image", label: "图片", icon: <FileImageOutlined /> },
|
||||
{ value: "video", label: "视频", icon: <PlayCircleOutlined /> },
|
||||
{ value: "file", label: "文件", icon: <FileOutlined /> },
|
||||
{ value: "audio", label: "音频", icon: <SoundOutlined /> },
|
||||
{ value: "link", label: "链接", icon: <LinkOutlined /> },
|
||||
];
|
||||
|
||||
// 同步外部value到内部state
|
||||
useEffect(() => {
|
||||
setContentItems(value);
|
||||
}, [value]);
|
||||
|
||||
// 初始化时添加默认文本内容项
|
||||
useEffect(() => {
|
||||
if (contentItems.length === 0) {
|
||||
const defaultTextItem: ContentItem = {
|
||||
type: "text",
|
||||
data: "",
|
||||
};
|
||||
setContentItems([defaultTextItem]);
|
||||
onChange?.([defaultTextItem]);
|
||||
}
|
||||
}, [contentItems.length, onChange]);
|
||||
|
||||
// 更新内容项
|
||||
const updateContentItems = (newItems: ContentItem[]) => {
|
||||
setContentItems(newItems);
|
||||
onChange?.(newItems);
|
||||
};
|
||||
|
||||
// 添加新内容项
|
||||
const handleAddItem = () => {
|
||||
const newItem: ContentItem = {
|
||||
type: "text",
|
||||
data: "",
|
||||
};
|
||||
|
||||
const newItems = [...contentItems, newItem];
|
||||
updateContentItems(newItems);
|
||||
};
|
||||
|
||||
// 删除内容项
|
||||
const handleDeleteItem = (index: number) => {
|
||||
const newItems = contentItems.filter((_, i) => i !== index);
|
||||
updateContentItems(newItems);
|
||||
};
|
||||
|
||||
// 更新内容项数据
|
||||
const updateItemData = (index: number, data: any) => {
|
||||
const newItems = [...contentItems];
|
||||
newItems[index] = { ...newItems[index], data };
|
||||
updateContentItems(newItems);
|
||||
};
|
||||
|
||||
// 更新内容项类型
|
||||
const updateItemType = (index: number, newType: string) => {
|
||||
const newItems = [...contentItems];
|
||||
|
||||
// 根据新类型重置数据
|
||||
let newData: any;
|
||||
if (newType === "link") {
|
||||
newData = { title: "", url: "", cover: "" };
|
||||
} else {
|
||||
newData = "";
|
||||
}
|
||||
|
||||
newItems[index] = {
|
||||
type: newType as any,
|
||||
data: newData,
|
||||
};
|
||||
updateContentItems(newItems);
|
||||
};
|
||||
|
||||
// 渲染内容项
|
||||
const renderContentItem = (item: ContentItem, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
border: "1px solid #d9d9d9",
|
||||
borderRadius: "6px",
|
||||
padding: "12px",
|
||||
marginBottom: "8px",
|
||||
backgroundColor: "#fafafa",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<Select
|
||||
value={item.type}
|
||||
onChange={newType => updateItemType(index, newType)}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
>
|
||||
{contentTypes.map(type => (
|
||||
<Option key={type.value} value={type.value}>
|
||||
{type.icon} {type.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{index !== 0 && (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDeleteItem(index)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderContentInput(item, index)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染内容输入
|
||||
const renderContentInput = (item: ContentItem, index: number) => {
|
||||
switch (item.type) {
|
||||
case "text":
|
||||
return (
|
||||
<TextArea
|
||||
value={item.data as string}
|
||||
onChange={e => updateItemData(index, e.target.value)}
|
||||
placeholder="请输入文本内容"
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<ImageUpload
|
||||
count={1}
|
||||
accept="image/*"
|
||||
value={item.data ? [item.data as string] : []}
|
||||
onChange={urls => updateItemData(index, urls[0] || "")}
|
||||
/>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<VideoUpload
|
||||
value={item.data as string}
|
||||
onChange={url => {
|
||||
const videoUrl = Array.isArray(url) ? url[0] || "" : url || "";
|
||||
updateItemData(index, videoUrl);
|
||||
}}
|
||||
maxSize={50}
|
||||
showPreview={true}
|
||||
/>
|
||||
);
|
||||
case "file":
|
||||
return (
|
||||
<FileUpload
|
||||
value={item.data as string}
|
||||
onChange={url => {
|
||||
const fileUrl = Array.isArray(url) ? url[0] || "" : url || "";
|
||||
updateItemData(index, fileUrl);
|
||||
}}
|
||||
maxSize={10}
|
||||
showPreview={true}
|
||||
acceptTypes={["excel", "word", "ppt", "pdf", "txt"]}
|
||||
/>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<AudioUpload
|
||||
value={item.data as string}
|
||||
onChange={url => {
|
||||
const audioUrl = Array.isArray(url) ? url[0] || "" : url || "";
|
||||
updateItemData(index, audioUrl);
|
||||
}}
|
||||
maxSize={50}
|
||||
showPreview={true}
|
||||
/>
|
||||
);
|
||||
case "link": {
|
||||
const linkData = (item.data as LinkData) || {
|
||||
title: "",
|
||||
url: "",
|
||||
cover: "",
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={linkData.title}
|
||||
onChange={e =>
|
||||
updateItemData(index, { ...linkData, title: e.target.value })
|
||||
}
|
||||
placeholder="链接标题"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
value={linkData.url}
|
||||
onChange={e =>
|
||||
updateItemData(index, { ...linkData, url: e.target.value })
|
||||
}
|
||||
placeholder="链接URL"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<ImageUpload
|
||||
count={1}
|
||||
accept="image/*"
|
||||
value={linkData.cover ? [linkData.cover] : []}
|
||||
onChange={urls =>
|
||||
updateItemData(index, { ...linkData, cover: urls[0] || "" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 内容列表 */}
|
||||
{contentItems.map((item, index) => renderContentItem(item, index))}
|
||||
|
||||
{/* 添加内容区域 */}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddItem}
|
||||
>
|
||||
添加内容
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentManager;
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Modal, Form, Input, Button, message, Select } from "antd";
|
||||
import {
|
||||
addKeyword,
|
||||
updateKeyword,
|
||||
getKeywordDetails,
|
||||
type KeywordAddRequest,
|
||||
type KeywordUpdateRequest,
|
||||
} from "../../api";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
interface KeywordModalProps {
|
||||
visible: boolean;
|
||||
mode: "add" | "edit";
|
||||
keywordId?: string | null;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const KeywordModal: React.FC<KeywordModalProps> = ({
|
||||
visible,
|
||||
mode,
|
||||
keywordId,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取关键词详情
|
||||
const fetchKeywordDetails = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const response = await getKeywordDetails(id);
|
||||
if (response) {
|
||||
const keyword = response;
|
||||
form.setFieldsValue({
|
||||
title: keyword.title,
|
||||
keywords: keyword.keywords,
|
||||
content: keyword.content,
|
||||
matchType: keyword.matchType,
|
||||
priority: keyword.priority,
|
||||
replyType: keyword.replyType,
|
||||
status: keyword.status,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取关键词详情失败:", error);
|
||||
message.error("获取关键词详情失败");
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
// 当弹窗打开时处理数据
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (mode === "edit" && keywordId) {
|
||||
// 编辑模式:获取详情
|
||||
fetchKeywordDetails(keywordId);
|
||||
} else if (mode === "add") {
|
||||
// 添加模式:重置表单
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
}, [visible, mode, keywordId, fetchKeywordDetails, form]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (mode === "add") {
|
||||
const data: KeywordAddRequest = {
|
||||
title: values.title,
|
||||
keywords: values.keywords,
|
||||
content: values.content,
|
||||
matchType: values.matchType,
|
||||
priority: values.priority,
|
||||
replyType: values.replyType,
|
||||
status: values.status || "1",
|
||||
};
|
||||
|
||||
const response = await addKeyword(data);
|
||||
if (response) {
|
||||
message.success("添加关键词成功");
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(response?.message || "添加关键词失败");
|
||||
}
|
||||
} else {
|
||||
const data: KeywordUpdateRequest = {
|
||||
id: keywordId,
|
||||
title: values.title,
|
||||
keywords: values.keywords,
|
||||
content: values.content,
|
||||
matchType: values.matchType,
|
||||
priority: values.priority,
|
||||
replyType: values.replyType,
|
||||
status: values.status,
|
||||
};
|
||||
|
||||
const response = await updateKeyword(data);
|
||||
if (response) {
|
||||
message.success("更新关键词回复成功");
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(response?.message || "更新关键词回复失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${mode === "add" ? "添加" : "更新"}关键词失败:`, error);
|
||||
message.error(`${mode === "add" ? "添加" : "更新"}关键词失败`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const title = mode === "add" ? "添加关键词回复" : "编辑关键词回复";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{
|
||||
status: "1",
|
||||
matchType: "模糊匹配",
|
||||
priority: "1",
|
||||
replyType: "text",
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="关键词标题"
|
||||
rules={[{ required: true, message: "请输入关键词标题" }]}
|
||||
>
|
||||
<Input placeholder="请输入关键词标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="keywords"
|
||||
label="关键词"
|
||||
rules={[{ required: true, message: "请输入关键词" }]}
|
||||
>
|
||||
<Input placeholder="请输入关键词" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="回复内容"
|
||||
rules={[{ required: true, message: "请输入回复内容" }]}
|
||||
>
|
||||
<TextArea rows={4} placeholder="请输入回复内容" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="matchType"
|
||||
label="匹配类型"
|
||||
rules={[{ required: true, message: "请选择匹配类型" }]}
|
||||
>
|
||||
<Select placeholder="请选择匹配类型">
|
||||
<Option value="模糊匹配">模糊匹配</Option>
|
||||
<Option value="精确匹配">精确匹配</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label="优先级"
|
||||
rules={[{ required: true, message: "请选择优先级" }]}
|
||||
>
|
||||
<Select placeholder="请选择优先级">
|
||||
<Option value="1">优先级1</Option>
|
||||
<Option value="2">优先级2</Option>
|
||||
<Option value="3">优先级3</Option>
|
||||
<Option value="4">优先级4</Option>
|
||||
<Option value="5">优先级5</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="replyType"
|
||||
label="回复类型"
|
||||
rules={[{ required: true, message: "请选择回复类型" }]}
|
||||
>
|
||||
<Select placeholder="请选择回复类型">
|
||||
<Option value="text">文本回复</Option>
|
||||
<Option value="template">模板回复</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: "请选择状态" }]}
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Option value="1">启用</Option>
|
||||
<Option value="0">禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}
|
||||
>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeywordModal;
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Modal, Form, Input, Button, message, Select } from "antd";
|
||||
import {
|
||||
addMaterial,
|
||||
updateMaterial,
|
||||
getMaterialDetails,
|
||||
type MaterialAddRequest,
|
||||
type MaterialUpdateRequest,
|
||||
type ContentItem,
|
||||
} from "../../api";
|
||||
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
|
||||
import ContentManager from "./ContentManager";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface MaterialModalProps {
|
||||
visible: boolean;
|
||||
mode: "add" | "edit";
|
||||
materialId?: number | null;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const MaterialModal: React.FC<MaterialModalProps> = ({
|
||||
visible,
|
||||
mode,
|
||||
materialId,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [contentItems, setContentItems] = useState<ContentItem[]>([]);
|
||||
|
||||
// 获取素材详情
|
||||
const fetchMaterialDetails = useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
const response = await getMaterialDetails(id.toString());
|
||||
if (response) {
|
||||
const material = response;
|
||||
form.setFieldsValue({
|
||||
title: material.title,
|
||||
cover: material.cover ? [material.cover] : [],
|
||||
status: material.status,
|
||||
});
|
||||
// 设置内容项
|
||||
setContentItems(material.content || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取素材详情失败:", error);
|
||||
message.error("获取素材详情失败");
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
// 当弹窗打开时处理数据
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (mode === "edit" && materialId) {
|
||||
// 编辑模式:获取详情
|
||||
fetchMaterialDetails(materialId);
|
||||
} else if (mode === "add") {
|
||||
// 添加模式:重置表单
|
||||
form.resetFields();
|
||||
setContentItems([]);
|
||||
}
|
||||
}
|
||||
}, [visible, mode, materialId, fetchMaterialDetails, form]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 验证内容项
|
||||
if (contentItems.length === 0) {
|
||||
message.warning("请至少添加一个内容项");
|
||||
return;
|
||||
}
|
||||
|
||||
const coverValue = Array.isArray(values.cover)
|
||||
? values.cover[0] || ""
|
||||
: values.cover || "";
|
||||
|
||||
const data: MaterialAddRequest = {
|
||||
title: values.title,
|
||||
status: values.status || 1,
|
||||
content: contentItems,
|
||||
...(coverValue && { cover: coverValue }),
|
||||
};
|
||||
|
||||
if (mode === "add") {
|
||||
const response = await addMaterial(data);
|
||||
if (response) {
|
||||
message.success("添加素材成功");
|
||||
form.resetFields();
|
||||
setContentItems([]);
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(response?.message || "添加素材失败");
|
||||
}
|
||||
} else {
|
||||
const updateData: MaterialUpdateRequest = {
|
||||
...data,
|
||||
id: materialId?.toString(),
|
||||
};
|
||||
|
||||
const response = await updateMaterial(updateData);
|
||||
if (response) {
|
||||
message.success("更新素材成功");
|
||||
form.resetFields();
|
||||
setContentItems([]);
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(response?.message || "更新素材失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${mode === "add" ? "添加" : "更新"}素材失败:`, error);
|
||||
message.error(`${mode === "add" ? "添加" : "更新"}素材失败`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
setContentItems([]);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const title = mode === "add" ? "添加素材" : "编辑素材";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{ status: 1 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="素材标题"
|
||||
rules={[{ required: true, message: "请输入素材标题" }]}
|
||||
>
|
||||
<Input placeholder="请输入素材标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="素材内容">
|
||||
<ContentManager value={contentItems} onChange={setContentItems} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="cover" label="封面图片">
|
||||
<ImageUpload
|
||||
count={1}
|
||||
accept="image/*"
|
||||
className="material-cover-upload"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: "请选择状态" }]}
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Option value={1}>启用</Option>
|
||||
<Option value={0}>禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}
|
||||
>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialModal;
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Form, Input, Button, message, Select } from "antd";
|
||||
import {
|
||||
addSensitiveWord,
|
||||
updateSensitiveWord,
|
||||
getSensitiveWordDetails,
|
||||
type SensitiveWordAddRequest,
|
||||
type SensitiveWordUpdateRequest,
|
||||
} from "../../api";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
interface SensitiveWordModalProps {
|
||||
visible: boolean;
|
||||
mode: "add" | "edit";
|
||||
sensitiveWordId?: string | null;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const SensitiveWordModal: React.FC<SensitiveWordModalProps> = ({
|
||||
visible,
|
||||
mode,
|
||||
sensitiveWordId,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取敏感词详情
|
||||
const fetchSensitiveWordDetails = async (id: string) => {
|
||||
try {
|
||||
const response = await getSensitiveWordDetails(id);
|
||||
if (response) {
|
||||
const sensitiveWord = response;
|
||||
form.setFieldsValue({
|
||||
title: sensitiveWord.title,
|
||||
keywords: sensitiveWord.keywords,
|
||||
content: sensitiveWord.content,
|
||||
operation: sensitiveWord.operation,
|
||||
status: sensitiveWord.status,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取敏感词详情失败:", error);
|
||||
message.error("获取敏感词详情失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 当弹窗打开且为编辑模式时,获取详情
|
||||
useEffect(() => {
|
||||
if (visible && mode === "edit" && sensitiveWordId) {
|
||||
fetchSensitiveWordDetails(sensitiveWordId);
|
||||
} else if (visible && mode === "add") {
|
||||
// 添加模式时重置表单
|
||||
form.resetFields();
|
||||
}
|
||||
}, [visible, mode, sensitiveWordId]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (mode === "add") {
|
||||
const data: SensitiveWordAddRequest = {
|
||||
title: values.title,
|
||||
keywords: values.keywords,
|
||||
content: values.content,
|
||||
operation: values.operation,
|
||||
status: values.status || "1",
|
||||
};
|
||||
|
||||
const response = await addSensitiveWord(data);
|
||||
if (response) {
|
||||
message.success("添加敏感词成功");
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(response?.message || "添加敏感词失败");
|
||||
}
|
||||
} else {
|
||||
const data: SensitiveWordUpdateRequest = {
|
||||
id: sensitiveWordId,
|
||||
title: values.title,
|
||||
keywords: values.keywords,
|
||||
content: values.content,
|
||||
operation: values.operation,
|
||||
status: values.status,
|
||||
};
|
||||
|
||||
const response = await updateSensitiveWord(data);
|
||||
if (response) {
|
||||
message.success("更新敏感词成功");
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(response?.message || "更新敏感词失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${mode === "add" ? "添加" : "更新"}敏感词失败:`, error);
|
||||
message.error(`${mode === "add" ? "添加" : "更新"}敏感词失败`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const title = mode === "add" ? "添加敏感词" : "编辑敏感词";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{ status: "1", operation: "1" }}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="敏感词标题"
|
||||
rules={[{ required: true, message: "请输入敏感词标题" }]}
|
||||
>
|
||||
<Input placeholder="请输入敏感词标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="keywords"
|
||||
label="关键词"
|
||||
rules={[{ required: true, message: "请输入关键词" }]}
|
||||
>
|
||||
<Input placeholder="请输入关键词" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="敏感词内容"
|
||||
rules={[{ required: true, message: "请输入敏感词内容" }]}
|
||||
>
|
||||
<TextArea rows={4} placeholder="请输入敏感词内容" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="operation"
|
||||
label="操作类型"
|
||||
rules={[{ required: true, message: "请选择操作类型" }]}
|
||||
>
|
||||
<Select placeholder="请选择操作类型">
|
||||
<Option value="0">不操作</Option>
|
||||
<Option value="1">替换</Option>
|
||||
<Option value="2">删除</Option>
|
||||
<Option value="3">警告</Option>
|
||||
<Option value="4">禁止发送</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
rules={[{ required: true, message: "请选择状态" }]}
|
||||
>
|
||||
<Select placeholder="请选择状态">
|
||||
<Option value="1">启用</Option>
|
||||
<Option value="0">禁用</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}
|
||||
>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SensitiveWordModal;
|
||||
@@ -0,0 +1,5 @@
|
||||
// 模态框组件统一导出
|
||||
export { default as MaterialModal } from "./MaterialModal";
|
||||
export { default as SensitiveWordModal } from "./SensitiveWordModal";
|
||||
export { default as KeywordModal } from "./KeywordModal";
|
||||
export { default as ContentManager } from "./ContentManager";
|
||||
@@ -1,31 +1,152 @@
|
||||
.container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
.headerActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
|
||||
:global(.ant-btn) {
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tabsSection {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: #fff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 16px 16px 0px 16px;
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: #1890ff;
|
||||
border-bottom-color: #1890ff;
|
||||
background-color: #f0f8ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
background: #fff;
|
||||
border-radius: 0 0 8px 8px;
|
||||
padding: 24px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.materialContent {
|
||||
.searchSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
:global(.ant-input-search) {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.materialGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.materialCard {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
|
||||
.typeIcon {
|
||||
font-size: 32px;
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #188eee;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.typeSize {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
|
||||
span:first-child {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -34,10 +155,452 @@
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
// 敏感词管理样式
|
||||
.sensitiveContent {
|
||||
.searchSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
:global(.ant-input-search) {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sensitiveList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sensitiveItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #d9d9d9;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
|
||||
.categoryName {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.sensitiveTag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actionText {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.toggleSwitch {
|
||||
:global(.ant-switch) {
|
||||
background-color: #d9d9d9;
|
||||
}
|
||||
|
||||
:global(.ant-switch-checked) {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.editIcon {
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.deleteIcon {
|
||||
font-size: 14px;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关键词管理样式
|
||||
.keywordContent {
|
||||
.searchSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
:global(.ant-input-search) {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.keywordList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.keywordItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #d9d9d9;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.matchTag,
|
||||
.priorityTag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.replyTypeTag {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 16px;
|
||||
|
||||
.toggleSwitch {
|
||||
:global(.ant-switch) {
|
||||
background-color: #d9d9d9;
|
||||
}
|
||||
|
||||
:global(.ant-switch-checked) {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.editIcon {
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.deleteIcon {
|
||||
font-size: 14px;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗中的图片上传组件样式
|
||||
:global(.material-cover-upload) {
|
||||
.uploadContainer {
|
||||
:global(.adm-image-uploader) {
|
||||
.adm-image-uploader-upload-button {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.adm-image-uploader-item {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 素材卡片操作按钮样式
|
||||
.cardActions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
.editBtn {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 素材类型图标样式
|
||||
.typeIcon {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 状态操作区域样式
|
||||
.statusActions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 卡片头部样式
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
// 类型标签样式
|
||||
.typeTag {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
:global(.ant-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
|
||||
.tab {
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
border-radius: 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.materialContent {
|
||||
.searchSection {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
|
||||
:global(.ant-input-search) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.materialGrid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.sensitiveContent {
|
||||
.searchSection {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
|
||||
:global(.ant-input-search) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.sensitiveItem {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
|
||||
.itemContent {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.categoryName {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keywordContent {
|
||||
.searchSection {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
|
||||
:global(.ant-input-search) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.keywordItem {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
|
||||
.itemContent {
|
||||
.tags {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
justify-content: flex-end;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,144 @@
|
||||
import React from "react";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Button } from "antd";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import styles from "./index.module.scss";
|
||||
import {
|
||||
MaterialManagement,
|
||||
SensitiveWordManagement,
|
||||
KeywordManagement,
|
||||
MaterialModal,
|
||||
SensitiveWordModal,
|
||||
KeywordModal,
|
||||
} from "./components";
|
||||
|
||||
const ContentManagement: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<string>("material");
|
||||
const [materialModalVisible, setMaterialModalVisible] = useState(false);
|
||||
const [sensitiveWordModalVisible, setSensitiveWordModalVisible] =
|
||||
useState(false);
|
||||
const [keywordModalVisible, setKeywordModalVisible] = useState(false);
|
||||
|
||||
// 引用管理组件
|
||||
const materialManagementRef = useRef<any>(null);
|
||||
const keywordManagementRef = useRef<any>(null);
|
||||
|
||||
const tabs = [
|
||||
{ key: "material", label: "素材资源库" },
|
||||
{ key: "sensitive", label: "敏感词管理" },
|
||||
{ key: "keyword", label: "关键词回复" },
|
||||
];
|
||||
|
||||
// 按钮点击处理函数
|
||||
const handleAddMaterial = () => {
|
||||
setMaterialModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddSensitiveWord = () => {
|
||||
setSensitiveWordModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddKeyword = () => {
|
||||
setKeywordModalVisible(true);
|
||||
};
|
||||
|
||||
// 弹窗成功回调
|
||||
const handleModalSuccess = () => {
|
||||
// 刷新素材列表
|
||||
if (materialManagementRef.current?.fetchMaterials) {
|
||||
materialManagementRef.current.fetchMaterials();
|
||||
}
|
||||
// 刷新关键词列表
|
||||
if (keywordManagementRef.current?.fetchKeywords) {
|
||||
keywordManagementRef.current.fetchKeywords();
|
||||
}
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case "material":
|
||||
return (
|
||||
<MaterialManagement ref={materialManagementRef} {...({} as any)} />
|
||||
);
|
||||
case "sensitive":
|
||||
return <SensitiveWordManagement />;
|
||||
case "keyword":
|
||||
return (
|
||||
<KeywordManagement ref={keywordManagementRef} {...({} as any)} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<MaterialManagement ref={materialManagementRef} {...({} as any)} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PowerNavigation
|
||||
title="内容管理"
|
||||
subtitle="素材管理、数据词汇库、关键词自动回复"
|
||||
subtitle="可以讲聊天过程的信息收录到素材库中,也调用。"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
rightContent={
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={handleAddMaterial}
|
||||
>
|
||||
添加素材
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddKeyword}>
|
||||
添加关键词回复
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddSensitiveWord}>
|
||||
添加敏感词
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* 功能内容待开发 */}
|
||||
<div className={styles.placeholder}>
|
||||
<p>内容管理功能正在开发中...</p>
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
<br />
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.key}
|
||||
className={`${styles.tab} ${
|
||||
activeTab === tab.key ? styles.tabActive : ""
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>{renderTabContent()}</div>
|
||||
|
||||
{/* 弹窗组件 */}
|
||||
<MaterialModal
|
||||
visible={materialModalVisible}
|
||||
mode="add"
|
||||
onCancel={() => setMaterialModalVisible(false)}
|
||||
onSuccess={handleModalSuccess}
|
||||
/>
|
||||
|
||||
<SensitiveWordModal
|
||||
visible={sensitiveWordModalVisible}
|
||||
mode="add"
|
||||
onCancel={() => setSensitiveWordModalVisible(false)}
|
||||
onSuccess={handleModalSuccess}
|
||||
/>
|
||||
|
||||
<KeywordModal
|
||||
visible={keywordModalVisible}
|
||||
mode="add"
|
||||
onCancel={() => setKeywordModalVisible(false)}
|
||||
onSuccess={handleModalSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,355 @@
|
||||
.container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
.searchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
// 页面头部
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.headerLeft {
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
.addButton {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索和筛选区域
|
||||
.searchSection {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.searchBox {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: #8c8c8c;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px 0 40px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:focus {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filterButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
background: white;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签页
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.activeTab {
|
||||
color: #1890ff;
|
||||
border-bottom-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 联系人列表
|
||||
.contactsList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
// 联系人卡片
|
||||
.contactCard {
|
||||
background: white;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.contactInfo {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nameSection {
|
||||
flex: 1;
|
||||
|
||||
.contactName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 4px 0;
|
||||
|
||||
.starIcon {
|
||||
color: #faad14;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.roleCompany {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 头像样式
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.avatarPlaceholder {
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.contactDetails {
|
||||
margin-bottom: 16px;
|
||||
flex: 1;
|
||||
|
||||
.contactItem {
|
||||
display: flex;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
|
||||
.label {
|
||||
color: #8c8c8c;
|
||||
margin-right: 8px;
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tagsSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.tag {
|
||||
background: #f5f5f5;
|
||||
color: #595959;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.source {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
.lastContact {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notes {
|
||||
background: #f9f9f9;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
|
||||
.actionButton {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
background: white;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&:first-child:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
&:last-child:hover {
|
||||
background: #f6ffed;
|
||||
border-color: #52c41a;
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.contactsList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.contactCard {
|
||||
min-height: 175px;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
.headerRight {
|
||||
width: 100%;
|
||||
|
||||
.addButton {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
flex-direction: column;
|
||||
|
||||
.filterButton {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
|
||||
.tab {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,331 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
MessageOutlined,
|
||||
PhoneOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import styles from "./index.module.scss";
|
||||
import { Button, Input, Row, Col, Pagination, Spin, message } from "antd";
|
||||
import { getContactList } from "@/pages/pc/ckbox/weChat/api";
|
||||
import { ContractData } from "@/pages/pc/ckbox/data";
|
||||
import Layout from "@/components/Layout/LayoutFiexd";
|
||||
// 头像组件
|
||||
const Avatar: React.FC<{ name: string; avatar?: string; size?: number }> = ({
|
||||
name,
|
||||
avatar,
|
||||
size = 40,
|
||||
}) => {
|
||||
const getInitials = (name: string) => {
|
||||
return name.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
const CustomerManagement: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PowerNavigation
|
||||
title="客户好友管理"
|
||||
subtitle="统一管理客户信息和好友关系,提升服务效率"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
const getAvatarColor = (name: string) => {
|
||||
const colors = [
|
||||
"#1890ff",
|
||||
"#52c41a",
|
||||
"#faad14",
|
||||
"#f5222d",
|
||||
"#722ed1",
|
||||
"#13c2c2",
|
||||
"#eb2f96",
|
||||
"#fa8c16",
|
||||
];
|
||||
const index = name.charCodeAt(0) % colors.length;
|
||||
return colors[index];
|
||||
};
|
||||
|
||||
if (avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name}
|
||||
className={styles.avatar}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* 功能内容待开发 */}
|
||||
<div className={styles.placeholder}>
|
||||
<p>客户好友管理功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.avatarPlaceholder}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: getAvatarColor(name),
|
||||
fontSize: size * 0.4,
|
||||
}}
|
||||
>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomerManagement: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [contacts, setContacts] = useState<ContractData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 12,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 获取各分类的总数
|
||||
const [tabCounts, setTabCounts] = useState({
|
||||
all: 0,
|
||||
customer: 0,
|
||||
potential: 0,
|
||||
partner: 0,
|
||||
friend: 0,
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ key: "all", label: "全部", count: tabCounts.all },
|
||||
{ key: "customer", label: "客户", count: tabCounts.customer },
|
||||
{ key: "potential", label: "潜在客户", count: tabCounts.potential },
|
||||
{ key: "partner", label: "合作伙伴", count: tabCounts.partner },
|
||||
{ key: "friend", label: "朋友", count: tabCounts.friend },
|
||||
];
|
||||
|
||||
// 加载联系人数据
|
||||
const loadContacts = async (page: number = 1, pageSize: number = 12) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 构建请求参数
|
||||
const params: any = {
|
||||
page,
|
||||
limit: pageSize,
|
||||
};
|
||||
|
||||
// 添加搜索条件
|
||||
if (searchValue.trim()) {
|
||||
params.keyword = searchValue;
|
||||
}
|
||||
|
||||
// 添加分类筛选
|
||||
if (activeTab === "customer") {
|
||||
params.isPassed = true;
|
||||
} else if (activeTab === "potential") {
|
||||
params.isPassed = false;
|
||||
}
|
||||
// "全部"、"partner" 和 "friend" 不添加额外筛选条件
|
||||
|
||||
const response = await getContactList(params);
|
||||
|
||||
// 假设接口返回格式为 { data: Contact[], total: number, page: number, limit: number }
|
||||
setContacts(response.data || response.list || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: response.page || page,
|
||||
pageSize: response.limit || pageSize,
|
||||
total: response.total || 0,
|
||||
}));
|
||||
|
||||
// 更新分类统计
|
||||
if (page === 1) {
|
||||
// 只在第一页时更新统计,避免重复请求
|
||||
const allResponse = await getContactList({ page: 1, limit: 1 });
|
||||
const customerResponse = await getContactList({
|
||||
page: 1,
|
||||
limit: 1,
|
||||
isPassed: true,
|
||||
});
|
||||
const potentialResponse = await getContactList({
|
||||
page: 1,
|
||||
limit: 1,
|
||||
isPassed: false,
|
||||
});
|
||||
|
||||
setTabCounts({
|
||||
all: allResponse.total || 0,
|
||||
customer: customerResponse.total || 0,
|
||||
potential: potentialResponse.total || 0,
|
||||
partner: 0, // 可以根据业务逻辑调整
|
||||
friend: 0, // 可以根据业务逻辑调整
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载联系人数据失败:", error);
|
||||
message.error("加载联系人数据失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当分类或搜索条件改变时重新加载数据
|
||||
useEffect(() => {
|
||||
loadContacts(1, pagination.pageSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, searchValue, pagination.pageSize]);
|
||||
|
||||
const filteredContacts = contacts;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<div style={{ padding: "20px" }}>
|
||||
<PowerNavigation
|
||||
title="客户好友管理"
|
||||
subtitle="管理客户关系,维护好友信息"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
rightContent={<Button>添加好友</Button>}
|
||||
/>
|
||||
{/* 搜索和筛选 */}
|
||||
<div className={styles.searchBar}>
|
||||
<Input
|
||||
placeholder="搜索好友姓名、公司或标签..."
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => loadContacts(1, pagination.pageSize)}
|
||||
size="large"
|
||||
className={styles["refresh-btn"]}
|
||||
loading={loading}
|
||||
>
|
||||
<FilterOutlined />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
{/* 标签页 */}
|
||||
<div className={styles.tabs}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`${styles.tab} ${activeTab === tab.key ? styles.activeTab : ""}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label} ({tab.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className="pagination-wrapper">
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
showSizeChanger
|
||||
showQuickJumper
|
||||
showTotal={(total, range) =>
|
||||
`第 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
}
|
||||
onChange={(page, pageSize) => {
|
||||
loadContacts(page, pageSize || pagination.pageSize);
|
||||
}}
|
||||
onShowSizeChange={(current, size) => {
|
||||
loadContacts(1, size);
|
||||
}}
|
||||
pageSizeOptions={["6", "12", "24", "48"]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
{/* 联系人卡片列表 */}
|
||||
<div className={styles.contactsList}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: "center", padding: "50px" }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: "16px", color: "#666" }}>
|
||||
正在加载联系人数据...
|
||||
</p>
|
||||
</div>
|
||||
) : filteredContacts.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "50px" }}>
|
||||
<p style={{ color: "#999" }}>暂无联系人数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
{filteredContacts.map(contact => (
|
||||
<Col span={8} key={contact.id || contact.serverId}>
|
||||
<div className={styles.contactCard}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.contactInfo}>
|
||||
<Avatar
|
||||
name={
|
||||
contact.conRemark ||
|
||||
contact.nickname ||
|
||||
contact.alias ||
|
||||
"未知用户"
|
||||
}
|
||||
avatar={contact.avatar}
|
||||
size={48}
|
||||
/>
|
||||
<div className={styles.nameSection}>
|
||||
<h3 className={styles.contactName}>
|
||||
{contact.conRemark ||
|
||||
contact.nickname ||
|
||||
contact.alias ||
|
||||
"未知用户"}
|
||||
</h3>
|
||||
<p className={styles.roleCompany}>
|
||||
客户 {"·"} {contact.desc || "未设置公司"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.contactDetails}>
|
||||
<div className={styles.contactInfo}>
|
||||
<p className={styles.contactItem}>
|
||||
<span className={styles.label}>电话:</span>{" "}
|
||||
{contact.phone || "未设置电话"}
|
||||
</p>
|
||||
<p className={styles.contactItem}>
|
||||
<span className={styles.label}>地区:</span>{" "}
|
||||
{contact.region || contact.city || "未设置地区"}
|
||||
</p>
|
||||
<p className={styles.contactItem}>
|
||||
<span className={styles.label}>微信ID:</span>{" "}
|
||||
{contact.wechatId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tagsSection}>
|
||||
<div className={styles.tags}>
|
||||
{contact?.labels?.map(
|
||||
(tag: string, index: number) => (
|
||||
<span key={index} className={styles.tag}>
|
||||
{tag}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button type="primary" block>
|
||||
<MessageOutlined />
|
||||
聊天
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerManagement;
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import React from "react";
|
||||
import {
|
||||
AimOutlined,
|
||||
SendOutlined,
|
||||
CalendarOutlined,
|
||||
TagsOutlined,
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
FileTextOutlined,
|
||||
RobotOutlined,
|
||||
UserAddOutlined,
|
||||
AppstoreOutlined,
|
||||
SoundOutlined,
|
||||
ThunderboltOutlined,
|
||||
RiseOutlined,
|
||||
TeamOutlined,
|
||||
FolderOutlined,
|
||||
BarChartOutlined,
|
||||
CommentOutlined,
|
||||
FileTextOutlined,
|
||||
SoundOutlined,
|
||||
EditOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
// 数据类型定义
|
||||
export interface FeatureCard {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -24,96 +18,87 @@ export interface FeatureCard {
|
||||
color: string;
|
||||
path?: string;
|
||||
isNew?: boolean;
|
||||
isHot?: boolean;
|
||||
}
|
||||
|
||||
export interface TabItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
active?: boolean;
|
||||
export interface FeatureCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
count: number;
|
||||
features: FeatureCard[];
|
||||
}
|
||||
|
||||
export const featureCards: FeatureCard[] = [
|
||||
// 功能数据
|
||||
export const featureCategories: FeatureCategory[] = [
|
||||
{
|
||||
id: "precision-send",
|
||||
title: "精准群发",
|
||||
description: "基于客户标签和行为数据进行精准群发",
|
||||
icon: <AimOutlined />,
|
||||
color: "#ff6b35",
|
||||
path: "/pc/powerCenter/precision-send",
|
||||
id: "core",
|
||||
title: "核心功能",
|
||||
icon: <AimOutlined style={{ fontSize: "24px" }} />,
|
||||
color: "#1890ff",
|
||||
count: 2,
|
||||
features: [
|
||||
{
|
||||
id: "customer-management",
|
||||
title: "客户好友管理",
|
||||
description: "管理客户关系,维护好友信息,提升客户满意度",
|
||||
icon: <TeamOutlined style={{ fontSize: "24px" }} />,
|
||||
color: "#1890ff",
|
||||
path: "/pc/powerCenter/customer-management",
|
||||
isHot: true,
|
||||
},
|
||||
{
|
||||
id: "communication-record",
|
||||
title: "沟通记录",
|
||||
description: "记录和分析所有客户沟通历史,优化服务质量",
|
||||
icon: <CommentOutlined style={{ fontSize: "24px" }} />,
|
||||
color: "#52c41a",
|
||||
path: "/pc/powerCenter/communication-record",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sop-send",
|
||||
title: "SOP群发",
|
||||
description: "使用触客宝SOP标准化流程进行批量消息发送",
|
||||
icon: <SendOutlined />,
|
||||
color: "#4285f4",
|
||||
path: "/pc/powerCenter/sop-send",
|
||||
id: "ai",
|
||||
title: "AI智能功能",
|
||||
icon: <ThunderboltOutlined style={{ fontSize: "24px" }} />,
|
||||
color: "#722ed1",
|
||||
count: 2,
|
||||
features: [
|
||||
{
|
||||
id: "ai-training",
|
||||
title: "AI模型训练",
|
||||
description: "训练专属AI模型,提升智能服务能力",
|
||||
icon: <FileTextOutlined style={{ fontSize: "24px" }} />,
|
||||
color: "#fa8c16",
|
||||
path: "/pc/powerCenter/ai-training",
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
id: "auto-greeting",
|
||||
title: "自动问候",
|
||||
description: "设置智能问候规则,自动化客户接待流程",
|
||||
icon: <SoundOutlined style={{ fontSize: "24px" }} />,
|
||||
color: "#722ed1",
|
||||
path: "/pc/powerCenter/auto-greeting",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "moments-marketing",
|
||||
title: "朋友圈营销",
|
||||
description: "AI智能生成朋友圈内容,提升品牌曝光度",
|
||||
icon: <CalendarOutlined />,
|
||||
color: "#34a853",
|
||||
path: "/pc/powerCenter/moments-marketing",
|
||||
},
|
||||
{
|
||||
id: "tag-management",
|
||||
title: "标签管理",
|
||||
description: "智能客户标签分类,精准用户画像分析",
|
||||
icon: <TagsOutlined />,
|
||||
color: "#9c27b0",
|
||||
path: "/pc/powerCenter/tag-management",
|
||||
},
|
||||
{
|
||||
id: "customer-management",
|
||||
title: "客户好友管理",
|
||||
description: "统一管理客户信息和好友关系,提升服务效率",
|
||||
icon: <UserOutlined />,
|
||||
color: "#6366f1",
|
||||
path: "/pc/powerCenter/customer-management",
|
||||
},
|
||||
{
|
||||
id: "communication-record",
|
||||
title: "沟通记录",
|
||||
description: "完整记录客户沟通历史,支持多维度查询分析",
|
||||
icon: <MessageOutlined />,
|
||||
color: "#06b6d4",
|
||||
path: "/pc/powerCenter/communication-record",
|
||||
},
|
||||
{
|
||||
id: "content-management",
|
||||
title: "内容管理",
|
||||
description: "素材管理、数据词汇库、关键词自动回复",
|
||||
icon: <FileTextOutlined />,
|
||||
color: "#f59e0b",
|
||||
path: "/pc/powerCenter/content-management",
|
||||
},
|
||||
{
|
||||
id: "ai-training",
|
||||
title: "AI模型训练",
|
||||
description: "自定义AI模型训练,打造专属智能客服助手",
|
||||
icon: <RobotOutlined />,
|
||||
color: "#ec4899",
|
||||
path: "/pc/powerCenter/ai-training",
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
id: "auto-greeting",
|
||||
title: "自动打招呼",
|
||||
description: "智能识别新好友,自动发送个性化欢迎消息",
|
||||
icon: <UserAddOutlined />,
|
||||
color: "#10b981",
|
||||
path: "/pc/powerCenter/auto-greeting",
|
||||
id: "marketing",
|
||||
title: "营销管理",
|
||||
icon: <RiseOutlined style={{ fontSize: "24px" }} />,
|
||||
color: "#52c41a",
|
||||
count: 1,
|
||||
features: [
|
||||
{
|
||||
id: "content-management",
|
||||
title: "内容管理",
|
||||
description: "管理营销内容,素材库,提升内容创作效率",
|
||||
icon: <EditOutlined style={{ fontSize: "24px" }} />,
|
||||
color: "#722ed1",
|
||||
path: "/pc/powerCenter/content-management",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const tabItems: TabItem[] = [
|
||||
{ key: "all", label: "全部功能", icon: <AppstoreOutlined /> },
|
||||
{ key: "marketing", label: "营销推广", icon: <SoundOutlined /> },
|
||||
{ key: "customer", label: "客户管理", icon: <TeamOutlined /> },
|
||||
{ key: "ai", label: "AI智能", icon: <RobotOutlined /> },
|
||||
{ key: "content", label: "内容管理", icon: <FolderOutlined /> },
|
||||
{ key: "data", label: "数据分析", icon: <BarChartOutlined /> },
|
||||
];
|
||||
|
||||
@@ -1,127 +1,176 @@
|
||||
.powerCenter {
|
||||
padding: 24px;
|
||||
background-color: #f5f5f5;
|
||||
padding: 40px;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
|
||||
min-height: 100vh;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 32px;
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
// 功能分类区域
|
||||
.categorySection {
|
||||
margin-bottom: 48px;
|
||||
|
||||
.headerLeft {
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
.categoryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 0 8px;
|
||||
|
||||
.categoryIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.anticon {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
.categoryInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.categoryTitle {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.categoryCount {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background: #f0f0f0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
height: 24px;
|
||||
line-height: 20px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
.tabs {
|
||||
.tab {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
color: #666;
|
||||
.featureCards {
|
||||
.featureCard {
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.activeTab {
|
||||
border-radius: 8px;
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
|
||||
.cardContent {
|
||||
.cardHeader {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
.cardIcon {
|
||||
color: #ffffff;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
.anticon {
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
.badge {
|
||||
background: #ff6b35;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
z-index: 2;
|
||||
box-shadow: 0 1px 4px rgba(255, 107, 53, 0.3);
|
||||
height: 24px;
|
||||
// 新功能标签样式
|
||||
&[data-type="new"] {
|
||||
background: #52c41a;
|
||||
box-shadow: 0 1px 4px rgba(82, 196, 26, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cardAction {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #979797;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
.arrow {
|
||||
font-size: 14px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
.featureCard {
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
height: 200px;
|
||||
cursor: pointer;
|
||||
// 页面底部
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 60px;
|
||||
padding: 24px 0;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
|
||||
.iconWrapper {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
|
||||
.icon {
|
||||
font-size: 28px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
.cardTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.newBadge {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a24);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,16 +178,65 @@
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.powerCenter {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
padding: 32px 24px;
|
||||
|
||||
.headerRight {
|
||||
width: 100%;
|
||||
.categorySection {
|
||||
.categoryHeader {
|
||||
.categoryIcon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
|
||||
.tabs {
|
||||
flex-wrap: wrap;
|
||||
.anticon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.categoryInfo {
|
||||
.categoryTitle {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.featureCards {
|
||||
.featureCard {
|
||||
height: 180px;
|
||||
width: 260px;
|
||||
padding: 20px;
|
||||
|
||||
.cardContent {
|
||||
.cardIcon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 18px auto 14px;
|
||||
|
||||
.anticon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
padding: 0 14px 14px;
|
||||
|
||||
.cardTitle {
|
||||
font-size: 15px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
font-size: 11px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cardAction {
|
||||
font-size: 11px;
|
||||
|
||||
.arrow {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,91 +245,89 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.powerCenter {
|
||||
padding: 16px;
|
||||
padding: 24px 16px;
|
||||
|
||||
.header {
|
||||
padding: 16px;
|
||||
.categorySection {
|
||||
margin-bottom: 32px;
|
||||
|
||||
.headerLeft {
|
||||
.title {
|
||||
font-size: 24px;
|
||||
.categoryHeader {
|
||||
.categoryIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
.anticon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
.featureCard {
|
||||
height: 160px;
|
||||
|
||||
.cardContent {
|
||||
.iconWrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.categoryInfo {
|
||||
.categoryTitle {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
.cardTitle {
|
||||
font-size: 14px;
|
||||
.categoryCount {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.featureCards {
|
||||
.featureCard {
|
||||
height: 160px;
|
||||
width: 240px;
|
||||
padding: 16px;
|
||||
|
||||
.cardContent {
|
||||
.badge {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 10px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
font-size: 11px;
|
||||
.cardIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 16px auto 12px;
|
||||
|
||||
.anticon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
padding: 0 12px 12px;
|
||||
|
||||
.cardTitle {
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
font-size: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cardAction {
|
||||
font-size: 10px;
|
||||
|
||||
.arrow {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片颜色主题
|
||||
.featureCard {
|
||||
// 精准群发 - 橙色
|
||||
&[data-color="#ff6b35"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #ff6b35, #f7931e);
|
||||
}
|
||||
|
||||
// SOP群发 - 蓝色
|
||||
&[data-color="#4285f4"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #4285f4, #1a73e8);
|
||||
}
|
||||
|
||||
// 朋友圈营销 - 绿色
|
||||
&[data-color="#34a853"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #34a853, #137333);
|
||||
}
|
||||
|
||||
// 标签管理 - 紫色
|
||||
&[data-color="#9c27b0"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #9c27b0, #7b1fa2);
|
||||
}
|
||||
|
||||
// 客户管理 - 靛蓝
|
||||
&[data-color="#6366f1"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||
}
|
||||
|
||||
// 沟通记录 - 青色
|
||||
&[data-color="#06b6d4"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #06b6d4, #0891b2);
|
||||
}
|
||||
|
||||
// 内容管理 - 黄色
|
||||
&[data-color="#f59e0b"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
}
|
||||
|
||||
// AI训练 - 粉色
|
||||
&[data-color="#ec4899"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #ec4899, #db2777);
|
||||
}
|
||||
|
||||
// 自动打招呼 - 翠绿
|
||||
&[data-color="#10b981"] .iconWrapper {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Card, Row, Col, Button, Space } from "antd";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { featureCards, tabItems, FeatureCard } from "./index.data";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
import { FeatureCard, featureCategories } from "./index.data.tsx";
|
||||
import { Col, Row } from "antd";
|
||||
const PowerCenter: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
|
||||
const handleCardClick = (card: FeatureCard) => {
|
||||
if (card.path) {
|
||||
@@ -14,87 +12,84 @@ const PowerCenter: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClick = (tabKey: string) => {
|
||||
setActiveTab(tabKey);
|
||||
};
|
||||
|
||||
// 根据选中的标签过滤功能卡片
|
||||
const filteredCards = useMemo(() => {
|
||||
if (activeTab === "all") {
|
||||
return featureCards;
|
||||
}
|
||||
|
||||
const categoryMap: { [key: string]: string[] } = {
|
||||
marketing: ["precision-send", "sop-send", "moments-marketing"],
|
||||
customer: ["customer-management", "tag-management"],
|
||||
ai: ["ai-training", "auto-greeting"],
|
||||
content: ["content-management"],
|
||||
data: ["communication-record"],
|
||||
};
|
||||
|
||||
const categoryIds = categoryMap[activeTab] || [];
|
||||
return featureCards.filter(card => categoryIds.includes(card.id));
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div className={styles.powerCenter}>
|
||||
{/* 页面头部 */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<h1 className={styles.title}>功能中心</h1>
|
||||
<p className={styles.subtitle}>触客宝AI私域营销系统 - 所有功能一览</p>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
<Space className={styles.tabs}>
|
||||
{tabItems.map(item => (
|
||||
<Button
|
||||
key={item.key}
|
||||
type={activeTab === item.key ? "primary" : "text"}
|
||||
className={
|
||||
activeTab === item.key ? styles.activeTab : styles.tab
|
||||
}
|
||||
onClick={() => handleTabClick(item.key)}
|
||||
icon={item.icon}
|
||||
{/* 功能分类展示 */}
|
||||
{featureCategories.map(category => (
|
||||
<div key={category.id} className={styles.categorySection}>
|
||||
{/* 分类标题 */}
|
||||
<div className={styles.categoryHeader}>
|
||||
<div
|
||||
className={styles.categoryIcon}
|
||||
style={{ backgroundColor: category.color }}
|
||||
>
|
||||
{category.icon}
|
||||
</div>
|
||||
<div className={styles.categoryInfo}>
|
||||
<h2 className={styles.categoryTitle}>{category.title}</h2>
|
||||
<span
|
||||
className={styles.categoryCount}
|
||||
style={{ backgroundColor: category.color, color: "#ffffff" }}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
{category.count}个功能
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能卡片网格 */}
|
||||
<div className={styles.cardGrid}>
|
||||
<Row gutter={[24, 24]}>
|
||||
{filteredCards.map(card => (
|
||||
<Col key={card.id} xs={24} sm={12} md={8} lg={6} xl={6}>
|
||||
<Card
|
||||
className={styles.featureCard}
|
||||
hoverable
|
||||
onClick={() => handleCardClick(card)}
|
||||
bodyStyle={{ padding: "24px" }}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
{/* 功能卡片 */}
|
||||
<div className={styles.featureCards}>
|
||||
<Row gutter={16}>
|
||||
{category.features.map(card => (
|
||||
<Col span={8} key={card.id}>
|
||||
<div
|
||||
className={styles.iconWrapper}
|
||||
style={{ backgroundColor: card.color }}
|
||||
key={card.id}
|
||||
className={styles.featureCard}
|
||||
onClick={() => handleCardClick(card)}
|
||||
>
|
||||
<div className={styles.icon}>{card.icon}</div>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div
|
||||
className={styles.cardIcon}
|
||||
style={{ backgroundColor: card.color }}
|
||||
>
|
||||
{card.icon}
|
||||
</div>
|
||||
{/* 热门/新功能标签 */}
|
||||
{(card.isHot || card.isNew) && (
|
||||
<div
|
||||
className={styles.badge}
|
||||
data-type={card.isNew ? "new" : "hot"}
|
||||
>
|
||||
{card.isHot ? "热门" : "新功能"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 功能图标 */}
|
||||
</div>
|
||||
|
||||
{/* 功能信息 */}
|
||||
<div className={styles.cardInfo}>
|
||||
<h3 className={styles.cardTitle}>{card.title}</h3>
|
||||
<p className={styles.cardDescription}>
|
||||
{card.description}
|
||||
</p>
|
||||
<div className={styles.cardAction}>
|
||||
<span>点击进入功能</span>
|
||||
<span className={styles.arrow}>→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cardInfo}>
|
||||
<h3 className={styles.cardTitle}>
|
||||
{card.title}
|
||||
{card.isNew && (
|
||||
<span className={styles.newBadge}>新功能</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className={styles.cardDescription}>{card.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 页面底部 */}
|
||||
<div className={styles.footer}>
|
||||
<p>触客宝 AI私域营销系统 - 让每一次沟通都更有价值</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
.container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from "react";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const MomentsMarketing: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PowerNavigation
|
||||
title="朋友圈营销"
|
||||
subtitle="AI智能生成朋友圈内容,提升品牌曝光度"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* 功能内容待开发 */}
|
||||
<div className={styles.placeholder}>
|
||||
<p>朋友圈营销功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MomentsMarketing;
|
||||
@@ -1,215 +0,0 @@
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stepsContainer {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.steps {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stepContent {
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
.stepCard {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
|
||||
:global(.ant-card-head) {
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
|
||||
.ant-card-head-title {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
|
||||
.anticon {
|
||||
margin-right: 8px;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ageRange {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ant-input-number {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.estimatedCount {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background: #e6f7ff;
|
||||
border: 1px solid #91d5ff;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.anticon {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
|
||||
strong {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.variableList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.ant-tag {
|
||||
margin: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messagePreview {
|
||||
margin-top: 24px;
|
||||
|
||||
h4 {
|
||||
margin-bottom: 12px;
|
||||
color: #262626;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.previewContent {
|
||||
padding: 16px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
min-height: 80px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #262626;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.summary {
|
||||
h4 {
|
||||
margin-bottom: 16px;
|
||||
color: #262626;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summaryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
|
||||
.anticon {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
.stepActions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0;
|
||||
background: #fff;
|
||||
|
||||
.ant-btn {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stepsContainer {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.summaryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ageRange {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 表单样式优化
|
||||
:global {
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-checkbox-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
.ant-upload-list-picture-card .ant-upload-list-item {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ant-steps-item-title {
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,726 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import Layout from "@/components/Layout/LayoutFiexd";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Steps,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
Radio,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
InputNumber,
|
||||
Upload,
|
||||
Tag,
|
||||
Divider,
|
||||
Space,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
SendOutlined,
|
||||
PlusOutlined,
|
||||
TagsOutlined,
|
||||
ClockCircleOutlined,
|
||||
TeamOutlined,
|
||||
AimOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import type { UploadFile } from "antd";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { Step } = Steps;
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
interface CustomerFilter {
|
||||
tags: string[];
|
||||
regions: string[];
|
||||
ageRange: [number, number];
|
||||
gender: "all" | "male" | "female";
|
||||
lastContactTime: "all" | "7days" | "30days" | "90days" | "180days";
|
||||
purchaseHistory: "all" | "purchased" | "no-purchase" | "high-value";
|
||||
}
|
||||
|
||||
interface MessageContent {
|
||||
type: "text" | "image" | "mixed";
|
||||
text: string;
|
||||
images: UploadFile[];
|
||||
variables: string[];
|
||||
}
|
||||
|
||||
interface SendSettings {
|
||||
sendMode: "immediate" | "scheduled";
|
||||
scheduledTime?: string;
|
||||
sendInterval: number;
|
||||
maxPerDay: number;
|
||||
timeRange: [string, string];
|
||||
}
|
||||
|
||||
const PrecisionSend: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [form] = Form.useForm();
|
||||
const [customerFilter, setCustomerFilter] = useState<CustomerFilter>({
|
||||
tags: [],
|
||||
regions: [],
|
||||
ageRange: [18, 65],
|
||||
gender: "all",
|
||||
lastContactTime: "all",
|
||||
purchaseHistory: "all",
|
||||
});
|
||||
const [messageContent, setMessageContent] = useState<MessageContent>({
|
||||
type: "text",
|
||||
text: "",
|
||||
images: [],
|
||||
variables: [],
|
||||
});
|
||||
const [sendSettings, setSendSettings] = useState<SendSettings>({
|
||||
sendMode: "immediate",
|
||||
sendInterval: 5,
|
||||
maxPerDay: 100,
|
||||
timeRange: ["09:00", "18:00"],
|
||||
});
|
||||
const [estimatedCount, setEstimatedCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const customerTags = [
|
||||
"高价值客户",
|
||||
"潜在客户",
|
||||
"活跃用户",
|
||||
"沉默用户",
|
||||
"VIP客户",
|
||||
"新客户",
|
||||
"老客户",
|
||||
"流失客户",
|
||||
];
|
||||
|
||||
const regions = [
|
||||
"北京",
|
||||
"上海",
|
||||
"广州",
|
||||
"深圳",
|
||||
"杭州",
|
||||
"南京",
|
||||
"成都",
|
||||
"武汉",
|
||||
"西安",
|
||||
"重庆",
|
||||
"天津",
|
||||
"苏州",
|
||||
];
|
||||
|
||||
const messageVariables = [
|
||||
"{客户姓名}",
|
||||
"{公司名称}",
|
||||
"{联系人}",
|
||||
"{产品名称}",
|
||||
"{优惠金额}",
|
||||
"{到期时间}",
|
||||
"{客服电话}",
|
||||
"{官网地址}",
|
||||
];
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 2) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
// 验证必填项
|
||||
if (!messageContent.text && messageContent.images.length === 0) {
|
||||
message.error("请输入消息内容或上传图片");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
sendSettings.sendMode === "scheduled" &&
|
||||
!sendSettings.scheduledTime
|
||||
) {
|
||||
message.error("请选择发送时间");
|
||||
return;
|
||||
}
|
||||
|
||||
if (estimatedCount === 0) {
|
||||
message.error("没有符合条件的客户,请调整筛选条件");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const hide = message.loading("正在创建发送任务...", 0);
|
||||
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
hide();
|
||||
message.success(
|
||||
`发送任务已创建成功!预计发送给 ${estimatedCount} 位客户`,
|
||||
);
|
||||
|
||||
// 重置表单
|
||||
setCurrentStep(0);
|
||||
form.resetFields();
|
||||
setCustomerFilter({
|
||||
tags: [],
|
||||
regions: [],
|
||||
ageRange: [18, 65],
|
||||
gender: "all",
|
||||
lastContactTime: "all",
|
||||
purchaseHistory: "all",
|
||||
});
|
||||
setMessageContent({
|
||||
type: "text",
|
||||
text: "",
|
||||
images: [],
|
||||
variables: [],
|
||||
});
|
||||
setSendSettings({
|
||||
sendMode: "immediate",
|
||||
sendInterval: 5,
|
||||
maxPerDay: 100,
|
||||
timeRange: ["09:00", "18:00"],
|
||||
});
|
||||
} catch (error) {
|
||||
message.error("提交失败,请检查网络连接后重试");
|
||||
console.error("Submit error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算预估客户数量
|
||||
const calculateEstimatedCount = useCallback(() => {
|
||||
let baseCount = 1000; // 假设基础客户数
|
||||
|
||||
// 根据筛选条件调整数量
|
||||
if (customerFilter.tags.length > 0) {
|
||||
baseCount = Math.floor(
|
||||
baseCount * (0.9 - customerFilter.tags.length * 0.1),
|
||||
);
|
||||
}
|
||||
if (customerFilter.regions.length > 0) {
|
||||
baseCount = Math.floor(
|
||||
baseCount * (0.95 - customerFilter.regions.length * 0.05),
|
||||
);
|
||||
}
|
||||
if (customerFilter.gender !== "all") {
|
||||
baseCount = Math.floor(baseCount * 0.5);
|
||||
}
|
||||
if (customerFilter.purchaseHistory !== "all") {
|
||||
baseCount = Math.floor(baseCount * 0.6);
|
||||
}
|
||||
|
||||
setEstimatedCount(Math.max(baseCount, 0));
|
||||
}, [customerFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateEstimatedCount();
|
||||
}, [calculateEstimatedCount]);
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<>
|
||||
<UserOutlined /> 客户筛选
|
||||
</>
|
||||
}
|
||||
className={styles.stepCard}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="客户标签" name="tags">
|
||||
<Checkbox.Group
|
||||
options={customerTags.map(tag => ({
|
||||
label: tag,
|
||||
value: tag,
|
||||
}))}
|
||||
value={customerFilter.tags}
|
||||
onChange={values =>
|
||||
setCustomerFilter({
|
||||
...customerFilter,
|
||||
tags: values as string[],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="地区筛选" name="regions">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择目标地区"
|
||||
value={customerFilter.regions}
|
||||
onChange={values =>
|
||||
setCustomerFilter({ ...customerFilter, regions: values })
|
||||
}
|
||||
>
|
||||
{regions.map(region => (
|
||||
<Option key={region} value={region}>
|
||||
{region}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="年龄范围" name="ageRange">
|
||||
<div className={styles.ageRange}>
|
||||
<InputNumber
|
||||
min={18}
|
||||
max={100}
|
||||
value={customerFilter.ageRange[0]}
|
||||
onChange={value =>
|
||||
setCustomerFilter({
|
||||
...customerFilter,
|
||||
ageRange: [value || 18, customerFilter.ageRange[1]],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span style={{ margin: "0 8px" }}>-</span>
|
||||
<InputNumber
|
||||
min={18}
|
||||
max={100}
|
||||
value={customerFilter.ageRange[1]}
|
||||
onChange={value =>
|
||||
setCustomerFilter({
|
||||
...customerFilter,
|
||||
ageRange: [customerFilter.ageRange[0], value || 65],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="性别" name="gender">
|
||||
<Radio.Group
|
||||
value={customerFilter.gender}
|
||||
onChange={e =>
|
||||
setCustomerFilter({
|
||||
...customerFilter,
|
||||
gender: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Radio value="all">不限</Radio>
|
||||
<Radio value="male">男性</Radio>
|
||||
<Radio value="female">女性</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="最后联系时间" name="lastContactTime">
|
||||
<Select
|
||||
value={customerFilter.lastContactTime}
|
||||
onChange={value =>
|
||||
setCustomerFilter({
|
||||
...customerFilter,
|
||||
lastContactTime: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Option value="all">不限</Option>
|
||||
<Option value="7days">7天内</Option>
|
||||
<Option value="30days">30天内</Option>
|
||||
<Option value="90days">90天内</Option>
|
||||
<Option value="180days">180天内</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="购买历史" name="purchaseHistory">
|
||||
<Select
|
||||
value={customerFilter.purchaseHistory}
|
||||
onChange={value =>
|
||||
setCustomerFilter({
|
||||
...customerFilter,
|
||||
purchaseHistory: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Option value="all">不限</Option>
|
||||
<Option value="purchased">有购买记录</Option>
|
||||
<Option value="no-purchase">无购买记录</Option>
|
||||
<Option value="high-value">高价值客户</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className={styles.estimatedCount}>
|
||||
<TeamOutlined />
|
||||
<span>
|
||||
预估触达客户:<strong>{estimatedCount}</strong> 人
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
case 1:
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<>
|
||||
<MessageOutlined /> 消息内容
|
||||
</>
|
||||
}
|
||||
className={styles.stepCard}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="消息类型" name="messageType">
|
||||
<Radio.Group
|
||||
value={messageContent.type}
|
||||
onChange={e =>
|
||||
setMessageContent({
|
||||
...messageContent,
|
||||
type: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Radio value="text">纯文本</Radio>
|
||||
<Radio value="image">图片</Radio>
|
||||
<Radio value="mixed">图文混合</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{(messageContent.type === "text" ||
|
||||
messageContent.type === "mixed") && (
|
||||
<Form.Item label="消息文本" name="messageContent">
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="请输入消息内容,支持变量如 {客户姓名}、{产品名称} 等"
|
||||
value={messageContent.text}
|
||||
onChange={e =>
|
||||
setMessageContent({
|
||||
...messageContent,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
showCount
|
||||
maxLength={500}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{(messageContent.type === "image" ||
|
||||
messageContent.type === "mixed") && (
|
||||
<Form.Item label="上传图片" name="images">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={messageContent.images}
|
||||
onChange={({ fileList }) =>
|
||||
setMessageContent({ ...messageContent, images: fileList })
|
||||
}
|
||||
beforeUpload={file => {
|
||||
const isImage = file.type?.startsWith("image/");
|
||||
if (!isImage) {
|
||||
message.error("只能上传图片文件!");
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
if (!isLt2M) {
|
||||
message.error("图片大小不能超过 2MB!");
|
||||
}
|
||||
return false; // 阻止自动上传
|
||||
}}
|
||||
accept="image/*"
|
||||
>
|
||||
{messageContent.images.length < 9 && (
|
||||
<div>
|
||||
<PlusOutlined />
|
||||
<div style={{ marginTop: 8 }}>上传图片</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label="可用变量">
|
||||
<div className={styles.variableList}>
|
||||
{messageVariables.map(variable => (
|
||||
<Tag
|
||||
key={variable}
|
||||
icon={<TagsOutlined />}
|
||||
color="blue"
|
||||
style={{ cursor: "pointer", margin: "4px" }}
|
||||
onClick={() => {
|
||||
const newText = messageContent.text + variable;
|
||||
setMessageContent({ ...messageContent, text: newText });
|
||||
}}
|
||||
>
|
||||
{variable}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
<div className={styles.previewArea}>
|
||||
<h4>消息预览</h4>
|
||||
<div className={styles.messagePreview}>
|
||||
{messageContent.text && (
|
||||
<div className={styles.textPreview}>
|
||||
{messageContent.text}
|
||||
</div>
|
||||
)}
|
||||
{messageContent.images.length > 0 && (
|
||||
<div className={styles.imagePreview}>
|
||||
{messageContent.images.map((image, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={image.url || image.thumbUrl}
|
||||
alt={`preview-${index}`}
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
objectFit: "cover",
|
||||
margin: 4,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<>
|
||||
<SendOutlined /> 发送设置
|
||||
</>
|
||||
}
|
||||
className={styles.stepCard}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="发送模式" name="sendMode">
|
||||
<Radio.Group
|
||||
value={sendSettings.sendMode}
|
||||
onChange={e =>
|
||||
setSendSettings({
|
||||
...sendSettings,
|
||||
sendMode: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Radio value="immediate">立即发送</Radio>
|
||||
<Radio value="scheduled">定时发送</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{sendSettings.sendMode === "scheduled" && (
|
||||
<Form.Item label="定时时间" name="scheduledTime">
|
||||
<DatePicker
|
||||
showTime
|
||||
placeholder="选择发送时间"
|
||||
onChange={(date, dateString) =>
|
||||
setSendSettings({
|
||||
...sendSettings,
|
||||
scheduledTime: dateString as string,
|
||||
})
|
||||
}
|
||||
disabledDate={current =>
|
||||
current && current < dayjs().endOf("day")
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label="发送间隔(秒)" name="sendInterval">
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={60}
|
||||
value={sendSettings.sendInterval}
|
||||
onChange={value =>
|
||||
setSendSettings({
|
||||
...sendSettings,
|
||||
sendInterval: value || 5,
|
||||
})
|
||||
}
|
||||
addonAfter="秒"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="每日最大发送数" name="maxPerDay">
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={1000}
|
||||
value={sendSettings.maxPerDay}
|
||||
onChange={value =>
|
||||
setSendSettings({
|
||||
...sendSettings,
|
||||
maxPerDay: value || 100,
|
||||
})
|
||||
}
|
||||
addonAfter="条"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="发送时间段" name="timeRange">
|
||||
<TimePicker.RangePicker
|
||||
format="HH:mm"
|
||||
value={
|
||||
sendSettings.timeRange.map(time =>
|
||||
time ? dayjs(time, "HH:mm") : null,
|
||||
) as any
|
||||
}
|
||||
onChange={(times, timeStrings) =>
|
||||
setSendSettings({
|
||||
...sendSettings,
|
||||
timeRange: timeStrings as [string, string],
|
||||
})
|
||||
}
|
||||
placeholder={["开始时间", "结束时间"]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="高级设置">
|
||||
<Space direction="vertical">
|
||||
<Checkbox>启用发送跟踪 (统计阅读率、回复率等)</Checkbox>
|
||||
<Checkbox>启用自动回复 (客户回复时自动响应)</Checkbox>
|
||||
<Checkbox>避开休息时间 (自动跳过非工作时间)</Checkbox>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className={styles.summary}>
|
||||
<h4>
|
||||
<AimOutlined style={{ marginRight: 8 }} />
|
||||
发送摘要
|
||||
</h4>
|
||||
<div className={styles.summaryGrid}>
|
||||
<div className={styles.summaryItem}>
|
||||
<TeamOutlined />
|
||||
<span>
|
||||
目标客户:<strong>{estimatedCount}</strong> 人
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<ClockCircleOutlined />
|
||||
<span>
|
||||
发送模式:
|
||||
<strong>
|
||||
{sendSettings.sendMode === "immediate"
|
||||
? "立即发送"
|
||||
: `定时发送 (${sendSettings.scheduledTime || "未设置"})`}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<AimOutlined />
|
||||
<span>
|
||||
发送间隔:<strong>{sendSettings.sendInterval}</strong> 秒
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<TagsOutlined />
|
||||
<span>
|
||||
每日限额:<strong>{sendSettings.maxPerDay}</strong> 条
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<ClockCircleOutlined />
|
||||
<span>
|
||||
时间段:
|
||||
<strong>
|
||||
{sendSettings.timeRange[0]} - {sendSettings.timeRange[1]}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<PowerNavigation
|
||||
title="精准群发1"
|
||||
subtitle="基于客户标签和行为数据进行精准群发"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
/>
|
||||
<div className={styles.stepsContainer}>
|
||||
<Steps current={currentStep} className={styles.steps}>
|
||||
<Step title="客户筛选" icon={<UserOutlined />} />
|
||||
<Step title="消息内容" icon={<MessageOutlined />} />
|
||||
<Step title="发送设置" icon={<SendOutlined />} />
|
||||
</Steps>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className={styles.stepActions}>
|
||||
<Space>
|
||||
{currentStep > 0 && (
|
||||
<Button size="large" onClick={handlePrev} disabled={loading}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < 2 ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
// 简单验证当前步骤
|
||||
if (currentStep === 0 && estimatedCount === 0) {
|
||||
message.warning("请设置筛选条件以选择目标客户");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
currentStep === 1 &&
|
||||
!messageContent.text &&
|
||||
messageContent.images.length === 0
|
||||
) {
|
||||
message.warning("请输入消息内容或上传图片");
|
||||
return;
|
||||
}
|
||||
handleNext();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
icon={<SendOutlined />}
|
||||
>
|
||||
创建群发任务
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.stepContent}>{renderStepContent()}</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrecisionSend;
|
||||
@@ -1,43 +0,0 @@
|
||||
.container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from "react";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const SopSend: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PowerNavigation
|
||||
title="SOP群发"
|
||||
subtitle="使用触客宝SOP标准化流程进行批量消息发送"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* 功能内容待开发 */}
|
||||
<div className={styles.placeholder}>
|
||||
<p>SOP群发功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SopSend;
|
||||
@@ -1,43 +0,0 @@
|
||||
.container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 300px;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from "react";
|
||||
import PowerNavigation from "@/components/PowerNavtion";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const TagManagement: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PowerNavigation
|
||||
title="标签管理"
|
||||
subtitle="智能客户标签分类,精准用户画像分析"
|
||||
showBackButton={true}
|
||||
backButtonText="返回功能中心"
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* 功能内容待开发 */}
|
||||
<div className={styles.placeholder}>
|
||||
<p>标签管理功能正在开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagManagement;
|
||||
@@ -37,6 +37,11 @@ export function getContactList(params) {
|
||||
export function getGroupList(params) {
|
||||
return request("/v1/kefu/wechatChatroom/list", params, "GET");
|
||||
}
|
||||
// 分组列表
|
||||
export function getLabelsListByGroup(params) {
|
||||
return request("/v1/kefu/wechatGroup/list", params, "GET");
|
||||
}
|
||||
|
||||
//==============-原接口=================
|
||||
// 获取联系人列表
|
||||
// export const getContactList = (params: { prevId: number; count: number }) => {
|
||||
@@ -316,3 +321,65 @@ export const forwardMessage = (
|
||||
export const recallMessage = (messageId: string): Promise<void> => {
|
||||
return request2(`/v1/messages/${messageId}/recall`, {}, "PUT");
|
||||
};
|
||||
|
||||
// ============== 跟进提醒相关接口 ==============
|
||||
|
||||
// 跟进提醒列表
|
||||
export const getFollowUpList = (params: {
|
||||
isProcess?: string;
|
||||
isRemind?: string;
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
friendId?: string;
|
||||
}) => {
|
||||
return request("/v1/kefu/followUp/list", params, "GET");
|
||||
};
|
||||
|
||||
// 跟进提醒添加
|
||||
export const addFollowUp = (params: {
|
||||
description?: string;
|
||||
friendId: string;
|
||||
reminderTime?: string;
|
||||
title?: string;
|
||||
type?: string; // 0其他 1电话回访 2发送消息 3安排会议 4发送邮件
|
||||
}) => {
|
||||
return request("/v1/kefu/followUp/add", params, "POST");
|
||||
};
|
||||
|
||||
// 跟进提醒处理
|
||||
export const processFollowUp = (params: { ids?: string }) => {
|
||||
return request("/v1/kefu/followUp/process", params, "GET");
|
||||
};
|
||||
|
||||
// ============== 待办事项相关接口 ==============
|
||||
|
||||
// 待办事项列表
|
||||
export const getTodoList = (params: {
|
||||
isProcess?: string;
|
||||
isRemind?: string;
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
limit?: string;
|
||||
page?: string;
|
||||
friendId?: string;
|
||||
}) => {
|
||||
return request("/v1/kefu/todo/list", params, "GET");
|
||||
};
|
||||
|
||||
// 待办事项添加
|
||||
export const addTodo = (params: {
|
||||
description?: string;
|
||||
friendId: string;
|
||||
level?: string; // 0低优先级 1中优先级 2高优先级 3紧急
|
||||
reminderTime?: string;
|
||||
title?: string;
|
||||
}) => {
|
||||
return request("/v1/kefu/todo/add", params, "POST");
|
||||
};
|
||||
|
||||
// 待办事项处理
|
||||
export const processTodo = (params: { ids: string }) => {
|
||||
return request("/v1/kefu/todo/process", params, "GET");
|
||||
};
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
.extend {
|
||||
background: #fff;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.chatHeader {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// 跟进提醒模态框样式
|
||||
.followupModal {
|
||||
:global(.ant-modal-header) {
|
||||
border-bottom: none;
|
||||
padding: 16px 20px 0 20px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0 20px 20px 20px;
|
||||
max-height: 60vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.ant-modal-close) {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
.modalTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modalSubtitle {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.addReminderSection {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.reminderForm {
|
||||
.formRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.formItem {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
|
||||
:global(.ant-form-item-label) {
|
||||
padding-bottom: 4px;
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selectInput,
|
||||
.dateInput,
|
||||
.contentInput {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.contentInput {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
|
||||
|
||||
&:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remindersList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
|
||||
:global(.ant-list) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.ant-list-item) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.reminderItem {
|
||||
padding: 10px;
|
||||
|
||||
.reminderContent {
|
||||
width: 100%;
|
||||
|
||||
.reminderHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.typeTag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
.anticon {
|
||||
margin-right: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.recipient {
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.reminderBody {
|
||||
margin-bottom: 6px;
|
||||
|
||||
.reminderText {
|
||||
font-size: 13px;
|
||||
color: #262626;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.reminderFooter {
|
||||
.clockIcon {
|
||||
color: #8c8c8c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.scheduledTime {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.followupModal {
|
||||
:global(.ant-modal) {
|
||||
margin: 0;
|
||||
max-width: 100vw;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
.addReminderSection {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.reminderForm {
|
||||
.formRow {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remindersList {
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Select,
|
||||
DatePicker,
|
||||
Input,
|
||||
Button,
|
||||
List,
|
||||
Tag,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
ClockCircleOutlined,
|
||||
PhoneOutlined,
|
||||
MessageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
getFollowUpList,
|
||||
addFollowUp,
|
||||
processFollowUp,
|
||||
} from "@/pages/pc/ckbox/weChat/api";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
// 类型映射
|
||||
const typeMap: { [key: string]: string } = {
|
||||
"1": "电话",
|
||||
"2": "消息",
|
||||
"3": "会议",
|
||||
"4": "邮件",
|
||||
"0": "其他",
|
||||
};
|
||||
|
||||
interface FollowupReminder {
|
||||
id: string;
|
||||
type: "电话" | "消息" | "其他" | "会议" | "邮件";
|
||||
status: "待处理" | "已完成" | "已取消";
|
||||
content: string;
|
||||
scheduledTime: string;
|
||||
recipient: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
friendId?: string;
|
||||
}
|
||||
|
||||
interface FollowupReminderModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
recipientName?: string;
|
||||
friendId?: string;
|
||||
}
|
||||
|
||||
const FollowupReminderModal: React.FC<FollowupReminderModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
recipientName = "客户",
|
||||
friendId,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [reminders, setReminders] = useState<FollowupReminder[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
|
||||
// 跟进方式选项
|
||||
const followupMethods = [
|
||||
{ value: "1", label: "电话回访" },
|
||||
{ value: "2", label: "发送消息" },
|
||||
{ value: "3", label: "安排会议" },
|
||||
{ value: "4", label: "发送邮件" },
|
||||
{ value: "0", label: "其他" },
|
||||
];
|
||||
|
||||
// 加载跟进提醒列表
|
||||
const loadFollowUpList = useCallback(async () => {
|
||||
if (!friendId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getFollowUpList({
|
||||
friendId,
|
||||
limit: "50",
|
||||
page: "1",
|
||||
});
|
||||
|
||||
if (response && response.list) {
|
||||
const formattedReminders = response.list.map((item: any) => ({
|
||||
id: item.id?.toString() || "",
|
||||
type: typeMap[item.type] || "其他",
|
||||
status: item.isProcess === 1 ? "已完成" : "待处理",
|
||||
content: item.description || item.title || "",
|
||||
scheduledTime: item.reminderTime || "",
|
||||
recipient: recipientName,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
friendId: item.friendId,
|
||||
}));
|
||||
setReminders(formattedReminders);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载跟进提醒列表失败:", error);
|
||||
message.error("加载跟进提醒列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [friendId, recipientName]);
|
||||
|
||||
// 当模态框打开时加载数据
|
||||
useEffect(() => {
|
||||
if (visible && friendId) {
|
||||
loadFollowUpList();
|
||||
}
|
||||
}, [visible, friendId, loadFollowUpList]);
|
||||
|
||||
// 处理添加提醒
|
||||
const handleAddReminder = async () => {
|
||||
if (!friendId) {
|
||||
message.error("缺少好友ID,无法添加提醒");
|
||||
return;
|
||||
}
|
||||
|
||||
setAddLoading(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
const params = {
|
||||
friendId,
|
||||
type: values.method,
|
||||
title: values.content,
|
||||
description: values.content,
|
||||
reminderTime: values.dateTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||
};
|
||||
|
||||
const response = await addFollowUp(params);
|
||||
|
||||
if (response) {
|
||||
message.success("添加跟进提醒成功");
|
||||
form.resetFields();
|
||||
// 重新加载列表
|
||||
loadFollowUpList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("添加跟进提醒失败:", error);
|
||||
message.error("添加跟进提醒失败");
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理跟进提醒
|
||||
const handleProcessReminder = async (id: string) => {
|
||||
try {
|
||||
const response = await processFollowUp({ ids: id });
|
||||
if (response) {
|
||||
message.success("处理成功");
|
||||
// 重新加载列表
|
||||
loadFollowUpList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("处理跟进提醒失败:", error);
|
||||
message.error("处理跟进提醒失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态标签颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "待处理":
|
||||
return "warning";
|
||||
case "已完成":
|
||||
return "success";
|
||||
case "已取消":
|
||||
return "default";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
// 获取类型图标
|
||||
const getTypeIcon = (type: string) => {
|
||||
return type === "电话" ? <PhoneOutlined /> : <MessageOutlined />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>跟进提醒设置</div>
|
||||
<div className={styles.modalSubtitle}>设置客户跟进时间和方式</div>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={480}
|
||||
className={styles.followupModal}
|
||||
>
|
||||
<div className={styles.modalContent}>
|
||||
{/* 添加新提醒区域 */}
|
||||
<div className={styles.addReminderSection}>
|
||||
<Form form={form} layout="vertical" className={styles.reminderForm}>
|
||||
<div className={styles.formRow}>
|
||||
<Form.Item
|
||||
name="method"
|
||||
label="跟进方式"
|
||||
rules={[{ required: true, message: "请选择跟进方式" }]}
|
||||
className={styles.formItem}
|
||||
>
|
||||
<Select placeholder="电话回访" className={styles.selectInput}>
|
||||
{followupMethods.map(method => (
|
||||
<Option key={method.value} value={method.value}>
|
||||
{method.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="dateTime"
|
||||
label="提醒时间"
|
||||
rules={[{ required: true, message: "请选择提醒时间" }]}
|
||||
className={styles.formItem}
|
||||
>
|
||||
<DatePicker
|
||||
showTime
|
||||
format="YYYY/M/D HH:mm"
|
||||
placeholder="年/月/日 --:--"
|
||||
className={styles.dateInput}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="提醒内容"
|
||||
rules={[{ required: true, message: "请输入提醒内容" }]}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="提醒内容..."
|
||||
rows={3}
|
||||
className={styles.contentInput}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddReminder}
|
||||
className={styles.addButton}
|
||||
loading={addLoading}
|
||||
block
|
||||
>
|
||||
添加提醒
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* 现有提醒列表 */}
|
||||
<div className={styles.remindersList}>
|
||||
<List
|
||||
dataSource={reminders}
|
||||
loading={loading}
|
||||
renderItem={reminder => (
|
||||
<List.Item className={styles.reminderItem}>
|
||||
<div className={styles.reminderContent}>
|
||||
<div className={styles.reminderHeader}>
|
||||
<Space>
|
||||
<Tag
|
||||
icon={getTypeIcon(reminder.type)}
|
||||
color="blue"
|
||||
className={styles.typeTag}
|
||||
>
|
||||
{reminder.type}
|
||||
</Tag>
|
||||
<Tag color={getStatusColor(reminder.status)}>
|
||||
{reminder.status}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text className={styles.recipient}>
|
||||
{reminder.recipient}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.reminderBody}>
|
||||
<Text className={styles.reminderText}>
|
||||
{reminder.content}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className={styles.reminderFooter}>
|
||||
<Space>
|
||||
<ClockCircleOutlined className={styles.clockIcon} />
|
||||
<Text className={styles.scheduledTime}>
|
||||
{reminder.scheduledTime}
|
||||
</Text>
|
||||
{reminder.status === "待处理" && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleProcessReminder(reminder.id)}
|
||||
>
|
||||
处理
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowupReminderModal;
|
||||
@@ -32,10 +32,9 @@ import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
import styles from "./Person.module.scss";
|
||||
interface PersonProps {
|
||||
contract: ContractData | weChatGroup;
|
||||
onToggleProfile?: () => void;
|
||||
}
|
||||
|
||||
const Person: React.FC<PersonProps> = ({ contract, onToggleProfile }) => {
|
||||
const Person: React.FC<PersonProps> = ({ contract }) => {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [isEditingRemark, setIsEditingRemark] = useState(false);
|
||||
const [remarkValue, setRemarkValue] = useState(contract.conRemark || "");
|
||||
@@ -504,10 +503,6 @@ const Person: React.FC<PersonProps> = ({ contract, onToggleProfile }) => {
|
||||
chatroomOperateType: 4, // 4 for quit
|
||||
});
|
||||
messageApi.success("已退出群聊");
|
||||
// 可能还需要一个回调来关闭侧边栏或切换到另一个聊天
|
||||
if (onToggleProfile) {
|
||||
onToggleProfile();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,10 +11,9 @@ const { Sider } = Layout;
|
||||
|
||||
interface PersonProps {
|
||||
contract: ContractData | weChatGroup;
|
||||
onToggleProfile?: () => void;
|
||||
}
|
||||
|
||||
const Person: React.FC<PersonProps> = ({ contract, onToggleProfile }) => {
|
||||
const Person: React.FC<PersonProps> = ({ contract }) => {
|
||||
const [activeKey, setActiveKey] = useState("profile");
|
||||
const isGroup = "chatroomId" in contract;
|
||||
console.log(contract);
|
||||
@@ -50,12 +49,7 @@ const Person: React.FC<PersonProps> = ({ contract, onToggleProfile }) => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{activeKey === "profile" && (
|
||||
<ProfileModules
|
||||
contract={contract}
|
||||
onToggleProfile={onToggleProfile}
|
||||
/>
|
||||
)}
|
||||
{activeKey === "profile" && <ProfileModules contract={contract} />}
|
||||
{activeKey === "quickwords" && (
|
||||
<QuickWords
|
||||
words={[]}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// 待办事项模态框样式
|
||||
.todoModal {
|
||||
:global(.ant-modal-header) {
|
||||
border-bottom: none;
|
||||
padding: 16px 20px 0 20px;
|
||||
}
|
||||
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0 20px 20px 20px;
|
||||
max-height: 60vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.ant-modal-close) {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
.modalTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modalSubtitle {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.addTaskSection {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.taskForm {
|
||||
.titleInput,
|
||||
.descriptionInput,
|
||||
.prioritySelect,
|
||||
.dateInput {
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionInput {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.formItem {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
|
||||
:global(.ant-form-item-label) {
|
||||
padding-bottom: 4px;
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.addButton {
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
|
||||
|
||||
&:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.3);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.todoList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
|
||||
// 自定义滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-list) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.ant-list-item) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.todoItem {
|
||||
padding: 10px;
|
||||
|
||||
.todoContent {
|
||||
width: 100%;
|
||||
|
||||
.todoHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.todoCheckbox {
|
||||
margin-right: 8px;
|
||||
|
||||
:global(.ant-checkbox-inner) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.todoTitle {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
|
||||
&.completed {
|
||||
text-decoration: line-through;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.todoDescription {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.descriptionText {
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.todoFooter {
|
||||
.clientInfo {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.priorityTag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dueDate {
|
||||
.calendarIcon {
|
||||
color: #8c8c8c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dueDateText {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.todoModal {
|
||||
:global(.ant-modal) {
|
||||
margin: 0;
|
||||
max-width: 100vw;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.ant-modal-body) {
|
||||
padding: 0 16px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
.addTaskSection {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.taskForm {
|
||||
.formRow {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.todoList {
|
||||
max-height: 150px;
|
||||
|
||||
// 移动端滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
Button,
|
||||
List,
|
||||
Checkbox,
|
||||
Tag,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import { PlusOutlined, CalendarOutlined } from "@ant-design/icons";
|
||||
import { getTodoList, addTodo, processTodo } from "@/pages/pc/ckbox/weChat/api";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
// 优先级映射
|
||||
const priorityMap: { [key: string]: string } = {
|
||||
"0": "低",
|
||||
"1": "中",
|
||||
"2": "高",
|
||||
"3": "紧急",
|
||||
};
|
||||
|
||||
interface TodoItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
client?: string;
|
||||
priority: "高" | "中" | "低" | "紧急";
|
||||
dueDate: string;
|
||||
completed: boolean;
|
||||
friendId?: string;
|
||||
}
|
||||
|
||||
interface TodoListModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
clientName?: string;
|
||||
friendId?: string;
|
||||
}
|
||||
|
||||
const TodoListModal: React.FC<TodoListModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
clientName = "客户",
|
||||
friendId,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [todos, setTodos] = useState<TodoItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
|
||||
// 优先级选项
|
||||
const priorityOptions = [
|
||||
{ value: "2", label: "高优先级", color: "orange" },
|
||||
{ value: "1", label: "中优先级", color: "blue" },
|
||||
{ value: "0", label: "低优先级", color: "green" },
|
||||
{ value: "3", label: "紧急", color: "red" },
|
||||
];
|
||||
|
||||
// 加载待办事项列表
|
||||
const loadTodoList = useCallback(async () => {
|
||||
if (!friendId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getTodoList({
|
||||
friendId,
|
||||
limit: "50",
|
||||
page: "1",
|
||||
});
|
||||
|
||||
if (response && response.list) {
|
||||
const formattedTodos = response.list.map((item: any) => ({
|
||||
id: item.id?.toString() || "",
|
||||
title: item.title || "",
|
||||
description: item.description || "",
|
||||
client: clientName,
|
||||
priority: priorityMap[item.level] || "中",
|
||||
dueDate: item.reminderTime || "",
|
||||
completed: item.isProcess === 1,
|
||||
friendId: item.friendId,
|
||||
}));
|
||||
setTodos(formattedTodos);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载待办事项列表失败:", error);
|
||||
message.error("加载待办事项列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [friendId, clientName]);
|
||||
|
||||
// 当模态框打开时加载数据
|
||||
useEffect(() => {
|
||||
if (visible && friendId) {
|
||||
loadTodoList();
|
||||
}
|
||||
}, [visible, friendId, loadTodoList]);
|
||||
|
||||
// 处理添加任务
|
||||
const handleAddTask = async () => {
|
||||
if (!friendId) {
|
||||
message.error("缺少好友ID,无法添加任务");
|
||||
return;
|
||||
}
|
||||
|
||||
setAddLoading(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
const params = {
|
||||
friendId,
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
level: values.priority,
|
||||
reminderTime: values.dueDate.format("YYYY-MM-DD HH:mm:ss"),
|
||||
};
|
||||
|
||||
const response = await addTodo(params);
|
||||
|
||||
if (response) {
|
||||
message.success("添加待办事项成功");
|
||||
form.resetFields();
|
||||
// 重新加载列表
|
||||
loadTodoList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("添加待办事项失败:", error);
|
||||
message.error("添加待办事项失败");
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理任务完成状态切换
|
||||
const handleToggleComplete = async (id: string) => {
|
||||
try {
|
||||
const response = await processTodo({ ids: id });
|
||||
if (response) {
|
||||
message.success("任务状态更新成功");
|
||||
// 重新加载列表
|
||||
loadTodoList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("更新任务状态失败:", error);
|
||||
message.error("更新任务状态失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 获取优先级标签颜色
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "高":
|
||||
return "orange";
|
||||
case "中":
|
||||
return "blue";
|
||||
case "低":
|
||||
return "green";
|
||||
case "紧急":
|
||||
return "red";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>待办事项清单</div>
|
||||
<div className={styles.modalSubtitle}>管理日常工作任务</div>
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={480}
|
||||
className={styles.todoModal}
|
||||
>
|
||||
<div className={styles.modalContent}>
|
||||
{/* 添加新任务区域 */}
|
||||
<div className={styles.addTaskSection}>
|
||||
<Form form={form} layout="vertical" className={styles.taskForm}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
rules={[{ required: true, message: "请输入任务标题" }]}
|
||||
>
|
||||
<Input placeholder="任务标题..." className={styles.titleInput} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description">
|
||||
<TextArea
|
||||
placeholder="任务描述 (可选)..."
|
||||
rows={2}
|
||||
className={styles.descriptionInput}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<Form.Item
|
||||
name="priority"
|
||||
rules={[{ required: true, message: "请选择优先级" }]}
|
||||
className={styles.formItem}
|
||||
>
|
||||
<Select
|
||||
placeholder="中优先级"
|
||||
className={styles.prioritySelect}
|
||||
>
|
||||
{priorityOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="dueDate"
|
||||
rules={[{ required: true, message: "请选择截止时间" }]}
|
||||
className={styles.formItem}
|
||||
>
|
||||
<DatePicker
|
||||
showTime
|
||||
format="MM/DD HH:mm"
|
||||
placeholder="年/月/日 --:--"
|
||||
className={styles.dateInput}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTask}
|
||||
className={styles.addButton}
|
||||
loading={addLoading}
|
||||
block
|
||||
>
|
||||
添加任务
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className={styles.todoList}>
|
||||
<List
|
||||
dataSource={todos}
|
||||
loading={loading}
|
||||
renderItem={todo => (
|
||||
<List.Item className={styles.todoItem}>
|
||||
<div className={styles.todoContent}>
|
||||
<div className={styles.todoHeader}>
|
||||
<Checkbox
|
||||
checked={todo.completed}
|
||||
onChange={() => handleToggleComplete(todo.id)}
|
||||
className={styles.todoCheckbox}
|
||||
/>
|
||||
<Text
|
||||
className={`${styles.todoTitle} ${todo.completed ? styles.completed : ""}`}
|
||||
>
|
||||
{todo.title}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{todo.description && (
|
||||
<div className={styles.todoDescription}>
|
||||
<Text className={styles.descriptionText}>
|
||||
{todo.description}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.todoFooter}>
|
||||
<Space>
|
||||
<Text className={styles.clientInfo}>
|
||||
客户:{todo.client}
|
||||
</Text>
|
||||
<Tag
|
||||
color={getPriorityColor(todo.priority)}
|
||||
className={styles.priorityTag}
|
||||
>
|
||||
{todo.priority}
|
||||
</Tag>
|
||||
<Space className={styles.dueDate}>
|
||||
<CalendarOutlined className={styles.calendarIcon} />
|
||||
<Text className={styles.dueDateText}>
|
||||
{todo.dueDate}
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoListModal;
|
||||
@@ -3,9 +3,10 @@ import { Layout, Button, Avatar, Space, Tooltip, Dropdown } from "antd";
|
||||
import {
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
InfoCircleOutlined,
|
||||
RobotOutlined,
|
||||
DownOutlined,
|
||||
BellOutlined,
|
||||
CheckSquareOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import styles from "./ChatWindow.module.scss";
|
||||
@@ -13,6 +14,8 @@ import styles from "./ChatWindow.module.scss";
|
||||
import ProfileCard from "./components/ProfileCard";
|
||||
import MessageEnter from "./components/MessageEnter";
|
||||
import MessageRecord from "./components/MessageRecord";
|
||||
import FollowupReminderModal from "./components/FollowupReminderModal";
|
||||
import TodoListModal from "./components/TodoListModal";
|
||||
import { setFriendInjectConfig } from "@/pages/pc/ckbox/weChat/api";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
const { Header, Content } = Layout;
|
||||
@@ -21,6 +24,12 @@ interface ChatWindowProps {
|
||||
contract: ContractData | weChatGroup;
|
||||
}
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 0, label: "人工接待" },
|
||||
{ value: 1, label: "AI辅助" },
|
||||
{ value: 2, label: "AI接管" },
|
||||
];
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
|
||||
const updateAiQuoteMessageContent = useWeChatStore(
|
||||
state => state.updateAiQuoteMessageContent,
|
||||
@@ -29,15 +38,28 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
|
||||
state => state.aiQuoteMessageContent,
|
||||
);
|
||||
const [showProfile, setShowProfile] = useState(true);
|
||||
const [followupModalVisible, setFollowupModalVisible] = useState(false);
|
||||
const [todoModalVisible, setTodoModalVisible] = useState(false);
|
||||
|
||||
const onToggleProfile = () => {
|
||||
setShowProfile(!showProfile);
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 0, label: "人工接待" },
|
||||
{ value: 1, label: "AI辅助" },
|
||||
{ value: 2, label: "AI接管" },
|
||||
];
|
||||
const handleFollowupClick = () => {
|
||||
setFollowupModalVisible(true);
|
||||
};
|
||||
|
||||
const handleFollowupModalClose = () => {
|
||||
setFollowupModalVisible(false);
|
||||
};
|
||||
|
||||
const handleTodoClick = () => {
|
||||
setTodoModalVisible(true);
|
||||
};
|
||||
|
||||
const handleTodoModalClose = () => {
|
||||
setTodoModalVisible(false);
|
||||
};
|
||||
|
||||
const [currentConfig, setCurrentConfig] = useState(
|
||||
typeOptions.find(option => option.value === aiQuoteMessageContent),
|
||||
@@ -107,15 +129,20 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
|
||||
)}
|
||||
|
||||
<Tooltip title="个人资料">
|
||||
<Button
|
||||
onClick={onToggleProfile}
|
||||
type="text"
|
||||
icon={<InfoCircleOutlined />}
|
||||
className={styles.headerButton}
|
||||
/>
|
||||
<Button onClick={onToggleProfile} icon={<UserOutlined />}>
|
||||
客户信息
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Header>
|
||||
<div className={styles.extend}>
|
||||
<Button icon={<BellOutlined />} onClick={handleFollowupClick}>
|
||||
跟进提醒
|
||||
</Button>
|
||||
<Button icon={<CheckSquareOutlined />} onClick={handleTodoClick}>
|
||||
待办事项
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 聊天内容 */}
|
||||
<Content className={styles.chatContent}>
|
||||
@@ -127,10 +154,22 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
|
||||
</Layout>
|
||||
|
||||
{/* 右侧个人资料卡片 */}
|
||||
<ProfileCard
|
||||
contract={contract}
|
||||
showProfile={showProfile}
|
||||
onToggleProfile={onToggleProfile}
|
||||
{showProfile && <ProfileCard contract={contract} />}
|
||||
|
||||
{/* 跟进提醒模态框 */}
|
||||
<FollowupReminderModal
|
||||
visible={followupModalVisible}
|
||||
onClose={handleFollowupModalClose}
|
||||
recipientName={contract.nickname || contract.name}
|
||||
friendId={contract.id?.toString()}
|
||||
/>
|
||||
|
||||
{/* 待办事项模态框 */}
|
||||
<TodoListModal
|
||||
visible={todoModalVisible}
|
||||
onClose={handleTodoModalClose}
|
||||
clientName={contract.nickname || contract.name}
|
||||
friendId={contract.id?.toString()}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,12 @@ import {
|
||||
asyncWeChatGroup,
|
||||
asyncCountLables,
|
||||
useCkChatStore,
|
||||
updateIsLoadWeChat,
|
||||
getIsLoadWeChat,
|
||||
} from "@/store/module/ckchat/ckchat";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { weChatGroupService, contractService } from "@/utils/db";
|
||||
|
||||
import {
|
||||
loginWithToken,
|
||||
@@ -14,38 +18,41 @@ import {
|
||||
getContactList,
|
||||
getGroupList,
|
||||
getAgentList,
|
||||
getLabelsListByGroup,
|
||||
} from "./api";
|
||||
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
import {
|
||||
KfUserListData,
|
||||
ContractData,
|
||||
weChatGroup,
|
||||
} from "@/pages/pc/ckbox/data";
|
||||
|
||||
import { WechatGroup } from "./api";
|
||||
const { login2 } = useUserStore.getState();
|
||||
//获取触客宝基础信息
|
||||
export const chatInitAPIdata = async () => {
|
||||
try {
|
||||
//获取联系人列表
|
||||
const contractList = await getAllContactList();
|
||||
let contractList = [];
|
||||
let groupList = [];
|
||||
|
||||
//获取联系人列表
|
||||
asyncContractList(contractList);
|
||||
if (getIsLoadWeChat()) {
|
||||
//获取联系人列表
|
||||
contractList = await contractService.findAll();
|
||||
//获取群列表
|
||||
groupList = await weChatGroupService.findAll();
|
||||
} else {
|
||||
//获取联系人列表
|
||||
contractList = await getAllContactList();
|
||||
|
||||
//获取群列表
|
||||
const groupList = await getAllGroupList();
|
||||
//获取群列表
|
||||
groupList = await getAllGroupList();
|
||||
|
||||
updateIsLoadWeChat(true);
|
||||
}
|
||||
//获取联系人列表
|
||||
await asyncContractList(contractList);
|
||||
|
||||
await asyncWeChatGroup(groupList);
|
||||
|
||||
// 提取不重复的wechatAccountId组
|
||||
const uniqueWechatAccountIds: number[] = getUniqueWechatAccountIds(
|
||||
contractList,
|
||||
groupList,
|
||||
);
|
||||
|
||||
//获取控制终端列表
|
||||
const kfUserList: KfUserListData[] = await getAgentList();
|
||||
|
||||
@@ -53,8 +60,8 @@ export const chatInitAPIdata = async () => {
|
||||
await asyncKfUserList(kfUserList);
|
||||
|
||||
//获取标签列表
|
||||
// const countLables = await getCountLables();
|
||||
// await asyncCountLables(countLables);
|
||||
const countLables = await getCountLables();
|
||||
await asyncCountLables(countLables);
|
||||
|
||||
//获取消息会话列表并按lastUpdateTime排序
|
||||
const filterUserSessions = contractList?.filter(
|
||||
@@ -121,15 +128,9 @@ export const initSocket = () => {
|
||||
};
|
||||
|
||||
export const getCountLables = async () => {
|
||||
const LablesRes = await Promise.all(
|
||||
[1, 2].map(item =>
|
||||
WechatGroup({
|
||||
groupType: item,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const [friend, group] = LablesRes;
|
||||
const countLables = [
|
||||
const Result = await getLabelsListByGroup({});
|
||||
const LablesRes = Result.list;
|
||||
return [
|
||||
...[
|
||||
{
|
||||
id: 0,
|
||||
@@ -137,8 +138,7 @@ export const getCountLables = async () => {
|
||||
groupType: 2,
|
||||
},
|
||||
],
|
||||
...group,
|
||||
...friend,
|
||||
...LablesRes,
|
||||
...[
|
||||
{
|
||||
id: 0,
|
||||
@@ -147,8 +147,6 @@ export const getCountLables = async () => {
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return countLables;
|
||||
};
|
||||
/**
|
||||
* 根据标签组织联系人
|
||||
|
||||
@@ -2,21 +2,22 @@ import CkboxPage from "@/pages/pc/ckbox";
|
||||
import WeChatPage from "@/pages/pc/ckbox/weChat";
|
||||
import Dashboard from "@/pages/pc/ckbox/dashboard";
|
||||
import PowerCenter from "@/pages/pc/ckbox/powerCenter";
|
||||
import PrecisionSend from "@/pages/pc/ckbox/powerCenter/precision-send";
|
||||
import SopSend from "@/pages/pc/ckbox/powerCenter/sop-send";
|
||||
import MomentsMarketing from "@/pages/pc/ckbox/powerCenter/moments-marketing";
|
||||
import TagManagement from "@/pages/pc/ckbox/powerCenter/tag-management";
|
||||
import CustomerManagement from "@/pages/pc/ckbox/powerCenter/customer-management";
|
||||
import CommunicationRecord from "@/pages/pc/ckbox/powerCenter/communication-record";
|
||||
import ContentManagement from "@/pages/pc/ckbox/powerCenter/content-management";
|
||||
import ContentManagement from "@/pages/pc/ckbox/powerCenter/content-management/index";
|
||||
import AiTraining from "@/pages/pc/ckbox/powerCenter/ai-training";
|
||||
import AutoGreeting from "@/pages/pc/ckbox/powerCenter/auto-greeting";
|
||||
import CommonConfig from "@/pages/pc/ckbox/commonConfig";
|
||||
const ckboxRoutes = [
|
||||
{
|
||||
path: "/pc",
|
||||
element: <CkboxPage />,
|
||||
auth: true,
|
||||
children: [
|
||||
{
|
||||
path: "commonConfig",
|
||||
element: <CommonConfig />,
|
||||
},
|
||||
{
|
||||
path: "dashboard",
|
||||
element: <Dashboard />,
|
||||
@@ -29,22 +30,6 @@ const ckboxRoutes = [
|
||||
path: "powerCenter",
|
||||
element: <PowerCenter />,
|
||||
},
|
||||
{
|
||||
path: "powerCenter/precision-send",
|
||||
element: <PrecisionSend />,
|
||||
},
|
||||
{
|
||||
path: "powerCenter/sop-send",
|
||||
element: <SopSend />,
|
||||
},
|
||||
{
|
||||
path: "powerCenter/moments-marketing",
|
||||
element: <MomentsMarketing />,
|
||||
},
|
||||
{
|
||||
path: "powerCenter/tag-management",
|
||||
element: <TagManagement />,
|
||||
},
|
||||
{
|
||||
path: "powerCenter/customer-management",
|
||||
element: <CustomerManagement />,
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface CkChatState {
|
||||
userInfo: CkUserInfo | null;
|
||||
isLoggedIn: boolean;
|
||||
searchKeyword: string;
|
||||
isLoadWeChat: boolean;
|
||||
getIsLoadWeChat: () => boolean;
|
||||
updateIsLoadWeChat: (isLoadWeChat: boolean) => void;
|
||||
contractList: ContractData[];
|
||||
chatSessions: any[];
|
||||
kfUserList: KfUserListData[];
|
||||
|
||||
@@ -24,16 +24,22 @@ export const useCkChatStore = createPersistStore<CkChatState>(
|
||||
newContractList: [], //联系人分组
|
||||
kfSelected: 0, //选中的客服
|
||||
searchKeyword: "", //搜索关键词
|
||||
isLoadWeChat: false, //是否加载微信
|
||||
getIsLoadWeChat: () => {
|
||||
return useCkChatStore.getState().isLoadWeChat;
|
||||
},
|
||||
updateIsLoadWeChat: (isLoadWeChat: boolean) => {
|
||||
console.log("updateIsLoadWeChat", isLoadWeChat);
|
||||
set({ isLoadWeChat });
|
||||
},
|
||||
//客服列表
|
||||
asyncKfUserList: async data => {
|
||||
set({ kfUserList: data });
|
||||
// await kfUserService.createManyWithServerId(data);
|
||||
},
|
||||
// 获取客服列表
|
||||
getkfUserList: async () => {
|
||||
const state = useCkChatStore.getState();
|
||||
return state.kfUserList;
|
||||
// return await kfUserService.findAll();
|
||||
},
|
||||
// 异步设置标签列表
|
||||
asyncCountLables: async (data: ContactGroupByLabel[]) => {
|
||||
@@ -545,3 +551,7 @@ export const searchContactsAndGroups = () =>
|
||||
export const pinChatSessionToTop = (sessionId: number) =>
|
||||
useCkChatStore.getState().pinChatSessionToTop(sessionId);
|
||||
useCkChatStore.getState().getKfSelectedUser();
|
||||
export const updateIsLoadWeChat = (isLoadWeChat: boolean) =>
|
||||
useCkChatStore.getState().updateIsLoadWeChat(isLoadWeChat);
|
||||
export const getIsLoadWeChat = () =>
|
||||
useCkChatStore.getState().getIsLoadWeChat();
|
||||
|
||||
@@ -315,3 +315,11 @@ button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user