Merge branch 'yongpxu-dev' into develop
This commit is contained in:
@@ -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>
|
||||
|
||||
10
Touchkebao/src/components/MetailSelection/api.ts
Normal file
10
Touchkebao/src/components/MetailSelection/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取群组列表
|
||||
export function getGroupList(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return request("/v1/kefu/content/material/list", params, "GET");
|
||||
}
|
||||
27
Touchkebao/src/components/MetailSelection/data.ts
Normal file
27
Touchkebao/src/components/MetailSelection/data.ts
Normal 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;
|
||||
}
|
||||
206
Touchkebao/src/components/MetailSelection/index.module.scss
Normal file
206
Touchkebao/src/components/MetailSelection/index.module.scss
Normal file
@@ -0,0 +1,206 @@
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 20px;
|
||||
}
|
||||
.input {
|
||||
padding-left: 38px !important;
|
||||
height: 48px;
|
||||
border-radius: 16px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 16px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.selectedListRow {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.selectedListRowContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.selectedListRowContentText {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 24px;
|
||||
}
|
||||
.popupTitle {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.searchWrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.searchInput {
|
||||
padding-left: 40px !important;
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
border-radius: 24px !important;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
font-size: 15px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
font-size: 16px;
|
||||
}
|
||||
.clearBtn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 50%;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.groupList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.groupListInner {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.groupItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.2s;
|
||||
&:hover {
|
||||
background: #f5f6fa;
|
||||
}
|
||||
}
|
||||
.groupInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.groupAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.groupDetail {
|
||||
flex: 1;
|
||||
}
|
||||
.groupName {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.groupId {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.groupOwner {
|
||||
font-size: 13px;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.loadingBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.loadingText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
.emptyBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.emptyText {
|
||||
color: #888;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.paginationRow {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
}
|
||||
.totalCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.paginationControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pageBtn {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
138
Touchkebao/src/components/MetailSelection/index.tsx
Normal file
138
Touchkebao/src/components/MetailSelection/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
Touchkebao/src/components/MetailSelection/selectionPopup.tsx
Normal file
257
Touchkebao/src/components/MetailSelection/selectionPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
16
Touchkebao/src/pages/pc/ckbox/components/NavCommon/api.ts
Normal file
16
Touchkebao/src/pages/pc/ckbox/components/NavCommon/api.ts
Normal 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");
|
||||
};
|
||||
@@ -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}>
|
||||
林秀杰:关于自由主时间特惠,原价1999元,现在只需1699元
|
||||
</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>
|
||||
</>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "当前登录客服",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 />
|
||||
聊天记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.inputArea}>
|
||||
|
||||
@@ -121,6 +121,11 @@
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
// 表情包消息
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 || ""} />;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 聊天内容 */}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
* 包含聊天消息、联系人管理、朋友圈等功能的状态和方法
|
||||
*/
|
||||
export interface WeChatState {
|
||||
showChatRecordModel: boolean;
|
||||
updateShowChatRecordModel: (show: boolean) => void;
|
||||
aiQuoteMessageContent: number;
|
||||
updateAiQuoteMessageContent: (message: number) => void;
|
||||
quoteMessageContent: string;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user