Merge branch 'yongpxu-dev' into develop

This commit is contained in:
超级老白兔
2025-10-14 10:05:32 +08:00
38 changed files with 3945 additions and 1499 deletions

View File

@@ -31,7 +31,7 @@ const defaultForm = {
syncCount: 5,
syncInterval: 30,
syncType: 1, // 1=业务号 2=人设号
accountType: "business" as "business" | "personal", // 仅UI用
accountType: 1, // 仅UI用
enabled: true,
deviceGroups: [] as any[],
contentGroups: [] as any[], // 存完整内容库对象数组
@@ -67,8 +67,8 @@ const NewMomentsSync: React.FC = () => {
endTime: res.timeRange?.end || "23:59",
syncCount: res.config?.syncCount || res.syncCount || 5,
syncInterval: res.config?.syncInterval || res.syncInterval || 30,
syncType: res.accountType === 1 ? 1 : 2,
accountType: res.accountType === 1 ? "business" : "personal",
syncType: res.config?.syncType,
accountType: res.config?.accountType,
enabled: res.status === 1,
deviceGroups: res.config?.deviceGroups || [],
// 关键用id字符串数组回填
@@ -101,11 +101,11 @@ const NewMomentsSync: React.FC = () => {
};
// UI选择账号类型时同步syncType和accountType
const handleAccountTypeChange = (type: "business" | "personal") => {
const handleAccountTypeChange = (type: number) => {
setFormData(prev => ({
...prev,
accountType: type,
syncType: type === "business" ? 1 : 2,
syncType: type,
}));
};
const handleDevicesChange = (devices: DeviceSelectionItem[]) => {
@@ -135,11 +135,11 @@ const NewMomentsSync: React.FC = () => {
const params = {
name: formData.taskName,
deviceGroups: formData.deviceGroups,
contentGroups: formData.contentGroups.map((lib: any) => lib.id),
contentGroups: contentGroupsOptions.map((lib: any) => lib.id),
syncInterval: formData.syncInterval,
syncCount: formData.syncCount,
syncType: formData.syncType, // 账号类型真实传参
accountType: formData.accountType === "business" ? 1 : 2, // 也要传
accountType: formData.accountType, // 也要传
startTime: formData.startTime,
endTime: formData.endTime,
contentTypes: formData.contentTypes,
@@ -227,14 +227,14 @@ const NewMomentsSync: React.FC = () => {
<div className={style.formLabel}></div>
<div className={style.accountTypeRow}>
<button
className={`${style.accountTypeBtn} ${formData.accountType === "business" ? style.accountTypeActive : ""}`}
onClick={() => handleAccountTypeChange("business")}
className={`${style.accountTypeBtn} ${formData.accountType === 1 ? style.accountTypeActive : ""}`}
onClick={() => handleAccountTypeChange(1)}
>
</button>
<button
className={`${style.accountTypeBtn} ${formData.accountType === "personal" ? style.accountTypeActive : ""}`}
onClick={() => handleAccountTypeChange("personal")}
className={`${style.accountTypeBtn} ${formData.accountType === 2 ? style.accountTypeActive : ""}`}
onClick={() => handleAccountTypeChange(2)}
>
</button>

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取群组列表
export function getGroupList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/kefu/content/material/list", params, "GET");
}

View File

@@ -0,0 +1,27 @@
export interface GroupSelectionItem {
id: string;
title: string;
cover?: string;
status: number;
[key: string]: any;
}
// 组件属性接口
export interface GroupSelectionProps {
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: GroupSelectionItem[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
selectionMode?: "multiple" | "single"; // 新增:选择模式,默认为多选
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}

View File

@@ -0,0 +1,206 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.selectedListRow {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
}
.selectedListRowContent {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.selectedListRowContentText {
flex: 1;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.groupList {
flex: 1;
overflow-y: auto;
}
.groupListInner {
border-top: 1px solid #f0f0f0;
}
.groupItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.groupAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.groupDetail {
flex: 1;
}
.groupName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.groupId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.groupOwner {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}

View File

@@ -0,0 +1,138 @@
import React, { useState } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Input } from "antd";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import SelectionPopup from "./selectionPopup";
import { GroupSelectionProps } from "./data";
export default function GroupSelection({
selectedOptions,
onSelect,
onSelectDetail,
placeholder = "选择素材",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
selectionMode = "single", // 默认为多选模式
onConfirm,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
// 删除已选素材
const handleRemoveGroup = (id: string) => {
if (readonly) return;
onSelect(selectedOptions.filter(g => g.id !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setRealVisible(true);
};
// 清空已选择的素材
const handleClear = () => {
if (readonly) return;
onSelect([]);
};
// 获取显示文本
const getDisplayText = () => {
if (selectedOptions.length === 0) return "";
if (selectionMode === "single") {
return selectedOptions[0]?.title || "已选择素材";
}
return `已选择 ${selectedOptions.length} 个素材`;
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly && selectedOptions.length > 0}
onClear={handleClear}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选素材列表窗口 */}
{showSelectedList && selectedOptions.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedOptions.map(group => (
<div key={group.id} className={style.selectedListRow}>
<div className={style.selectedListRowContent}>
<Avatar src={group.cover} />
<div className={style.selectedListRowContentText}>
<div>{group.title}</div>
<div>ID: {group.id}</div>
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveGroup(group.id)}
/>
)}
</div>
</div>
))}
</div>
)}
{/* 弹窗 */}
<SelectionPopup
visible={realVisible}
onVisibleChange={setRealVisible}
selectedOptions={selectedOptions}
onSelect={onSelect}
onSelectDetail={onSelectDetail}
readonly={readonly}
selectionMode={selectionMode}
onConfirm={onConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from "react";
import { Popup, Checkbox, Radio } from "antd-mobile";
import { getGroupList } from "./api";
import style from "./index.module.scss";
import Layout from "@/components/Layout/Layout";
import PopupHeader from "@/components/PopuLayout/header";
import PopupFooter from "@/components/PopuLayout/footer";
import { GroupSelectionItem } from "./data";
// 弹窗属性接口
interface SelectionPopupProps {
visible: boolean;
onVisibleChange: (visible: boolean) => void;
selectedOptions: GroupSelectionItem[];
onSelect: (groups: GroupSelectionItem[]) => void;
onSelectDetail?: (groups: GroupSelectionItem[]) => void;
readonly?: boolean;
selectionMode?: "multiple" | "single"; // 新增:选择模式,默认为多选
onConfirm?: (
selectedIds: string[],
selectedItems: GroupSelectionItem[],
) => void;
}
export default function SelectionPopup({
visible,
onVisibleChange,
selectedOptions,
onSelect,
onSelectDetail,
readonly = false,
selectionMode = "multiple", // 默认为多选模式
onConfirm,
}: SelectionPopupProps) {
const [groups, setGroups] = useState<GroupSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
const [tempSelectedOptions, setTempSelectedOptions] = useState<
GroupSelectionItem[]
>([]);
// 获取素材列表API
const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await getGroupList(params);
if (response && response.list) {
setGroups(response.list);
setTotalGroups(response.total || 0);
setTotalPages(Math.ceil((response.total || 0) / 20));
}
} catch (error) {
console.error("获取素材列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理素材选择
const handleGroupToggle = (group: GroupSelectionItem) => {
if (readonly) return;
if (selectionMode === "single") {
// 单选模式:直接设置为当前选中的项
setTempSelectedOptions([group]);
} else {
// 多选模式:切换选中状态
const newSelectedGroups = tempSelectedOptions.some(g => g.id === group.id)
? tempSelectedOptions.filter(g => g.id !== group.id)
: tempSelectedOptions.concat(group);
setTempSelectedOptions(newSelectedGroups);
}
};
// 全选当前页(仅在多选模式下有效)
const handleSelectAllCurrentPage = (checked: boolean) => {
if (readonly || selectionMode === "single") return;
if (checked) {
// 全选:添加当前页面所有未选中的素材
const currentPageGroups = groups.filter(
group => !tempSelectedOptions.some(g => g.id === group.id),
);
setTempSelectedOptions(prev => [...prev, ...currentPageGroups]);
} else {
// 取消全选:移除当前页面的所有素材
const currentPageGroupIds = groups.map(g => g.id);
setTempSelectedOptions(prev =>
prev.filter(g => !currentPageGroupIds.includes(g.id)),
);
}
};
// 检查当前页是否全选(仅在多选模式下有效)
const isCurrentPageAllSelected =
selectionMode === "multiple" &&
groups.length > 0 &&
groups.every(group => tempSelectedOptions.some(g => g.id === group.id));
// 确认选择
const handleConfirm = () => {
// 用户点击确认时才更新实际的selectedOptions
onSelect(tempSelectedOptions);
// 如果有 onSelectDetail 回调,传递完整的素材对象
if (onSelectDetail) {
const selectedGroupObjs = groups.filter(group =>
tempSelectedOptions.some(g => g.id === group.id),
);
onSelectDetail(selectedGroupObjs);
}
if (onConfirm) {
onConfirm(
tempSelectedOptions.map(g => g.id),
tempSelectedOptions,
);
}
onVisibleChange(false);
};
// 弹窗打开时初始化数据(只执行一次)
useEffect(() => {
if (visible) {
setCurrentPage(1);
setSearchQuery("");
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchGroups(1, "");
}
}, [visible]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
useEffect(() => {
if (!visible || searchQuery === "") return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, visible]);
// 页码变化时请求数据只在弹窗打开且页码不是1时执行
useEffect(() => {
if (!visible) return;
fetchGroups(currentPage, searchQuery);
}, [currentPage, visible, searchQuery]);
return (
<Popup
visible={visible && !readonly}
onMaskClick={() => onVisibleChange(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<Layout
header={
<PopupHeader
title="选择素材"
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchPlaceholder="搜索素材"
loading={loading}
onRefresh={() => fetchGroups(currentPage, searchQuery)}
/>
}
footer={
<PopupFooter
currentPage={currentPage}
totalPages={totalPages}
loading={loading}
selectedCount={tempSelectedOptions.length}
onPageChange={setCurrentPage}
onCancel={() => onVisibleChange(false)}
onConfirm={handleConfirm}
isAllSelected={isCurrentPageAllSelected}
onSelectAll={handleSelectAllCurrentPage}
showSelectAll={selectionMode === "multiple"} // 只在多选模式下显示全选功能
/>
}
>
<div className={style.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : groups.length > 0 ? (
<div className={style.groupListInner}>
{groups.map(group => (
<div key={group.id} className={style.groupItem}>
{selectionMode === "single" ? (
<Radio
checked={tempSelectedOptions.some(g => g.id === group.id)}
onChange={() => !readonly && handleGroupToggle(group)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
) : (
<Checkbox
checked={tempSelectedOptions.some(g => g.id === group.id)}
onChange={() => !readonly && handleGroupToggle(group)}
disabled={readonly}
style={{ marginRight: 12 }}
/>
)}
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{group.cover ? (
<img
src={group.cover}
alt={group.title}
className={style.avatarImg}
/>
) : (
group.title.charAt(0)
)}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>{group.title}</div>
<div className={style.groupOwner}>
: {group.userName}
</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的素材`
: "没有找到素材"}
</div>
</div>
)}
</div>
</Layout>
</Popup>
);
}

View File

@@ -14,6 +14,7 @@ interface PopupFooterProps {
// 全选功能相关
isAllSelected?: boolean;
onSelectAll?: (checked: boolean) => void;
showSelectAll?: boolean; // 新增控制全选功能显示默认为true
}
const PopupFooter: React.FC<PopupFooterProps> = ({
@@ -26,19 +27,22 @@ const PopupFooter: React.FC<PopupFooterProps> = ({
onConfirm,
isAllSelected = false,
onSelectAll,
showSelectAll = true, // 默认为true显示全选功能
}) => {
return (
<>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}>
<Checkbox
checked={isAllSelected}
onChange={e => onSelectAll(e.target.checked)}
className={style.selectAllCheckbox}
>
</Checkbox>
{showSelectAll && (
<Checkbox
checked={isAllSelected}
onChange={e => onSelectAll?.(e.target.checked)}
className={style.selectAllCheckbox}
>
</Checkbox>
)}
</div>
<div className={style.paginationControls}>
<Button

View File

@@ -0,0 +1,16 @@
import request from "@/api/request";
// 消息列表
export const noticeList = (params: { page: number; limit: number }) => {
return request(`/v1/kefu/notice/list`, params, "GET");
};
// 消息列表
export const readMessage = (params: { id: number }) => {
return request(`/v1/kefu/notice/readMessage`, params, "PUT");
};
// 消息列表
export const readAll = () => {
return request(`/v1/kefu/notice/readAll`, undefined, "PUT");
};

View File

@@ -1,16 +1,24 @@
import React, { useState } from "react";
import { Layout, Drawer, Avatar, Space, Button, Badge, Dropdown } from "antd";
import React, { useState, useEffect } from "react";
import {
Layout,
Drawer,
Avatar,
Space,
Button,
Badge,
Dropdown,
Empty,
} from "antd";
import {
BarChartOutlined,
UserOutlined,
BellOutlined,
LogoutOutlined,
UserSwitchOutlined,
ThunderboltOutlined,
SettingOutlined,
WechatOutlined,
} from "@ant-design/icons";
import { noticeList, readMessage, readAll } from "./api";
import { useUserStore } from "@/store/module/user";
import { useNavigate, useLocation } from "react-router-dom";
import styles from "./index.module.scss";
@@ -22,17 +30,42 @@ interface NavCommonProps {
onMenuClick?: () => void;
}
// 消息数据类型
interface MessageItem {
id: number;
type: number;
companyId: number;
userId: number;
bindId: number;
title: string;
message: string;
isRead: number;
createTime: string;
readTime: string;
friendData: {
nickname: string;
avatar: string;
};
}
const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
const [messageDrawerVisible, setMessageDrawerVisible] = useState(false);
const [messageCount] = useState(3); // 模拟消息数量
const [messageList, setMessageList] = useState<MessageItem[]>([]);
const [messageCount, setMessageCount] = useState(0);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useUserStore();
// 初始化时获取消息列表
useEffect(() => {
fetchMessageList();
setInterval(IntervalMessageCount, 30 * 1000);
}, []);
// 处理菜单图标点击:在两个路由之间切换
const handleMenuClick = () => {
const current = location.pathname;
if (current.startsWith("/pc/weChat")) {
const handleMenuClick = (index: number) => {
if (index === 0) {
navigate("/pc/powerCenter");
} else {
navigate("/pc/weChat");
@@ -43,9 +76,41 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
return location.pathname.startsWith("/pc/weChat");
};
// 定时器获取消息条数
const IntervalMessageCount = async () => {
try {
const response = await noticeList({ page: 1, limit: 20 });
if (response && response.noRead) {
setMessageCount(response.noRead);
}
} catch (error) {
console.error("获取消息列表失败:", error);
}
};
// 获取消息列表
const fetchMessageList = async () => {
try {
setLoading(true);
const response = await noticeList({ page: 1, limit: 20 });
if (response && response.list) {
setMessageList(response.list);
// 计算未读消息数量
const unreadCount = response.list.filter(
(item: MessageItem) => item.isRead === 0,
).length;
setMessageCount(unreadCount);
}
} catch (error) {
console.error("获取消息列表失败:", error);
} finally {
setLoading(false);
}
};
// 处理消息中心点击
const handleMessageClick = () => {
setMessageDrawerVisible(true);
fetchMessageList();
};
// 处理消息抽屉关闭
@@ -59,23 +124,78 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
navigate("/login"); // 跳转到登录页面
};
// 处理消息已读
const handleReadMessage = async (messageId: number) => {
try {
await readMessage({ id: messageId }); // 这里需要根据实际API调整参数
// 更新本地状态
setMessageList(prev =>
prev.map(item =>
item.id === messageId ? { ...item, isRead: 1 } : item,
),
);
// 重新计算未读数量
const unreadCount =
messageList.filter(item => item.isRead === 0).length - 1;
setMessageCount(Math.max(0, unreadCount));
} catch (error) {
console.error("标记消息已读失败:", error);
}
};
// 处理全部已读
const handleReadAll = async () => {
try {
await readAll(); // 这里需要根据实际API调整参数
// 更新本地状态
setMessageList(prev => prev.map(item => ({ ...item, isRead: 1 })));
setMessageCount(0);
} catch (error) {
console.error("全部已读失败:", error);
}
};
// 格式化时间
const formatTime = (timeStr: string) => {
const date = new Date(timeStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
} else if (days === 1) {
return "昨天";
} else if (days < 7) {
return `${days}天前`;
} else {
return date.toLocaleDateString("zh-CN", {
month: "2-digit",
day: "2-digit",
});
}
};
// 用户菜单项
const userMenuItems = [
{
key: "userInfo",
label: (
<div style={{ fontWeight: "bold", color: "#188eee" }}>
{user.username}{user.account}
{user.account}
</div>
),
},
{
key: "profile",
icon: <UserSwitchOutlined style={{ fontSize: 16 }} />,
label: "个人资料",
key: "settings",
icon: <SettingOutlined style={{ fontSize: 16 }} />,
label: "全局配置",
onClick: () => {
console.log("个人资料点击");
// TODO: 跳转到个人资料页面
navigate("/pc/commonConfig");
},
},
{
@@ -93,14 +213,14 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
<Button
icon={<BarChartOutlined />}
type={!isWeChat() ? "primary" : "default"}
onClick={handleMenuClick}
onClick={() => handleMenuClick(0)}
>
</Button>
<Button
icon={<WechatOutlined />}
type={isWeChat() ? "primary" : "default"}
onClick={handleMenuClick}
onClick={() => handleMenuClick(1)}
>
Ai智能客服
</Button>
@@ -120,14 +240,7 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
<BellOutlined style={{ fontSize: 20 }} />
</Badge>
</div>
<Button
onClick={() => {
navigate("/pc/commonConfig");
}}
icon={<SettingOutlined />}
>
</Button>
<Dropdown
menu={{ items: userMenuItems }}
placement="bottomRight"
@@ -160,74 +273,57 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
className={styles.messageDrawer}
extra={
<Space>
<Button type="text" size="small">
<Button type="text" size="small" onClick={handleReadAll}>
</Button>
</Space>
}
>
<div className={styles.messageContent}>
<div className={styles.messageItem}>
<div className={styles.messageAvatar}>
<Avatar size={40} style={{ backgroundColor: "#87d068" }}>
</Avatar>
{loading ? (
<div style={{ textAlign: "center", padding: "20px" }}>
...
</div>
<div className={styles.messageInfo}>
<div className={styles.messageTitle}>
<span className={styles.messageType}></span>
<div className={styles.messageStatus}></div>
) : messageList.length === 0 ? (
<Empty description="暂无消息" />
) : (
messageList.map(item => (
<div
key={item.id}
className={`${styles.messageItem} ${
item.isRead === 0 ? styles.unread : ""
}`}
onClick={() => handleReadMessage(item.id)}
>
<div className={styles.messageAvatar}>
<Avatar
size={40}
src={item.friendData?.avatar}
style={{ backgroundColor: "#87d068" }}
>
{item.friendData?.nickname?.charAt(0) || "U"}
</Avatar>
</div>
<div className={styles.messageInfo}>
<div className={styles.messageTitle}>
<span className={styles.messageType}>{item.title}</span>
{item.isRead === 0 && (
<div className={styles.messageStatus}></div>
)}
</div>
<div className={styles.messageText}>{item.message}</div>
{item.isRead === 0 && (
<div className={styles.messageTime}>
{formatTime(item.createTime)}
<Button type="link" size="small">
</Button>
</div>
)}
</div>
</div>
<div className={styles.messageText}>
19991699
</div>
<div className={styles.messageTime}>
03-05
<Button type="link" size="small">
</Button>
</div>
</div>
</div>
<div className={styles.messageItem}>
<div className={styles.messageAvatar}>
<Avatar size={40} style={{ backgroundColor: "#f56a00" }}>
E
</Avatar>
</div>
<div className={styles.messageInfo}>
<div className={styles.messageTitle}>
<span className={styles.messageType}></span>
<div className={styles.messageStatus}></div>
</div>
<div className={styles.messageText}>
Eric在@了您
</div>
<div className={styles.messageTime}>03-05</div>
</div>
</div>
<div className={styles.messageItem}>
<div className={styles.messageAvatar}>
<Avatar size={40} style={{ backgroundColor: "#1890ff" }}>
</Avatar>
</div>
<div className={styles.messageInfo}>
<div className={styles.messageTitle}>
<span className={styles.messageType}></span>
</div>
<div className={styles.messageText}></div>
<div className={styles.messageTime}>03-04</div>
<div className={styles.messageActions}>
<Button type="primary" size="small">
</Button>
<Button size="small"></Button>
</div>
</div>
</div>
))
)}
</div>
</Drawer>
</>

View File

@@ -118,7 +118,7 @@ export function updateSensitiveWord(data: SensitiveWordUpdateRequest) {
// 违禁词管理-修改状态
export function setSensitiveWordStatus(data: SensitiveWordSetStatusRequest) {
return request("/v1/kefu/content/sensitiveWord/setStatus", data, "POST");
return request("/v1/kefu/content/sensitiveWord/setStatus", data, "GET");
}
// 关键词回复管理相关接口
@@ -132,18 +132,19 @@ export interface KeywordAddRequest {
title: string;
keywords: string;
content: string;
matchType: string; // 匹配类型:模糊匹配、精确匹配
priority: string; // 优先级
replyType: string; // 回复类型:文本回复、模板回复
type: number; // 匹配类型:模糊匹配、精确匹配
level: number; // 优先级
replyType: number; // 回复类型:文本回复、模板回复
status: string;
metailGroups: any[];
}
export interface KeywordUpdateRequest extends KeywordAddRequest {
id?: string;
id?: number;
}
export interface KeywordSetStatusRequest {
id: string;
id: number;
}
// 关键词回复-列表
@@ -157,12 +158,12 @@ export function addKeyword(data: KeywordAddRequest) {
}
// 关键词回复-详情
export function getKeywordDetails(id: string) {
export function getKeywordDetails(id: number) {
return request("/v1/kefu/content/keywords/details", { id }, "GET");
}
// 关键词回复-删除
export function deleteKeyword(id: string) {
export function deleteKeyword(id: number) {
return request("/v1/kefu/content/keywords/del", { id }, "DELETE");
}

View File

@@ -4,14 +4,21 @@ import React, {
forwardRef,
useImperativeHandle,
} from "react";
import { Button, Input, Tag, Switch, message } from "antd";
import {
Button,
Input,
Tag,
Switch,
message,
Popconfirm,
Pagination,
} from "antd";
import {
SearchOutlined,
FilterOutlined,
FormOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import styles from "../../index.module.scss";
import styles from "./index.module.scss";
import {
getKeywordList,
deleteKeyword,
@@ -23,15 +30,15 @@ import KeywordModal from "../modals/KeywordModal";
const { Search } = Input;
interface KeywordItem {
id: string;
id?: number;
type: number;
replyType: number;
title: string;
keywords: string;
status: number;
content: string;
matchType: string;
priority: string;
replyType: string;
status: string;
enabled: boolean;
metailGroupsOptions: { title: string; id: number }[];
level: number;
}
const KeywordManagement = forwardRef<any, Record<string, never>>(
@@ -40,41 +47,79 @@ const KeywordManagement = forwardRef<any, Record<string, never>>(
const [keywordsList, setKeywordsList] = useState<KeywordItem[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
const [editingKeywordId, setEditingKeywordId] = useState<string | null>(
const [editingKeywordId, setEditingKeywordId] = useState<number | null>(
null,
);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
// 已提交的搜索关键词(仅在点击搜索时更新,用于服务端查询)
const [keywordQuery, setKeywordQuery] = useState<string>("");
//匹配类型
const getMatchTypeText = (type: number) => {
switch (type) {
case 0:
return "模糊匹配";
case 1:
return "精确匹配";
}
};
//匹配优先级
const getPriorityText = (level: number) => {
switch (level) {
case 0:
return "低优先级";
case 1:
return "中优先级";
case 2:
return "高优先级";
}
};
// 回复类型映射
const getReplyTypeText = (replyType: string) => {
const getReplyTypeText = (replyType: number) => {
switch (replyType) {
case "text":
return "文本回复";
case "template":
return "模板回复";
case 0:
return "素材回复";
case 1:
return "自定义";
default:
return "未知类型";
}
};
// 回复类型颜色
const getReplyTypeColor = (replyType: string) => {
const getReplyTypeColor = (replyType: number) => {
switch (replyType) {
case "text":
return "#1890ff";
case "template":
return "#722ed1";
case 0:
return "blue";
case 1:
return "purple";
default:
return "#8c8c8c";
return "gray";
}
};
// 获取关键词列表
// 获取关键词列表(服务端搜索)
const fetchKeywords = async (params?: KeywordListParams) => {
try {
setLoading(true);
const response = await getKeywordList(params || {});
const requestParams = {
page: pagination.current.toString(),
limit: pagination.pageSize.toString(),
keyword: keywordQuery || undefined,
...params,
} as KeywordListParams;
const response = await getKeywordList(requestParams);
if (response) {
setKeywordsList(response.list || []);
setPagination(prev => ({
...prev,
total: response.total || 0,
}));
} else {
setKeywordsList([]);
message.error(response?.message || "获取关键词列表失败");
@@ -94,26 +139,24 @@ const KeywordManagement = forwardRef<any, Record<string, never>>(
}));
// 关键词管理相关函数
const handleToggleKeyword = async (id: string) => {
const handleToggleKeyword = async (id: number) => {
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 || "状态更新失败");
}
await setKeywordStatus({ id });
setKeywordsList(prev =>
prev.map(item =>
item.id === id
? { ...item, status: item.status === 1 ? 0 : 1 }
: item,
),
);
message.success("状态更新成功");
} catch (error) {
console.error("状态更新失败:", error);
message.error("状态更新失败");
}
};
const handleEditKeyword = (id: string) => {
const handleEditKeyword = (id: number) => {
setEditingKeywordId(id);
setEditModalVisible(true);
};
@@ -123,40 +166,39 @@ const KeywordManagement = forwardRef<any, Record<string, never>>(
fetchKeywords(); // 重新获取数据
};
const handleDeleteKeyword = async (id: string) => {
const handleDeleteKeyword = async (id: number) => {
try {
const response = await deleteKeyword(id);
if (response) {
setKeywordsList(prev => prev.filter(item => item.id !== id));
message.success("删除成功");
} else {
message.error(response?.message || "删除失败");
}
await deleteKeyword(id);
setKeywordsList(prev => prev.filter(item => item.id !== id));
message.success("删除成功");
} 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())
);
});
// 移除本地筛选,改为服务端搜索,列表直接使用 keywordsList
// 搜索处理函数
const handleSearch = (value: string) => {
fetchKeywords({ keyword: value });
setKeywordQuery(value || "");
setPagination(prev => ({ ...prev, current: 1 }));
};
// 组件挂载时获取数据
// 分页处理函数
const handlePageChange = (page: number, pageSize?: number) => {
setPagination(prev => ({
...prev,
current: page,
pageSize: pageSize || prev.pageSize,
}));
};
// 初始化与依赖变化时获取数据(依赖分页与搜索关键字)
useEffect(() => {
fetchKeywords();
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination.current, pagination.pageSize, keywordQuery]);
return (
<div className={styles.keywordContent}>
@@ -169,59 +211,92 @@ const KeywordManagement = forwardRef<any, Record<string, never>>(
style={{ width: 300 }}
prefix={<SearchOutlined />}
/>
<Button icon={<FilterOutlined />}></Button>
</div>
<div className={styles.keywordList}>
{loading ? (
<div className={styles.loading}>...</div>
) : filteredKeywords.length === 0 ? (
) : keywordsList.length === 0 ? (
<div className={styles.empty}></div>
) : (
filteredKeywords.map(item => (
keywordsList.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 className={styles.leftSection}>
<div className={styles.titleRow}>
<div className={styles.title}>{item.title}</div>
<Tag color="default">{getMatchTypeText(item.type)}</Tag>
<Tag color="default">{getPriorityText(item.level)}</Tag>
</div>
{item.content.length ? (
<div className={styles.description}>{item.content}</div>
) : (
<div className={styles.description}>
{item.metailGroupsOptions.map(v => (
<Tag color="success" key={v.id}>
{v.title}
</Tag>
))}
</div>
)}
<div className={styles.footer}>
<Tag color={getReplyTypeColor(item.replyType)}>
{getReplyTypeText(item.replyType)}
</Tag>
</div>
</div>
<div className={styles.rightSection}>
<Switch
checked={item.status === 1}
onChange={() => handleToggleKeyword(item.id)}
className={styles.toggleSwitch}
/>
<Button
type="text"
size="small"
icon={<FormOutlined className={styles.editIcon} />}
onClick={() => handleEditKeyword(item.id)}
className={styles.actionBtn}
/>
<Popconfirm
title="确认删除"
description="确定要删除这个关键词吗?删除后无法恢复。"
onConfirm={() => handleDeleteKeyword(item.id)}
okText="确定"
cancelText="取消"
okType="danger"
>
<Button
type="text"
size="small"
icon={<DeleteOutlined className={styles.deleteIcon} />}
className={styles.actionBtn}
/>
</Popconfirm>
</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>
{/* 分页组件 */}
<div style={{ marginTop: 16, textAlign: "right" }}>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
showSizeChanger
showQuickJumper
showTotal={(total, range) =>
`${range[0]}-${range[1]} 条/共 ${total}`
}
onChange={handlePageChange}
onShowSizeChange={handlePageChange}
/>
</div>
{/* 编辑弹窗 */}
<KeywordModal
visible={editModalVisible}

View File

@@ -3,8 +3,9 @@ import React, {
useEffect,
forwardRef,
useImperativeHandle,
useRef,
} from "react";
import { Button, Input, Card, message, Modal } from "antd";
import { Button, Input, Card, message, Popconfirm, Pagination } from "antd";
import {
SearchOutlined,
FilterOutlined,
@@ -49,6 +50,15 @@ const MaterialManagement = forwardRef<any, Record<string, never>>(
const [editingMaterialId, setEditingMaterialId] = useState<number | null>(
null,
);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
// 使用 ref 来存储最新的分页状态
const paginationRef = useRef(pagination);
paginationRef.current = pagination;
// 获取类型图标
const getTypeIcon = (type: string) => {
@@ -68,9 +78,19 @@ const MaterialManagement = forwardRef<any, Record<string, never>>(
const fetchMaterials = async (params?: MaterialListParams) => {
try {
setLoading(true);
const response = await getMaterialList(params || {});
const currentPagination = paginationRef.current;
const requestParams = {
page: currentPagination.current.toString(),
limit: currentPagination.pageSize.toString(),
...params,
};
const response = await getMaterialList(requestParams);
if (response) {
setMaterialsList(response.list || []);
setPagination(prev => ({
...prev,
total: response.total || 0,
}));
} else {
setMaterialsList([]);
message.error(response?.message || "获取素材列表失败");
@@ -91,22 +111,13 @@ const MaterialManagement = forwardRef<any, Record<string, never>>(
// 素材管理相关函数
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("删除失败");
}
},
});
try {
await deleteMaterial(id.toString());
setMaterialsList(prev => prev.filter(item => item.id !== id));
message.success("删除成功");
} catch (error) {
message.error("删除失败");
}
};
// 编辑素材
@@ -122,9 +133,23 @@ const MaterialManagement = forwardRef<any, Record<string, never>>(
// 搜索处理函数
const handleSearch = (value: string) => {
setPagination(prev => ({ ...prev, current: 1 }));
fetchMaterials({ keyword: value });
};
// 分页处理函数
const handlePageChange = (page: number, pageSize?: number) => {
setPagination(prev => ({
...prev,
current: page,
pageSize: pageSize || prev.pageSize,
}));
// 分页变化后立即获取数据
setTimeout(() => {
fetchMaterials();
}, 0);
};
// 组件挂载时获取数据
useEffect(() => {
fetchMaterials();
@@ -167,18 +192,24 @@ const MaterialManagement = forwardRef<any, Record<string, never>>(
>
</Button>,
<Button
<Popconfirm
key="delete"
type="text"
danger
icon={<DeleteOutlined />}
onClick={e => {
e.stopPropagation();
handleDeleteMaterial(item.id);
}}
title="确认删除"
description="确定要删除这个素材吗?删除后无法恢复。"
onConfirm={() => handleDeleteMaterial(item.id)}
okText="确定"
cancelText="取消"
okType="danger"
>
</Button>,
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={e => e.stopPropagation()}
>
</Button>
</Popconfirm>,
]}
>
<div
@@ -252,6 +283,22 @@ const MaterialManagement = forwardRef<any, Record<string, never>>(
)}
</div>
{/* 分页组件 */}
<div style={{ marginTop: 16, textAlign: "right" }}>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
showSizeChanger
showQuickJumper
showTotal={(total, range) =>
`${range[0]}-${range[1]} 条/共 ${total}`
}
onChange={handlePageChange}
onShowSizeChange={handlePageChange}
/>
</div>
{/* 编辑弹窗 */}
<MaterialModal
visible={editModalVisible}

View File

@@ -1,5 +1,18 @@
import React, { useState, useEffect } from "react";
import { Button, Input, Tag, Switch, message } from "antd";
import React, {
useState,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import {
Button,
Input,
Tag,
Switch,
message,
Popconfirm,
Pagination,
} from "antd";
import {
SearchOutlined,
FilterOutlined,
@@ -22,211 +35,259 @@ interface SensitiveWordItem {
title: string;
keywords: string;
content: string;
operation: string;
status: string;
enabled: boolean;
operation: number;
status: number;
}
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 SensitiveWordManagement = forwardRef<any, Record<string, never>>(
(props, ref) => {
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 [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const getTagColor = (tag: string) => {
switch (tag) {
case "政治":
return "#ff4d4f";
case "色情":
return "#ff4d4f";
case "暴力":
return "#ff4d4f";
default:
return "#ff4d4f";
}
};
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 getOperationText = (operation: number) => {
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 {
// 获取敏感词列表
const fetchSensitiveWords = async (params?: SensitiveWordListParams) => {
try {
setLoading(true);
const requestParams = {
page: pagination.current.toString(),
limit: pagination.pageSize.toString(),
...params,
};
const response = await getSensitiveWordList(requestParams);
if (response) {
setSensitiveWordsList(response.list || []);
setPagination(prev => ({
...prev,
total: response.total || 0,
}));
} else {
setSensitiveWordsList([]);
message.error(response?.message || "获取敏感词列表失败");
}
} catch (error) {
console.error("获取敏感词列表失败:", error);
setSensitiveWordsList([]);
message.error(response?.message || "获取敏感词列表失败");
message.error("获取敏感词列表失败");
} finally {
setLoading(false);
}
} 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 || "状态更新失败");
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
fetchSensitiveWords,
}));
// 敏感词管理相关函数
const handleToggleSensitiveWord = async (id: string) => {
try {
const response = await setSensitiveWordStatus({ id });
if (response) {
setSensitiveWordsList(prev =>
prev.map(item =>
item.id === id
? { ...item, status: item.status === 1 ? 0 : 1 }
: item,
),
);
message.success("状态更新成功");
} else {
message.error(response?.message || "状态更新失败");
}
} catch (error) {
console.error("状态更新失败:", error);
message.error("状态更新失败");
}
} catch (error) {
console.error("状态更新失败:", error);
message.error("状态更新失败");
}
};
};
const handleEditSensitiveWord = (id: string) => {
setEditingSensitiveWordId(id);
setEditModalVisible(true);
};
const handleEditSensitiveWord = (id: string) => {
setEditingSensitiveWordId(id);
setEditModalVisible(true);
};
// 编辑弹窗成功回调
const handleEditSuccess = () => {
fetchSensitiveWords(); // 重新获取数据
};
// 编辑弹窗成功回调
const handleEditSuccess = () => {
fetchSensitiveWords(); // 重新获取数据
};
const handleDeleteSensitiveWord = async (id: string) => {
try {
const response = await deleteSensitiveWord(id);
if (response) {
const handleDeleteSensitiveWord = async (id: string) => {
try {
await deleteSensitiveWord(id);
setSensitiveWordsList(prev => prev.filter(item => item.id !== id));
message.success("删除成功");
} else {
message.error(response?.message || "删除失败");
} catch (error) {
console.error("删除失败:", error);
message.error("删除失败");
}
} 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) => {
setPagination(prev => ({ ...prev, current: 1 }));
fetchSensitiveWords({ keyword: value });
};
// 分页处理函数
const handlePageChange = (page: number, pageSize?: number) => {
setPagination(prev => ({
...prev,
current: page,
pageSize: pageSize || prev.pageSize,
}));
};
// 组件挂载和分页变化时获取数据
useEffect(() => {
fetchSensitiveWords();
}, [pagination.current, pagination.pageSize]);
// 搜索和筛选功能
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())
);
});
<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>
// 搜索处理函数
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 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>
<div className={styles.actionText}>
{getOperationText(item.operation)}
</div>
</div>
<div className={styles.itemActions}>
<Switch
checked={item.status == 1}
onChange={() => handleToggleSensitiveWord(item.id)}
className={styles.toggleSwitch}
/>
<Button
type="text"
size="small"
icon={<FormOutlined className={styles.editIcon} />}
onClick={() => handleEditSensitiveWord(item.id)}
className={styles.actionBtn}
/>
<Popconfirm
title="确认删除"
description="确定要删除这个敏感词吗?删除后无法恢复。"
onConfirm={() => handleDeleteSensitiveWord(item.id)}
okText="确定"
cancelText="取消"
okType="danger"
>
<Button
type="text"
size="small"
icon={<DeleteOutlined className={styles.deleteIcon} />}
className={styles.actionBtn}
/>
</Popconfirm>
</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>
))
)}
</div>
{/* 编辑弹窗 */}
<SensitiveWordModal
visible={editModalVisible}
mode="edit"
sensitiveWordId={editingSensitiveWordId}
onCancel={() => {
setEditModalVisible(false);
setEditingSensitiveWordId(null);
}}
onSuccess={handleEditSuccess}
/>
</div>
);
};
{/* 分页组件 */}
<div style={{ marginTop: 16, textAlign: "right" }}>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
showSizeChanger
showQuickJumper
showTotal={(total, range) =>
`${range[0]}-${range[1]} 条/共 ${total}`
}
onChange={handlePageChange}
onShowSizeChange={handlePageChange}
/>
</div>
{/* 编辑弹窗 */}
<SensitiveWordModal
visible={editModalVisible}
mode="edit"
sensitiveWordId={editingSensitiveWordId}
onCancel={() => {
setEditModalVisible(false);
setEditingSensitiveWordId(null);
}}
onSuccess={handleEditSuccess}
/>
</div>
);
},
);
SensitiveWordManagement.displayName = "SensitiveWordManagement";
export default SensitiveWordManagement;

View File

@@ -0,0 +1,252 @@
// 关键词管理样式
.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 {
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;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
.leftSection {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
.titleRow {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
.title {
font-size: 16px;
font-weight: 500;
color: #262626;
margin: 0;
}
.tags {
display: flex;
gap: 8px;
.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: 20px;
background: #fff;
color: #fff;
}
}
.rightSection {
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;
}
}
}
}
}
}
// 响应式设计
@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 {
.itemContent {
flex-direction: column;
gap: 12px;
.leftSection {
.titleRow {
flex-direction: column;
align-items: flex-start;
gap: 8px;
.tags {
flex-wrap: wrap;
}
}
}
.rightSection {
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
}
}
}
}

View File

@@ -7,14 +7,14 @@ import {
type KeywordAddRequest,
type KeywordUpdateRequest,
} from "../../api";
import MetailSelection from "@/components/MetailSelection";
const { TextArea } = Input;
const { Option } = Select;
interface KeywordModalProps {
visible: boolean;
mode: "add" | "edit";
keywordId?: string | null;
keywordId?: number | null;
onCancel: () => void;
onSuccess: () => void;
}
@@ -28,10 +28,12 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const title = mode === "add" ? "添加关键词回复" : "编辑关键词回复";
const [selectedOptions, setSelectedOptions] = useState<any[]>([]);
// 获取关键词详情
const fetchKeywordDetails = useCallback(
async (id: string) => {
async (id: number) => {
try {
const response = await getKeywordDetails(id);
if (response) {
@@ -40,11 +42,13 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
title: keyword.title,
keywords: keyword.keywords,
content: keyword.content,
matchType: keyword.matchType,
priority: keyword.priority,
type: keyword.type,
level: keyword.level,
replyType: keyword.replyType,
status: keyword.status,
metailGroups: keyword.metailGroups,
});
setSelectedOptions(keyword.metailGroupsOptions);
}
} catch (error) {
console.error("获取关键词详情失败:", error);
@@ -63,6 +67,7 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
} else if (mode === "add") {
// 添加模式:重置表单
form.resetFields();
setSelectedOptions([]);
}
}
}, [visible, mode, keywordId, fetchKeywordDetails, form]);
@@ -76,10 +81,11 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
title: values.title,
keywords: values.keywords,
content: values.content,
matchType: values.matchType,
priority: values.priority,
type: values.type,
level: values.level,
replyType: values.replyType,
status: values.status || "1",
metailGroups: values.metailGroups,
};
const response = await addKeyword(data);
@@ -97,10 +103,11 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
title: values.title,
keywords: values.keywords,
content: values.content,
matchType: values.matchType,
priority: values.priority,
type: values.type,
level: values.level,
replyType: values.replyType,
status: values.status,
metailGroups: values.metailGroups,
};
const response = await updateKeyword(data);
@@ -123,11 +130,42 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
const handleCancel = () => {
form.resetFields();
setSelectedOptions([]);
onCancel();
};
const title = mode === "add" ? "添加关键词回复" : "编辑关键词回复";
const handSelectMaterial = (options: any[]) => {
if (options.length === 0) {
form.setFieldsValue({
metailGroups: [],
});
} else {
// 在单选模式下只取第一个选项的ID
form.setFieldsValue({
metailGroups: options.map(v => v.id),
});
}
setSelectedOptions(options);
};
// 监听表单值变化
const handleFormValuesChange = (changedValues: any) => {
// 当回复类型切换时,清空素材选择
if (changedValues.replyType !== undefined) {
setSelectedOptions([]);
if (changedValues.replyType === 1) {
// 切换到自定义回复时清空materialId
form.setFieldsValue({
materialId: null,
});
} else {
// 切换到素材回复时清空content
form.setFieldsValue({
content: null,
});
}
}
};
return (
<Modal
title={title}
@@ -140,11 +178,12 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={handleFormValuesChange}
initialValues={{
status: "1",
matchType: "模糊匹配",
priority: "1",
replyType: "text",
status: 1,
type: "模糊匹配",
level: 1,
replyType: 0,
}}
>
<Form.Item
@@ -163,47 +202,60 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
<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>
<Option value={0}></Option>
<Option value={1}></Option>
</Select>
</Form.Item>
{form.getFieldValue("replyType") === 1 ? (
<Form.Item
name="content"
label="回复内容"
rules={[{ required: true, message: "请输入回复内容" }]}
>
<TextArea rows={4} placeholder="请输入回复内容" />
</Form.Item>
) : (
<Form.Item
name="metailGroups"
label="回复内容"
rules={[{ required: true, message: "请输入回复内容" }]}
>
<MetailSelection
selectedOptions={selectedOptions}
onSelect={handSelectMaterial}
selectionMode="single"
placeholder="选择素材"
/>
</Form.Item>
)}
<Form.Item
name="type"
label="匹配类型"
rules={[{ required: true, message: "请选择匹配类型" }]}
>
<Select placeholder="请选择匹配类型">
<Option value={0}></Option>
<Option value={1}></Option>
</Select>
</Form.Item>
<Form.Item
name="level"
label="优先级"
rules={[{ required: true, message: "请选择优先级" }]}
>
<Select placeholder="请选择优先级">
<Option value={0}></Option>
<Option value={1}></Option>
<Option value={2}></Option>
</Select>
</Form.Item>
@@ -213,8 +265,8 @@ const KeywordModal: React.FC<KeywordModalProps> = ({
rules={[{ required: true, message: "请选择状态" }]}
>
<Select placeholder="请选择状态">
<Option value="1"></Option>
<Option value="0"></Option>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { Modal, Form, Input, Button, message, Select } from "antd";
import {
addSensitiveWord,
@@ -30,34 +30,40 @@ const SensitiveWordModal: React.FC<SensitiveWordModalProps> = ({
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,
});
const fetchSensitiveWordDetails = useCallback(
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("获取敏感词详情失败");
}
} catch (error) {
console.error("获取敏感词详情失败:", error);
message.error("获取敏感词详情失败");
}
};
},
[form],
);
// 当弹窗打开且为编辑模式时,获取详情
// 当弹窗打开时处理数据
useEffect(() => {
if (visible && mode === "edit" && sensitiveWordId) {
fetchSensitiveWordDetails(sensitiveWordId);
} else if (visible && mode === "add") {
// 添加模式时重置表单
form.resetFields();
if (visible) {
if (mode === "edit" && sensitiveWordId) {
// 编辑模式:获取详情
fetchSensitiveWordDetails(sensitiveWordId);
} else if (mode === "add") {
// 添加模式:重置表单
form.resetFields();
}
}
}, [visible, mode, sensitiveWordId]);
}, [visible, mode, sensitiveWordId, fetchSensitiveWordDetails, form]);
const handleSubmit = async (values: any) => {
try {
@@ -160,11 +166,11 @@ const SensitiveWordModal: React.FC<SensitiveWordModalProps> = ({
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>
<Option value={0}></Option>
<Option value={1}></Option>
<Option value={2}></Option>
<Option value={3}></Option>
<Option value={4}></Option>
</Select>
</Form.Item>
@@ -174,8 +180,8 @@ const SensitiveWordModal: React.FC<SensitiveWordModalProps> = ({
rules={[{ required: true, message: "请选择状态" }]}
>
<Select placeholder="请选择状态">
<Option value="1"></Option>
<Option value="0"></Option>
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>

View File

@@ -291,133 +291,6 @@
}
}
// 关键词管理样式
.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 {
@@ -525,82 +398,4 @@
}
}
}
.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;
}
}
}
}

View File

@@ -22,6 +22,7 @@ const ContentManagement: React.FC = () => {
// 引用管理组件
const materialManagementRef = useRef<any>(null);
const keywordManagementRef = useRef<any>(null);
const sensitiveWordManagementRef = useRef<any>(null);
const tabs = [
{ key: "material", label: "素材资源库" },
@@ -44,12 +45,20 @@ const ContentManagement: React.FC = () => {
// 弹窗成功回调
const handleModalSuccess = () => {
console.log("handleModalSuccess");
// 刷新素材列表
if (materialManagementRef.current?.fetchMaterials) {
console.log("刷新素材列表");
materialManagementRef.current.fetchMaterials();
}
// 刷新敏感词列表
if (sensitiveWordManagementRef.current?.fetchSensitiveWords) {
console.log("刷新敏感词列表");
sensitiveWordManagementRef.current.fetchSensitiveWords();
}
// 刷新关键词列表
if (keywordManagementRef.current?.fetchKeywords) {
console.log("刷新关键词列表");
keywordManagementRef.current.fetchKeywords();
}
};
@@ -61,7 +70,12 @@ const ContentManagement: React.FC = () => {
<MaterialManagement ref={materialManagementRef} {...({} as any)} />
);
case "sensitive":
return <SensitiveWordManagement />;
return (
<SensitiveWordManagement
ref={sensitiveWordManagementRef}
{...({} as any)}
/>
);
case "keyword":
return (
<KeywordManagement ref={keywordManagementRef} {...({} as any)} />
@@ -89,12 +103,12 @@ const ContentManagement: React.FC = () => {
>
</Button>
<Button icon={<PlusOutlined />} onClick={handleAddKeyword}>
</Button>
<Button icon={<PlusOutlined />} onClick={handleAddSensitiveWord}>
</Button>
<Button icon={<PlusOutlined />} onClick={handleAddKeyword}>
</Button>
</div>
}
/>
@@ -136,6 +150,7 @@ const ContentManagement: React.FC = () => {
<KeywordModal
visible={keywordModalVisible}
mode="add"
keywordId={null}
onCancel={() => setKeywordModalVisible(false)}
onSuccess={handleModalSuccess}
/>

View File

@@ -114,9 +114,9 @@
// 标签页
.tabs {
padding: 20px;
display: flex;
gap: 0;
border-bottom: 1px solid #f0f0f0;
gap: 10px;
.tab {
padding: 12px 24px;
@@ -127,15 +127,6 @@
color: #8c8c8c;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: #1890ff;
}
&.activeTab {
color: #1890ff;
border-bottom-color: #1890ff;
}
}
}

View File

@@ -1,13 +1,8 @@
import React, { useState, useEffect } from "react";
import PowerNavigation from "@/components/PowerNavtion";
import {
SearchOutlined,
FilterOutlined,
MessageOutlined,
PhoneOutlined,
} from "@ant-design/icons";
import { SearchOutlined, FilterOutlined } from "@ant-design/icons";
import styles from "./index.module.scss";
import { Button, Input, Row, Col, Pagination, Spin, message } from "antd";
import { Button, Input, Table, message } from "antd";
import { getContactList } from "@/pages/pc/ckbox/weChat/api";
import { ContractData } from "@/pages/pc/ckbox/data";
import Layout from "@/components/Layout/LayoutFiexd";
@@ -196,132 +191,129 @@ const CustomerManagement: React.FC = () => {
</Button>
</div>
{/* 标签 */}
{/* 标签按钮组 */}
<div className={styles.tabs}>
{tabs.map(tab => (
<button
<Button
key={tab.key}
className={`${styles.tab} ${activeTab === tab.key ? styles.activeTab : ""}`}
type={activeTab === tab.key ? "primary" : "default"}
onClick={() => setActiveTab(tab.key)}
>
{tab.label} ({tab.count})
</button>
{tab.label}
<span style={{ marginLeft: 6, opacity: 0.85 }}>
{tab.count}
</span>
</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>
}
footer={null}
>
<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>
{/* 联系人表 */}
<Table
rowKey={(record: any) => record.id || record.serverId}
loading={loading}
dataSource={filteredContacts as any}
columns={[
{
title: "客户姓名",
key: "name",
render: (_: any, record: any) => {
const displayName =
record.conRemark ||
record.nickname ||
record.alias ||
"未知用户";
return (
<div className={styles.contactInfo}>
<Avatar
name={displayName}
avatar={record.avatar}
size={40}
/>
<div className={styles.nameSection}>
<h3 className={styles.contactName}>{displayName}</h3>
<p className={styles.roleCompany}>
· {record.desc || "未设置公司"}
</p>
</div>
</Col>
))}
</Row>
</>
)}
</div>
</div>
);
},
},
{
title: "RFM评分",
dataIndex: "rfmScore",
key: "rfmScore",
width: 100,
render: (val: any) => val ?? "-",
},
{
title: "电话",
dataIndex: "phone",
key: "phone",
width: 180,
render: (val: string) => val || "未设置电话",
},
{
title: "微信号",
dataIndex: "wechatId",
key: "wechatId",
width: 200,
},
{
title: "地址",
key: "address",
ellipsis: true,
render: (_: any, record: any) =>
record.region || record.city || "未设置地区",
},
{
title: "标签",
key: "labels",
render: (_: any, record: any) => (
<div className={styles.tags}>
{(record?.labels || []).map(
(tag: string, index: number) => (
<span key={index} className={styles.tag}>
{tag}
</span>
),
)}
</div>
),
},
{
title: "操作",
key: "action",
width: 120,
render: () => (
<Button type="primary" size="small">
</Button>
),
},
]}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) =>
`${range[0]}-${range[1]} 条,共 ${total}`,
pageSizeOptions: ["6", "12", "24", "48"],
}}
onChange={(pager: any) => {
const nextCurrent = pager.current || 1;
const nextSize = pager.pageSize || pagination.pageSize;
loadContacts(nextCurrent, nextSize);
}}
/>
</div>
</div>
</Layout>

View File

@@ -1,13 +1,4 @@
import {
AimOutlined,
ThunderboltOutlined,
RiseOutlined,
TeamOutlined,
CommentOutlined,
FileTextOutlined,
SoundOutlined,
EditOutlined,
} from "@ant-design/icons";
import { TeamOutlined, CommentOutlined, BookOutlined } from "@ant-design/icons";
// 数据类型定义
export interface FeatureCard {
@@ -16,89 +7,97 @@ export interface FeatureCard {
description: string;
icon: React.ReactNode;
color: string;
tag: string;
features: string[];
path?: string;
isNew?: boolean;
isHot?: boolean;
}
export interface FeatureCategory {
export interface KPIData {
id: string;
title: string;
icon: React.ReactNode;
color: string;
count: number;
features: FeatureCard[];
value: string;
label: string;
trend?: {
icon: string;
text: string;
};
}
// 功能数据
export const featureCategories: FeatureCategory[] = [
// 功能数据 - 匹配图片中的三个核心模块
export const featureCategories: FeatureCard[] = [
{
id: "core",
title: "核心功能",
icon: <AimOutlined style={{ fontSize: "24px" }} />,
id: "customer-management",
title: "客户好友管理",
description: "管理客户关系,维护好友信息,查看沟通记录,提升客户满意度",
icon: <TeamOutlined style={{ fontSize: "32px", color: "#1890ff" }} />,
color: "#1890ff",
count: 2,
tag: "核心功能",
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",
},
"RFM价值评分系统",
"多维度精准筛选",
"完整聊天记录查看",
"客户详情页面",
],
path: "/pc/powerCenter/customer-management",
},
{
id: "ai",
title: "AI智能功能",
icon: <ThunderboltOutlined style={{ fontSize: "24px" }} />,
id: "ai-reception",
title: "AI接待设置",
description: "配置AI自动回复,智能推送策略,提升接待效率和客户体验",
icon: <CommentOutlined style={{ fontSize: "32px", color: "#722ed1" }} />,
color: "#722ed1",
count: 2,
tag: "AI智能",
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",
},
"自动欢迎语设置",
"AI智能推送策略",
"标签化精准推送",
"接待模式切换",
],
path: "/pc/powerCenter/ai-reception",
},
{
id: "marketing",
title: "营销管理",
icon: <RiseOutlined style={{ fontSize: "24px" }} />,
id: "content-library",
title: "AI内容库配置",
description: "管理AI内容库,配置调用权限,优化AI推送效果和内容质量",
icon: <BookOutlined style={{ fontSize: "32px", color: "#52c41a" }} />,
color: "#52c41a",
count: 1,
tag: "内容管理",
features: [
{
id: "content-management",
title: "内容管理",
description: "管理营销内容,素材库,提升内容创作效率",
icon: <EditOutlined style={{ fontSize: "24px" }} />,
color: "#722ed1",
path: "/pc/powerCenter/content-management",
},
"多库管理与分类",
"AI调用权限配置",
"内容检索规则设置",
"手动内容上传",
],
path: "/pc/powerCenter/content-library",
},
];
// KPI统计数据
export const kpiData: KPIData[] = [
{
id: "total-customers",
value: "1,234",
label: "总客户数",
trend: {
icon: "↑",
text: "12% 本月",
},
},
{
id: "active-customers",
value: "856",
label: "活跃客户",
trend: {
icon: "↑",
text: "8% 本月",
},
},
{
id: "assigned-users",
value: "342",
label: "当前客服分配用户数",
trend: {
icon: "",
text: "当前登录客服",
},
},
];

View File

@@ -1,160 +1,188 @@
.powerCenter {
padding: 40px;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
background: #ffffff;
min-height: 100vh;
// 功能分类区域
.categorySection {
margin-bottom: 48px;
// 页面标题区域
.pageHeader {
text-align: center;
margin-bottom: 60px;
.categoryHeader {
display: flex;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
.categoryIcon {
width: 48px;
height: 48px;
color: #ffffff;
border-radius: 50%;
.titleSection {
.mainTitle {
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
gap: 16px;
margin-bottom: 16px;
.anticon {
font-size: 24px;
color: white;
.titleIcon {
font-size: 32px;
color: #722ed1;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #1a1a1a;
margin: 0;
}
}
.categoryInfo {
.subtitle {
font-size: 18px;
color: #666;
margin: 0;
font-weight: 400;
}
}
}
// 核心功能模块
.coreFeatures {
margin-bottom: 60px;
.featureCard {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
cursor: pointer;
overflow: hidden;
height: 400px;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.cardContent {
padding: 32px;
height: 100%;
display: flex;
align-items: center;
gap: 10px;
.categoryTitle {
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 4px 0;
flex-direction: column;
.cardHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
.cardIcon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(24, 144, 255, 0.1);
}
.cardTag {
color: white;
font-size: 12px;
font-weight: 500;
padding: 6px 12px;
border-radius: 16px;
height: 28px;
line-height: 16px;
display: flex;
align-items: center;
}
}
.categoryCount {
font-size: 12px;
color: #666;
background: #f0f0f0;
border-radius: 12px;
border: 1px solid #e0e0e0;
height: 24px;
line-height: 20px;
padding: 0 10px;
.cardInfo {
flex: 1;
display: flex;
flex-direction: column;
.cardTitle {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 12px 0;
line-height: 1.3;
}
.cardDescription {
font-size: 14px;
color: #666;
line-height: 1.6;
margin: 0 0 20px 0;
}
.featureList {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
li {
font-size: 13px;
color: #666;
line-height: 1.5;
margin-bottom: 8px;
position: relative;
padding-left: 16px;
&::before {
content: "";
color: #1890ff;
font-weight: bold;
position: absolute;
left: 0;
}
}
}
}
}
}
}
.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;
// KPI统计区域
.kpiSection {
margin-bottom: 40px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
.kpiCard {
background: white;
border-radius: 12px;
padding: 24px;
text-align: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.kpiValue {
font-size: 32px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
}
.kpiLabel {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.kpiTrend {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
.trendIcon {
color: #52c41a;
font-weight: bold;
}
.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);
}
}
}
.trendText {
font-size: 12px;
color: #52c41a;
}
}
}
@@ -180,63 +208,65 @@
.powerCenter {
padding: 32px 24px;
.categorySection {
.categoryHeader {
.categoryIcon {
width: 44px;
height: 44px;
.pageHeader {
margin-bottom: 40px;
.anticon {
font-size: 22px;
.titleSection {
.mainTitle {
h1 {
font-size: 36px;
}
.titleIcon {
font-size: 28px;
}
}
.categoryInfo {
.categoryTitle {
font-size: 22px;
.subtitle {
font-size: 16px;
}
}
}
.coreFeatures {
.featureCard {
height: 360px;
.cardContent {
padding: 24px;
.cardHeader {
.cardIcon {
width: 56px;
height: 56px;
}
}
.cardInfo {
.cardTitle {
font-size: 18px;
}
.cardDescription {
font-size: 13px;
}
.featureList {
li {
font-size: 12px;
}
}
}
}
}
}
.featureCards {
.featureCard {
height: 180px;
width: 260px;
padding: 20px;
.kpiSection {
.kpiCard {
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;
}
}
}
}
.kpiValue {
font-size: 28px;
}
}
}
@@ -247,76 +277,89 @@
.powerCenter {
padding: 24px 16px;
.categorySection {
.pageHeader {
margin-bottom: 32px;
.categoryHeader {
.categoryIcon {
width: 40px;
height: 40px;
.titleSection {
.mainTitle {
flex-direction: column;
gap: 8px;
.anticon {
font-size: 20px;
h1 {
font-size: 28px;
}
.titleIcon {
font-size: 24px;
}
}
.categoryInfo {
.categoryTitle {
font-size: 20px;
.subtitle {
font-size: 14px;
}
}
}
.coreFeatures {
.featureCard {
height: 320px;
margin-bottom: 16px;
.cardContent {
padding: 20px;
.cardHeader {
margin-bottom: 16px;
.cardIcon {
width: 48px;
height: 48px;
}
.cardTag {
font-size: 11px;
padding: 4px 8px;
}
}
.categoryCount {
font-size: 12px;
padding: 3px 10px;
.cardInfo {
.cardTitle {
font-size: 16px;
margin-bottom: 8px;
}
.cardDescription {
font-size: 12px;
margin-bottom: 16px;
}
.featureList {
li {
font-size: 11px;
margin-bottom: 6px;
}
}
}
}
}
}
.featureCards {
.featureCard {
height: 160px;
width: 240px;
padding: 16px;
.kpiSection {
.kpiCard {
padding: 16px;
margin-bottom: 12px;
.cardContent {
.badge {
top: 10px;
right: 10px;
font-size: 10px;
padding: 3px 6px;
}
.kpiValue {
font-size: 24px;
}
.cardIcon {
width: 40px;
height: 40px;
margin: 16px auto 12px;
.kpiLabel {
font-size: 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;
}
}
}
.kpiTrend {
.trendText {
font-size: 11px;
}
}
}

View File

@@ -1,10 +1,21 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import styles from "./index.module.scss";
import { FeatureCard, featureCategories } from "./index.data.tsx";
import { FeatureCard, featureCategories, kpiData } from "./index.data";
import { Col, Row } from "antd";
import {
UserOutlined,
TeamOutlined,
UsergroupAddOutlined,
} from "@ant-design/icons";
const PowerCenter: React.FC = () => {
const navigate = useNavigate();
const getKpiBg = (id: string) => {
if (id === "total-customers") return "#1890ff";
if (id === "active-customers") return "#52c41a";
return "#722ed1";
};
const handleCardClick = (card: FeatureCard) => {
if (card.path) {
@@ -14,79 +25,127 @@ const PowerCenter: React.FC = () => {
return (
<div className={styles.powerCenter}>
{/* 功能分类展示 */}
{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" }}
>
{category.count}
</span>
</div>
{/* 页面标题区域 */}
<div className={styles.pageHeader}>
<div className={styles.titleSection}>
<div className={styles.mainTitle}>
<div className={styles.titleIcon}></div>
<h1></h1>
</div>
<p className={styles.subtitle}>
AI智能营销··
</p>
</div>
</div>
{/* 功能卡片 */}
<div className={styles.featureCards}>
<Row gutter={16}>
{category.features.map(card => (
<Col span={8} key={card.id}>
{/* KPI统计区域置顶按图展示 */}
<div className={styles.kpiSection}>
<Row gutter={16}>
{kpiData.map(kpi => (
<Col span={8} key={kpi.id}>
<div className={styles.kpiCard}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<div
className={styles.kpiLabel}
style={{ textAlign: "left", marginBottom: 6 }}
>
{kpi.label}
</div>
<div
className={styles.kpiValue}
style={{ textAlign: "left", marginBottom: 6 }}
>
{kpi.value}
</div>
{kpi.trend && (
<div
className={styles.kpiTrend}
style={{ justifyContent: "flex-start" }}
>
<span className={styles.trendIcon}>
{kpi.trend.icon}
</span>
<span className={styles.trendText}>
{kpi.trend.text}
</span>
</div>
)}
</div>
<div
key={card.id}
className={styles.featureCard}
onClick={() => handleCardClick(card)}
aria-hidden
style={{
width: 56,
height: 56,
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: getKpiBg(kpi.id),
color: "#fff",
boxShadow: "0 6px 14px rgba(0,0,0,0.18)",
}}
>
<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>
)}
{kpi.id === "total-customers" && (
<UserOutlined style={{ fontSize: 22 }} />
)}
{kpi.id === "active-customers" && (
<TeamOutlined style={{ fontSize: 22 }} />
)}
{kpi.id !== "total-customers" &&
kpi.id !== "active-customers" && (
<UsergroupAddOutlined style={{ fontSize: 22 }} />
)}
</div>
</div>
</div>
</Col>
))}
</Row>
</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 className={styles.coreFeatures}>
<Row gutter={24}>
{featureCategories.map(card => (
<Col span={8} key={card.id}>
<div
className={styles.featureCard}
onClick={() => handleCardClick(card)}
>
<div className={styles.cardContent}>
<div className={styles.cardHeader}>
<div className={styles.cardIcon}>{card.icon}</div>
<div
className={styles.cardTag}
style={{ backgroundColor: card.color }}
>
{card.tag}
</div>
</div>
</Col>
))}
</Row>
</div>
</div>
))}
<div className={styles.cardInfo}>
<h3 className={styles.cardTitle}>{card.title}</h3>
<p className={styles.cardDescription}>{card.description}</p>
<ul className={styles.featureList}>
{card.features.map((feature, index) => (
<li key={index}>{feature}</li>
))}
</ul>
</div>
</div>
</div>
</Col>
))}
</Row>
</div>
{/* 页面底部 */}
<div className={styles.footer}>
<p> AI私域营销系统 - </p>

View File

@@ -0,0 +1,27 @@
.chatRecordSearch {
flex: 1;
display: flex;
justify-content: space-between;
.searchContentContainer {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
.timeRange {
display: flex;
align-items: center;
gap: 10px;
.timeRangeTitle {
width: 80px;
}
}
.searchContent {
display: flex;
align-items: center;
gap: 10px;
.searchContentTitle {
width: 100px;
}
}
}
}

View File

@@ -0,0 +1,86 @@
import React, { useState } from "react";
import { Button, Input, DatePicker, message } from "antd";
import dayjs from "dayjs";
import { CloseOutlined } from "@ant-design/icons";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import styles from "./index.module.scss";
const { RangePicker } = DatePicker;
const ChatRecordSearch: React.FC = () => {
const [searchContent, setSearchContent] = useState<string>("");
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(
null,
);
const [loading, setLoading] = useState(false);
const SearchMessage = useWeChatStore(state => state.SearchMessage);
const updateShowChatRecordModel = useWeChatStore(
state => state.updateShowChatRecordModel,
);
// 执行查找
const handleSearch = async () => {
if (!dateRange) {
message.warning("请选择时间范围");
return;
}
try {
setLoading(true);
const [From, To] = dateRange;
const searchData = {
From: From.unix() * 1000,
To: To.unix() * 1000,
keyword: searchContent.trim(),
};
await SearchMessage(searchData);
message.success("查找完成");
} catch (error) {
console.error("查找失败:", error);
message.error("查找失败,请重试");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
setSearchContent("");
setDateRange(null);
setLoading(false);
handleSearch();
updateShowChatRecordModel(false);
};
return (
<div className={styles.chatRecordSearch}>
<div className={styles.searchContentContainer}>
{/* 时间范围选择 */}
<div className={styles.timeRange}>
<div className={styles.timeRangeTitle}></div>
<RangePicker
value={dateRange}
onChange={setDateRange}
style={{ width: "100%" }}
disabled={loading}
placeholder={["开始日期", "结束日期"]}
/>
</div>
{/* 查找内容输入 */}
<div className={styles.searchContent}>
<div className={styles.searchContentTitle}></div>
<Input
placeholder="请输入要查找的关键词或内容"
value={searchContent}
onChange={e => setSearchContent(e.target.value)}
disabled={loading}
/>
<Button type="primary" loading={loading} onClick={handleSearch}>
</Button>
</div>
</div>
<CloseOutlined style={{ fontSize: 20 }} onClick={handleCancel} />
</div>
);
};
export default ChatRecordSearch;

View File

@@ -1,150 +0,0 @@
import React, { useState } from "react";
import { Button, Modal, Input, DatePicker, message } from "antd";
import { MessageOutlined } from "@ant-design/icons";
import dayjs from "dayjs";
import { useWeChatStore } from "@/store/module/weChat/weChat";
const { RangePicker } = DatePicker;
interface ChatRecordProps {
className?: string;
disabled?: boolean;
}
const ChatRecord: React.FC<ChatRecordProps> = ({
className,
disabled = false,
}) => {
const [visible, setVisible] = useState(false);
const [searchContent, setSearchContent] = useState<string>("");
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(
null,
);
const [loading, setLoading] = useState(false);
const SearchMessage = useWeChatStore(state => state.SearchMessage);
// 打开弹窗
const openModal = () => {
setVisible(true);
};
// 关闭弹窗并重置状态
const closeModal = () => {
setVisible(false);
setSearchContent("");
setDateRange(null);
setLoading(false);
};
// 执行查找
const handleSearch = async () => {
if (!dateRange) {
message.warning("请选择时间范围");
return;
}
try {
setLoading(true);
const [From, To] = dateRange;
const searchData = {
From: From.unix() * 1000,
To: To.unix() * 1000,
keyword: searchContent.trim(),
};
await SearchMessage(searchData);
message.success("查找完成");
closeModal();
} catch (error) {
console.error("查找失败:", error);
message.error("查找失败,请重试");
} finally {
setLoading(false);
}
};
return (
<>
<div
className={className}
onClick={openModal}
style={{
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
}}
>
<MessageOutlined />
</div>
<Modal
title="查找聊天记录"
open={visible}
onCancel={closeModal}
width={450}
centered
maskClosable={!loading}
footer={[
<div
key="footer"
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
}}
>
<Button
onClick={closeModal}
disabled={loading}
style={{ marginRight: "8px" }}
>
</Button>
<Button type="primary" loading={loading} onClick={handleSearch}>
</Button>
</div>,
]}
>
<div style={{ padding: "20px 0" }}>
{/* 时间范围选择 */}
<div style={{ marginBottom: "20px" }}>
<div
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
>
</div>
<RangePicker
value={dateRange}
onChange={setDateRange}
style={{ width: "100%" }}
size="large"
disabled={loading}
placeholder={["开始日期", "结束日期"]}
/>
</div>
{/* 查找内容输入 */}
<div>
<div
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
>
</div>
<Input
placeholder="请输入要查找的关键词或内容"
value={searchContent}
onChange={e => setSearchContent(e.target.value)}
size="large"
maxLength={100}
showCount
disabled={loading}
/>
</div>
</div>
</Modal>
</>
);
};
export default ChatRecord;

View File

@@ -6,6 +6,7 @@ import {
PictureOutlined,
ExportOutlined,
CloseOutlined,
MessageOutlined,
} from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
@@ -14,7 +15,6 @@ import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji";
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
import AudioRecorder from "@/components/Upload/AudioRecorder";
import ToContract from "./components/toContract";
import ChatRecord from "./components/chatRecord";
import styles from "./MessageEnter.module.scss";
import { useWeChatStore } from "@/store/module/weChat/weChat";
const { Footer } = Layout;
@@ -35,6 +35,12 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const updateTransmitModal = useWeChatStore(
state => state.updateTransmitModal,
);
const showChatRecordModel = useWeChatStore(
state => state.showChatRecordModel,
);
const updateShowChatRecordModel = useWeChatStore(
state => state.updateShowChatRecordModel,
);
const quoteMessageContent = useWeChatStore(
state => state.quoteMessageContent,
@@ -154,6 +160,9 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
const handTurnRignt = () => {
updateTransmitModal(true);
};
const openChatRecordModel = () => {
updateShowChatRecordModel(!showChatRecordModel);
};
return (
<>
@@ -202,7 +211,17 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
</div>
<div className={styles.rightTool}>
<ToContract className={styles.rightToolItem} />
<ChatRecord className={styles.rightToolItem} />
<div
style={{
fontSize: "12px",
cursor: "pointer",
color: "#666",
}}
onClick={openChatRecordModel}
>
<MessageOutlined />
&nbsp;
</div>
</div>
</div>
<div className={styles.inputArea}>

View File

@@ -121,6 +121,11 @@
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
&::after {
content: "";
display: block;
clear: both;
}
}
// 表情包消息

View File

@@ -0,0 +1,293 @@
// 通用消息文本样式
.messageText {
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
color: #666;
font-size: 14px;
}
// 位置消息基础样式
.locationMessage {
max-width: 420px;
margin: 4px 0;
}
.locationCard {
background: #ffffff;
border: 1px solid #e1e8ed;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
border-color: #1890ff;
}
&:active {
transform: translateY(0);
}
}
// 位置消息头部
.locationHeader {
display: flex;
align-items: center;
padding: 12px 16px 8px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-bottom: 1px solid #e1e8ed;
}
.locationIcon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border-radius: 8px;
color: white;
margin-right: 12px;
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2);
svg {
width: 18px;
height: 18px;
}
}
.locationTitle {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
letter-spacing: -0.01em;
}
// 位置消息内容
.locationContent {
padding: 12px 16px;
}
.poiName {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.locationAddress {
font-size: 13px;
color: #666;
line-height: 1.5;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.locationDetails {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.poiCategory,
.poiPhone {
display: flex;
align-items: center;
font-size: 12px;
color: #8c8c8c;
line-height: 1.4;
.categoryIcon,
.phoneIcon {
margin-right: 6px;
font-size: 11px;
}
}
.coordinates {
display: flex;
align-items: center;
font-size: 11px;
color: #999;
background: #f8f9fa;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #e9ecef;
.coordLabel {
margin-right: 6px;
font-weight: 500;
}
.coordValue {
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
letter-spacing: 0.5px;
}
}
// 位置消息操作区域
.locationAction {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
border-top: 1px solid #e1e8ed;
color: #1890ff;
font-size: 13px;
font-weight: 500;
transition: all 0.2s ease;
.actionText {
margin-right: 6px;
}
svg {
width: 14px;
height: 14px;
transition: transform 0.2s ease;
}
&:hover {
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
svg {
transform: translateX(2px);
}
}
}
// 响应式设计
@media (max-width: 768px) {
.locationMessage {
max-width: 280px;
}
.locationCard {
border-radius: 10px;
}
.locationHeader {
padding: 10px 14px 6px;
}
.locationIcon {
width: 28px;
height: 28px;
margin-right: 10px;
svg {
width: 16px;
height: 16px;
}
}
.locationTitle {
font-size: 13px;
}
.locationContent {
padding: 10px 14px;
}
.poiName {
font-size: 15px;
margin-bottom: 6px;
}
.locationAddress {
font-size: 12px;
margin-bottom: 10px;
}
.locationDetails {
gap: 4px;
margin-bottom: 10px;
}
.poiCategory,
.poiPhone {
font-size: 11px;
}
.coordinates {
font-size: 10px;
padding: 4px 6px;
}
.locationAction {
padding: 8px 14px;
font-size: 12px;
svg {
width: 12px;
height: 12px;
}
}
}
// 深色模式支持(如果需要)
@media (prefers-color-scheme: dark) {
.locationCard {
background: #1f1f1f;
border-color: #333;
color: #e6e6e6;
&:hover {
border-color: #40a9ff;
}
}
.locationHeader {
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
border-bottom-color: #333;
}
.locationTitle {
color: #e6e6e6;
}
.poiName {
color: #e6e6e6;
}
.locationAddress {
color: #999;
}
.poiCategory,
.poiPhone {
color: #666;
}
.coordinates {
background: #2a2a2a;
border-color: #333;
color: #999;
}
.locationAction {
background: linear-gradient(135deg, #1a2332 0%, #0d1419 100%);
border-top-color: #333;
color: #40a9ff;
&:hover {
background: linear-gradient(135deg, #0d1419 0%, #1a2332 100%);
}
}
}

View File

@@ -0,0 +1,146 @@
import React from "react";
import styles from "./LocationMessage.module.scss";
interface LocationMessageProps {
content: string;
}
interface LocationData {
x: string; // 经度
y: string; // 纬度
scale: string; // 缩放级别
label: string; // 地址标签
maptype: string; // 地图类型
poiname: string; // POI名称
poiid: string; // POI ID
buildingId: string; // 建筑ID
floorName: string; // 楼层名称
poiCategoryTips: string; // POI分类提示
poiBusinessHour: string; // 营业时间
poiPhone: string; // 电话
poiPriceTips: string; // 价格提示
isFromPoiList: string; // 是否来自POI列表
adcode: string; // 行政区划代码
cityname: string; // 城市名称
fromusername: string; // 发送者用户名
}
const LocationMessage: React.FC<LocationMessageProps> = ({ content }) => {
// 统一的错误消息渲染函数
const renderErrorMessage = (fallbackText: string) => (
<div className={styles.messageText}>{fallbackText}</div>
);
if (typeof content !== "string" || !content.trim()) {
return renderErrorMessage("[位置消息 - 无效内容]");
}
try {
// 解析位置消息内容
const parseLocationContent = (content: string): LocationData | null => {
try {
// 提取XML中的location标签内容
const locationMatch = content.match(/<location[^>]*>/);
if (!locationMatch) {
return null;
}
const locationTag = locationMatch[0];
// 提取所有属性
const extractAttribute = (tag: string, attrName: string): string => {
const regex = new RegExp(`${attrName}="([^"]*)"`);
const match = tag.match(regex);
return match ? match[1] : "";
};
return {
x: extractAttribute(locationTag, "x"),
y: extractAttribute(locationTag, "y"),
scale: extractAttribute(locationTag, "scale"),
label: extractAttribute(locationTag, "label"),
maptype: extractAttribute(locationTag, "maptype"),
poiname: extractAttribute(locationTag, "poiname"),
poiid: extractAttribute(locationTag, "poiid"),
buildingId: extractAttribute(locationTag, "buildingId"),
floorName: extractAttribute(locationTag, "floorName"),
poiCategoryTips: extractAttribute(locationTag, "poiCategoryTips"),
poiBusinessHour: extractAttribute(locationTag, "poiBusinessHour"),
poiPhone: extractAttribute(locationTag, "poiPhone"),
poiPriceTips: extractAttribute(locationTag, "poiPriceTips"),
isFromPoiList: extractAttribute(locationTag, "isFromPoiList"),
adcode: extractAttribute(locationTag, "adcode"),
cityname: extractAttribute(locationTag, "cityname"),
fromusername: extractAttribute(locationTag, "fromusername"),
};
} catch (error) {
console.error("解析位置消息失败:", error);
return null;
}
};
const locationData = parseLocationContent(content);
if (!locationData) {
return renderErrorMessage("[位置消息 - 解析失败]");
}
// 生成地图链接
const generateMapUrl = (lat: string, lng: string, label: string) => {
// 使用腾讯地图链接
return `https://apis.map.qq.com/uri/v1/marker?marker=coord:${lat},${lng};title:${encodeURIComponent(label)}&referer=wechat`;
};
const mapUrl = generateMapUrl(
locationData.y,
locationData.x,
locationData.label,
);
// 处理POI信息
const poiName = locationData.poiname || locationData.label;
const poiCategory = locationData.poiCategoryTips
? locationData.poiCategoryTips.split(":")[0]
: "";
const poiPhone = locationData.poiPhone || "";
return (
<div className={styles.locationMessage}>
<div
className={styles.locationCard}
onClick={() => window.open(mapUrl, "_blank")}
>
{/* 位置详情 */}
<div className={styles.locationContent}>
{/* POI名称 */}
{poiName && <div className={styles.poiName}>{poiName}</div>}
{/* 详细地址 */}
<div className={styles.locationAddress}>{locationData.label}</div>
{/* POI分类和电话 */}
<div className={styles.locationDetails}>
{poiCategory && (
<div className={styles.poiCategory}>
<span className={styles.categoryIcon}>🏷</span>
{poiCategory}
</div>
)}
{poiPhone && (
<div className={styles.poiPhone}>
<span className={styles.phoneIcon}>📞</span>
{poiPhone}
</div>
)}
</div>
</div>
</div>
</div>
);
} catch (error) {
console.error("位置消息渲染失败:", error);
return renderErrorMessage("[位置消息 - 渲染失败]");
}
};
export default LocationMessage;

View File

@@ -5,6 +5,7 @@ import AudioMessage from "./components/AudioMessage/AudioMessage";
import SmallProgramMessage from "./components/SmallProgramMessage";
import VideoMessage from "./components/VideoMessage";
import ClickMenu from "./components/ClickMeau";
import LocationMessage from "./components/LocationMessage";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
@@ -270,6 +271,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
}
return renderErrorMessage("[表情包]");
case 48: // 定位消息
return <LocationMessage content={content || ""} />;
case 49: // 小程序/文章/其他:图文、文件
return <SmallProgramMessage content={content || ""} />;

View File

@@ -0,0 +1,96 @@
import request from "@/api/request";
// 快捷回复项接口
export interface QuickWordsReply {
id: number;
groupId: number;
userId: number;
title: string;
msgType: number;
content: string;
createTime: string;
lastUpdateTime: string;
sortIndex: string;
updateTime: string | null;
isDel: number;
delTime: string | null;
}
// 快捷回复组接口
export interface QuickWordsItem {
id: number;
groupName: string;
sortIndex: string;
parentId: number;
replyType: string;
replys: any | null;
companyId: number;
userId: number;
replies: QuickWordsReply[];
children: QuickWordsItem[];
}
//好友接待配置
export function setFriendInjectConfig(params: any): Promise<QuickWordsItem[]> {
return request("/v1/kefu/reply/list", params, "GET");
}
export interface AddReplyRequest {
id?: string;
content?: string;
groupId?: string;
/**
* 1文本 3图片 43视频 49链接 等
*/
msgType?: string[];
/**
* 默认50
*/
sortIndex?: string;
title?: string;
[property: string]: any;
}
// 添加快捷回复
export function addReply(params: AddReplyRequest): Promise<any> {
return request("/v1/kefu/reply/addReply", params, "POST");
}
// 更新快捷回复
export function updateReply(params: AddReplyRequest): Promise<any> {
return request("/v1/kefu/reply/updateReply", params, "POST");
}
// 删除快捷回复
export function deleteReply(params: { id: string }): Promise<any> {
return request("/v1/kefu/reply/deleteReply", params, "DELETE");
}
export interface AddGroupRequest {
id?: string;
groupName?: string;
parentId?: string;
/**
* 0 公共 1私有 2部门
*/
replyType?: string[];
/**
* 默认50
*/
sortIndex?: string;
[property: string]: any;
}
// 添加快捷回复组
export function addGroup(params: AddGroupRequest): Promise<any> {
return request("/v1/kefu/reply/addGroup", params, "POST");
}
// 更新快捷回复组
export function updateGroup(params: AddGroupRequest): Promise<any> {
return request("/v1/kefu/reply/updateGroup", params, "POST");
}
// 删除快捷回复组
export function deleteGroup(params: { id: string }): Promise<any> {
return request("/v1/kefu/reply/deleteGroup", params, "DELETE");
}

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Modal, Form, Input, Space, Button } from "antd";
import { AddGroupRequest } from "../api";
export interface GroupModalProps {
open: boolean;
mode: "add" | "edit";
initialValues?: Partial<AddGroupRequest>;
onSubmit: (values: AddGroupRequest) => void;
onCancel: () => void;
}
const GroupModal: React.FC<GroupModalProps> = ({
open,
mode,
initialValues,
onSubmit,
onCancel,
}) => {
const [form] = Form.useForm<AddGroupRequest>();
return (
<Modal
title={mode === "add" ? "新增分组" : "编辑分组"}
open={open}
onCancel={() => {
onCancel();
form.resetFields();
}}
footer={null}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={values => onSubmit(values)}
initialValues={initialValues}
>
<Form.Item
name="groupName"
label="分组名称"
rules={[{ required: true, message: "请输入分组名称" }]}
>
<Input placeholder="请输入分组名称" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
onCancel();
form.resetFields();
}}
>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
};
export default GroupModal;

View File

@@ -0,0 +1,247 @@
import React, { useMemo } from "react";
import { Modal, Form, Input, Select, Space, Button } from "antd";
import {
PictureOutlined,
VideoCameraOutlined,
LinkOutlined,
} from "@ant-design/icons";
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
// 简化版不再使用样式与解析组件
import { AddReplyRequest } from "../api";
export interface QuickReplyModalProps {
open: boolean;
mode: "add" | "edit";
initialValues?: Partial<AddReplyRequest>;
onSubmit: (values: AddReplyRequest) => void;
onCancel: () => void;
groupOptions?: { label: string; value: string }[];
defaultGroupId?: string;
}
const QuickReplyModal: React.FC<QuickReplyModalProps> = ({
open,
mode,
initialValues,
onSubmit,
onCancel,
groupOptions,
defaultGroupId,
}) => {
const [form] = Form.useForm<AddReplyRequest>();
const mergedInitialValues = useMemo(() => {
return {
groupId: defaultGroupId,
msgType: initialValues?.msgType || ["1"],
...initialValues,
} as Partial<AddReplyRequest>;
}, [initialValues, defaultGroupId]);
// 监听类型变化
const msgTypeWatch = Form.useWatch("msgType", form);
const selectedMsgType = useMemo(() => {
const value = msgTypeWatch;
const raw = Array.isArray(value) ? value[0] : value;
return Number(raw || "1");
}, [msgTypeWatch]);
// 根据文件格式判断消息类型
const getMsgTypeByFileFormat = (filePath: string): number => {
const extension = filePath.toLowerCase().split(".").pop() || "";
const imageFormats = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
];
if (imageFormats.includes(extension)) return 3;
const videoFormats = [
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"3gp",
"rmvb",
];
if (videoFormats.includes(extension)) return 43;
return 49;
};
const FileType = {
TEXT: 1,
IMAGE: 2,
VIDEO: 3,
AUDIO: 4,
FILE: 5,
} as const;
const handleFileUploaded = (
filePath: string | { url: string; durationMs: number },
fileType: number,
) => {
let msgType = 1;
if (([FileType.TEXT] as number[]).includes(fileType)) {
msgType = getMsgTypeByFileFormat(filePath as string);
} else if (([FileType.IMAGE] as number[]).includes(fileType)) {
msgType = 3;
} else if (([FileType.VIDEO] as number[]).includes(fileType)) {
msgType = 43;
} else if (([FileType.AUDIO] as number[]).includes(fileType)) {
msgType = 34;
} else if (([FileType.FILE] as number[]).includes(fileType)) {
msgType = 49;
}
form.setFieldsValue({
msgType: [String(msgType)],
content: ([FileType.AUDIO] as number[]).includes(fileType)
? JSON.stringify(filePath)
: (filePath as string),
});
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.ctrlKey && !e.shiftKey) {
e.preventDefault();
form.submit();
}
};
// 简化后不再有预览解析
return (
<Modal
title={mode === "add" ? "添加快捷回复" : "编辑快捷回复"}
open={open}
onCancel={() => {
onCancel();
form.resetFields();
}}
footer={null}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={values => {
const normalized = {
...values,
msgType: Array.isArray(values.msgType)
? values.msgType
: [String(values.msgType)],
} as AddReplyRequest;
onSubmit(normalized);
}}
initialValues={mergedInitialValues}
>
<Space style={{ width: "100%" }} size={24}>
<Form.Item
name="title"
label="标题"
rules={[{ required: true, message: "请输入标题" }]}
style={{ flex: 1 }}
>
<Input placeholder="请输入快捷语标题" allowClear />
</Form.Item>
<Form.Item
name="groupId"
label="选择分组"
style={{ width: 260 }}
rules={[{ required: true, message: "请选择分组" }]}
>
<Select
placeholder="请选择分组"
options={groupOptions}
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Space>
<Form.Item
name="msgType"
label="消息类型"
rules={[{ required: true, message: "请选择消息类型" }]}
>
<Select placeholder="请选择消息类型">
<Select.Option value="1"></Select.Option>
<Select.Option value="3"></Select.Option>
<Select.Option value="43"></Select.Option>
<Select.Option value="49"></Select.Option>
</Select>
</Form.Item>
<Form.Item
name="content"
label="内容"
rules={[{ required: true, message: "请输入/上传内容" }]}
>
{selectedMsgType === 1 && (
<Input.TextArea
rows={4}
placeholder="请输入文本内容"
value={form.getFieldValue("content")}
onChange={e => form.setFieldsValue({ content: e.target.value })}
onKeyDown={handleKeyPress}
/>
)}
{selectedMsgType === 3 && (
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.IMAGE)
}
maxSize={1}
type={1}
slot={<Button icon={<PictureOutlined />}></Button>}
/>
)}
{selectedMsgType === 43 && (
<SimpleFileUpload
onFileUploaded={filePath =>
handleFileUploaded(filePath, FileType.VIDEO)
}
maxSize={1}
type={4}
slot={<Button icon={<VideoCameraOutlined />}></Button>}
/>
)}
{selectedMsgType === 49 && (
<Input
placeholder="请输入链接地址"
prefix={<LinkOutlined />}
value={form.getFieldValue("content")}
onChange={e => form.setFieldsValue({ content: e.target.value })}
/>
)}
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
onCancel();
form.resetFields();
}}
>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
);
};
export default QuickReplyModal;

View File

@@ -1,136 +1,583 @@
import React, { useMemo, useState } from "react";
import { Card, Input, Button, Space, List, Tag } from "antd";
import React, { useMemo, useState, useEffect, useCallback } from "react";
import {
Input,
Button,
Space,
Tabs,
Tree,
Modal,
Form,
message,
Tooltip,
Spin,
Dropdown,
} from "antd";
import {
PlusOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined,
FileTextOutlined,
PictureOutlined,
PlayCircleOutlined,
} from "@ant-design/icons";
import {
QuickWordsItem,
QuickWordsReply,
setFriendInjectConfig,
addReply,
updateReply,
deleteReply,
updateGroup,
deleteGroup,
AddReplyRequest,
AddGroupRequest,
addGroup,
} from "./api";
import Layout from "@/components/Layout/LayoutFiexd";
import QuickReplyModal from "./components/QuickReplyModal";
import GroupModal from "./components/GroupModal";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
export interface QuickWordItem {
id: string | number;
text?: string; // 兼容旧结构
title?: string;
content?: string;
tag?: string; // 分类/标签
usageCount?: number;
// 消息类型枚举
export enum MessageType {
TEXT = 1,
IMAGE = 3,
VIDEO = 43,
LINK = 49,
}
// 快捷语类型枚举
export enum QuickWordsType {
PERSONAL = 1, // 个人
PUBLIC = 0, // 公共
DEPARTMENT = 2, // 部门
}
export interface QuickWordsProps {
title?: string;
words: QuickWordItem[];
onInsert?: (text: string) => void;
onAdd?: (text: string) => void;
onRemove?: (id: string | number) => void;
onInsert?: (reply: QuickWordsReply) => void;
}
const QuickWords: React.FC<QuickWordsProps> = ({
title = "快捷语录",
words,
onInsert,
onRemove,
}) => {
const [keyword, setKeyword] = useState("");
const sorted = useMemo(
() =>
[...(words || [])].sort((a, b) =>
String(a.id).localeCompare(String(b.id)),
),
[words],
const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
const [activeTab, setActiveTab] = useState<QuickWordsType>(
QuickWordsType.PUBLIC,
);
const [keyword, setKeyword] = useState("");
const [loading, setLoading] = useState(false);
const [quickWordsData, setQuickWordsData] = useState<QuickWordsItem[]>([]);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
// 模态框状态
const [addModalVisible, setAddModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [groupModalVisible, setGroupModalVisible] = useState(false);
const [editingItem, setEditingItem] = useState<QuickWordsReply | null>(null);
const [editingGroup, setEditingGroup] = useState<QuickWordsItem | null>(null);
const [isAddingGroup, setIsAddingGroup] = useState(false);
const [form] = Form.useForm();
const [groupForm] = Form.useForm();
const updateQuoteMessageContent = useWeChatStore(
state => state.updateQuoteMessageContent,
);
const currentContract = useWeChatStore(state => state.currentContract);
const { sendCommand } = useWebSocketStore.getState();
const sendQuickReplyNow = (reply: QuickWordsReply) => {
if (!currentContract) return;
const params = {
wechatAccountId: currentContract.wechatAccountId,
wechatChatroomId: currentContract?.chatroomId ? currentContract.id : 0,
wechatFriendId: currentContract?.chatroomId ? 0 : currentContract.id,
msgSubType: 0,
msgType: reply.msgType,
content: reply.content,
} as any;
sendCommand("CmdSendMessage", params);
};
const previewAndConfirmSend = (reply: QuickWordsReply) => {
let previewNode: React.ReactNode = null;
if (reply.msgType === MessageType.IMAGE) {
previewNode = (
<div style={{ textAlign: "center" }}>
<img
src={reply.content}
alt="预览"
style={{ maxWidth: 360, maxHeight: 320, borderRadius: 6 }}
/>
</div>
);
} else if (reply.msgType === MessageType.VIDEO) {
try {
const json = JSON.parse(reply.content || "{}");
const cover = json.previewImage || json.thumbPath || "";
previewNode = (
<div style={{ textAlign: "center" }}>
{cover ? (
<img
src={String(cover)}
alt="视频预览"
style={{ maxWidth: 360, maxHeight: 320, borderRadius: 6 }}
/>
) : (
<div></div>
)}
</div>
);
} catch {
previewNode = <div></div>;
}
} else if (reply.msgType === MessageType.LINK) {
previewNode = (
<div>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{reply.title}</div>
<div style={{ color: "#1677ff" }}>{reply.content}</div>
</div>
);
}
Modal.confirm({
title: "确认发送该快捷语?",
content: previewNode,
okText: "发送",
cancelText: "取消",
onOk: () => {
sendQuickReplyNow(reply);
message.success("已发送");
},
});
};
// 获取快捷语数据
const fetchQuickWords = useCallback(async () => {
setLoading(true);
try {
const data = await setFriendInjectConfig({ replyType: activeTab });
setQuickWordsData(data || []);
} catch (error) {
message.error("获取快捷语数据失败");
} finally {
setLoading(false);
}
}, [activeTab]);
// 初始化数据
useEffect(() => {
fetchQuickWords();
}, [fetchQuickWords]);
// 获取消息类型图标
const getMessageTypeIcon = (msgType: number) => {
switch (msgType) {
case MessageType.TEXT:
return <FileTextOutlined style={{ color: "#1890ff" }} />;
case MessageType.IMAGE:
return <PictureOutlined style={{ color: "#52c41a" }} />;
case MessageType.VIDEO:
return <PlayCircleOutlined style={{ color: "#fa8c16" }} />;
default:
return <FileTextOutlined style={{ color: "#8c8c8c" }} />;
}
};
// 将数据转换为Tree组件需要的格式
const convertToTreeData = (data: QuickWordsItem[]): any[] => {
return data.map(item => ({
key: `group-${item.id}`,
title: (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
}}
>
<span>{item.groupName}</span>
<div style={{ display: "flex", gap: 4 }}>
<Tooltip title="编辑分组">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={e => {
e.stopPropagation();
handleEditGroup(item);
}}
/>
</Tooltip>
<Tooltip title="删除分组">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={e => {
e.stopPropagation();
handleDeleteGroup(item.id);
}}
/>
</Tooltip>
</div>
</div>
),
children: [
...item.replies.map(reply => ({
key: `reply-${reply.id}`,
title: (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
}}
onClick={e => {
e.stopPropagation();
// 将快捷语内容写入输入框(仅文本或可直接粘贴的内容)
try {
if ([MessageType.TEXT].includes(reply.msgType)) {
updateQuoteMessageContent(reply.content || "");
} else if ([MessageType.LINK].includes(reply.msgType)) {
previewAndConfirmSend(reply);
} else {
// 图片/视频等类型:弹出预览确认后直接发送
previewAndConfirmSend(reply);
}
} catch (_) {}
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{getMessageTypeIcon(reply.msgType)}
<span>{reply.title}</span>
</div>
<div style={{ display: "flex", gap: 4 }}>
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={e => {
e.stopPropagation();
handleEditReply(reply);
}}
/>
</Tooltip>
<Tooltip title="删除">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={e => {
e.stopPropagation();
handleDeleteReply(reply.id);
}}
/>
</Tooltip>
</div>
</div>
),
isLeaf: true,
})),
...convertToTreeData(item.children || []),
],
}));
};
// 处理添加快捷回复
const handleAddReply = async (values: AddReplyRequest) => {
try {
const fallbackGroupId =
selectedKeys[0]?.toString().replace("group-", "") ||
groupOptions[0]?.value ||
"";
await addReply({
...values,
groupId: values.groupId || fallbackGroupId,
replyType: [activeTab.toString()],
});
message.success("添加快捷回复成功");
setAddModalVisible(false);
form.resetFields();
fetchQuickWords();
} catch (error) {
message.error("添加快捷回复失败");
}
};
// 处理编辑快捷回复
const handleEditReply = (reply: QuickWordsReply) => {
setEditingItem(reply);
setEditModalVisible(true);
};
// 处理更新快捷回复
const handleUpdateReply = async (values: AddReplyRequest) => {
if (!editingItem) return;
try {
await updateReply({
...values,
id: editingItem.id.toString(),
});
message.success("更新快捷回复成功");
setEditModalVisible(false);
setEditingItem(null);
fetchQuickWords();
} catch (error) {
message.error("更新快捷回复失败");
}
};
// 处理删除快捷回复
const handleDeleteReply = async (id: number) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这个快捷回复吗?",
onOk: async () => {
try {
await deleteReply({ id: id.toString() });
message.success("删除成功");
fetchQuickWords();
} catch (error) {
message.error("删除失败");
}
},
});
};
// 处理编辑分组
const handleEditGroup = (group: QuickWordsItem) => {
setIsAddingGroup(false);
setEditingGroup(group);
setGroupModalVisible(true);
};
// 打开新增分组
const handleOpenAddGroup = () => {
setIsAddingGroup(true);
setEditingGroup(null);
groupForm.resetFields();
setGroupModalVisible(true);
};
// 处理更新分组
const handleUpdateGroup = async (values: AddGroupRequest) => {
if (!editingGroup) return;
try {
await updateGroup({
...values,
id: editingGroup.id.toString(),
});
message.success("更新分组成功");
setGroupModalVisible(false);
setEditingGroup(null);
fetchQuickWords();
} catch (error) {
message.error("更新分组失败");
}
};
// 处理新增分组
const handleAddGroup = async (values: AddGroupRequest) => {
try {
await addGroup({
...values,
parentId: selectedKeys[0]?.toString().startsWith("group-")
? selectedKeys[0]?.toString().replace("group-", "")
: "0",
replyType: [activeTab.toString()],
});
message.success("新增分组成功");
setGroupModalVisible(false);
setIsAddingGroup(false);
fetchQuickWords();
} catch (error) {
message.error("新增分组失败");
}
};
// 处理删除分组
const handleDeleteGroup = async (id: number) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这个分组吗?删除后该分组下的所有快捷回复也会被删除。",
onOk: async () => {
try {
await deleteGroup({ id: id.toString() });
message.success("删除成功");
fetchQuickWords();
} catch (error) {
message.error("删除失败");
}
},
});
};
// 过滤数据
const filteredData = useMemo(() => {
if (!keyword.trim()) return quickWordsData;
const filterData = (data: QuickWordsItem[]): QuickWordsItem[] => {
return data
.map(item => ({
...item,
replies: item.replies.filter(
reply =>
reply.title.toLowerCase().includes(keyword.toLowerCase()) ||
reply.content.toLowerCase().includes(keyword.toLowerCase()),
),
children: filterData(item.children || []),
}))
.filter(
item =>
item.replies.length > 0 ||
item.children.length > 0 ||
item.groupName.toLowerCase().includes(keyword.toLowerCase()),
);
};
return filterData(quickWordsData);
}, [quickWordsData, keyword]);
const treeData = convertToTreeData(filteredData);
// 供新增/编辑快捷语使用的分组下拉数据
const groupOptions = useMemo(() => {
const flat: { label: string; value: string }[] = [];
const walk = (items: QuickWordsItem[]) => {
items.forEach(it => {
flat.push({ label: it.groupName, value: it.id.toString() });
if (it.children && it.children.length) walk(it.children);
});
};
walk(quickWordsData);
return flat;
}, [quickWordsData]);
return (
<Card title={title} style={{ marginTop: 12 }}>
<Space direction="vertical" style={{ width: "100%" }}>
<Input.Search
placeholder="搜索快捷语录..."
allowClear
value={keyword}
onChange={e => setKeyword(e.target.value)}
onSearch={v => setKeyword(v)}
/>
<Layout
header={
<div style={{ padding: "0 16px" }}>
<Tabs
activeKey={activeTab.toString()}
onChange={key => setActiveTab(Number(key) as QuickWordsType)}
items={[
{
key: QuickWordsType.PERSONAL.toString(),
label: "个人快捷语",
},
<List
itemLayout="vertical"
split={false}
dataSource={sorted.filter(item => {
const text = `${item.title || ""}${item.content || ""}${item.text || ""}`;
return text.toLowerCase().includes(keyword.trim().toLowerCase());
})}
renderItem={item => {
const displayTitle = item.title || item.text || "未命名";
const displayContent = item.content || item.text || "";
return (
<List.Item
style={{
padding: "12px 8px",
border: "1px solid #f0f0f0",
borderRadius: 8,
marginBottom: 12,
background: "#fff",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
{item.tag && <Tag color="blue">{item.tag}</Tag>}
<span style={{ fontWeight: 600, color: "#262626" }}>
{displayTitle}
</span>
</div>
<div
style={{
color: "#8c8c8c",
fontSize: 13,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
}}
>
{displayContent}
</div>
{typeof item.usageCount === "number" && (
<div
style={{ color: "#bfbfbf", fontSize: 12, marginTop: 6 }}
>
使 {item.usageCount}
</div>
)}
</div>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
{onRemove && (
<Button
size="small"
danger
onClick={() => onRemove(item.id)}
>
</Button>
)}
<Button
type="primary"
size="small"
onClick={() => onInsert?.(displayContent || displayTitle)}
>
使
</Button>
</div>
</div>
</List.Item>
);
}}
/>
{
key: QuickWordsType.DEPARTMENT.toString(),
label: "公司快捷语",
},
]}
/>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Input.Search
placeholder="输入关键字过滤"
allowClear
value={keyword}
onChange={e => setKeyword(e.target.value)}
style={{ flex: 1 }}
/>
<Dropdown
menu={{
items: [
{ key: "add-group", label: "添加新分组" },
{ key: "add-reply", label: "新增快捷语" },
{ key: "import-reply", label: "导入快捷语" },
],
onClick: ({ key }) => {
if (key === "add-group") return handleOpenAddGroup();
if (key === "add-reply") return setAddModalVisible(true);
if (key === "import-reply")
return message.info("导入快捷语功能开发中");
},
}}
placement="bottomRight"
trigger={["click"]}
>
<Tooltip title="添加">
<Button type="primary" icon={<PlusOutlined />} />
</Tooltip>
</Dropdown>
<Tooltip title="刷新">
<Button icon={<ReloadOutlined />} onClick={fetchQuickWords} />
</Tooltip>
</div>
</div>
}
>
<Space direction="vertical" style={{ width: "100%", padding: 16 }}>
<Spin spinning={loading}>
<Tree
showLine
showIcon
expandedKeys={expandedKeys}
selectedKeys={selectedKeys}
onExpand={setExpandedKeys}
onSelect={setSelectedKeys}
treeData={treeData}
/>
</Spin>
</Space>
</Card>
<QuickReplyModal
open={addModalVisible}
mode="add"
groupOptions={groupOptions}
defaultGroupId={
selectedKeys[0]?.toString().replace("group-", "") ||
groupOptions[0]?.value
}
onSubmit={handleAddReply}
onCancel={() => setAddModalVisible(false)}
/>
<QuickReplyModal
open={editModalVisible}
mode="edit"
groupOptions={groupOptions}
defaultGroupId={selectedKeys[0]?.toString().replace("group-", "")}
initialValues={
editingItem
? {
title: editingItem.title,
content: editingItem.content,
msgType: [editingItem.msgType.toString()],
groupId:
editingItem.groupId?.toString?.() ||
selectedKeys[0]?.toString().replace("group-", ""),
}
: undefined
}
onSubmit={handleUpdateReply}
onCancel={() => {
setEditModalVisible(false);
setEditingItem(null);
}}
/>
<GroupModal
open={groupModalVisible}
mode={isAddingGroup ? "add" : "edit"}
initialValues={
editingGroup ? { groupName: editingGroup.groupName } : undefined
}
onSubmit={isAddingGroup ? handleAddGroup : handleUpdateGroup}
onCancel={() => {
setGroupModalVisible(false);
setEditingGroup(null);
setIsAddingGroup(false);
}}
/>
</Layout>
);
};

View File

@@ -16,6 +16,7 @@ import MessageEnter from "./components/MessageEnter";
import MessageRecord from "./components/MessageRecord";
import FollowupReminderModal from "./components/FollowupReminderModal";
import TodoListModal from "./components/TodoListModal";
import ChatRecordSearch from "./components/ChatRecordSearch";
import { setFriendInjectConfig } from "@/pages/pc/ckbox/weChat/api";
import { useWeChatStore } from "@/store/module/weChat/weChat";
const { Header, Content } = Layout;
@@ -37,6 +38,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
const aiQuoteMessageContent = useWeChatStore(
state => state.aiQuoteMessageContent,
);
const showChatRecordModel = useWeChatStore(
state => state.showChatRecordModel,
);
const [showProfile, setShowProfile] = useState(true);
const [followupModalVisible, setFollowupModalVisible] = useState(false);
const [todoModalVisible, setTodoModalVisible] = useState(false);
@@ -136,12 +140,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
</Space>
</Header>
<div className={styles.extend}>
<Button icon={<BellOutlined />} onClick={handleFollowupClick}>
</Button>
<Button icon={<CheckSquareOutlined />} onClick={handleTodoClick}>
</Button>
{showChatRecordModel ? (
<ChatRecordSearch />
) : (
<>
<Button icon={<BellOutlined />} onClick={handleFollowupClick}>
</Button>
<Button icon={<CheckSquareOutlined />} onClick={handleTodoClick}>
</Button>
</>
)}
</div>
{/* 聊天内容 */}

View File

@@ -10,6 +10,8 @@ import {
* 包含聊天消息、联系人管理、朋友圈等功能的状态和方法
*/
export interface WeChatState {
showChatRecordModel: boolean;
updateShowChatRecordModel: (show: boolean) => void;
aiQuoteMessageContent: number;
updateAiQuoteMessageContent: (message: number) => void;
quoteMessageContent: string;

View File

@@ -33,6 +33,10 @@ import {
export const useWeChatStore = create<WeChatState>()(
persist(
(set, get) => ({
showChatRecordModel: false,
updateShowChatRecordModel: (show: boolean) => {
set({ showChatRecordModel: show });
},
//当前用户的ai接管状态
aiQuoteMessageContent: 0,
updateAiQuoteMessageContent: (message: number) => {
@@ -159,7 +163,8 @@ export const useWeChatStore = create<WeChatState>()(
} else {
params.wechatChatroomId = contract.id;
}
//重置动作
set({ showChatRecordModel: false });
clearUnreadCount1(params);
clearUnreadCount2([contract.id]);
getFriendInjectConfig({