Merge branch 'yongpxu-dev' into develop

This commit is contained in:
超级老白兔
2025-10-22 10:43:36 +08:00
38 changed files with 2552 additions and 2389 deletions

View File

@@ -16,7 +16,8 @@ import {
LogoutOutlined,
ThunderboltOutlined,
SettingOutlined,
WechatOutlined,
CalendarOutlined,
RetweetOutlined,
} from "@ant-design/icons";
import { noticeList, readMessage, readAll } from "./api";
import { useUserStore } from "@/store/module/user";
@@ -64,8 +65,8 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
}, []);
// 处理菜单图标点击:在两个路由之间切换
const handleMenuClick = (index: number) => {
if (index === 0) {
const handleMenuClick = () => {
if (!location.pathname.startsWith("/pc/powerCenter")) {
navigate("/pc/powerCenter");
} else {
navigate("/pc/weChat");
@@ -205,24 +206,27 @@ const NavCommon: React.FC<NavCommonProps> = ({ title = "触客宝" }) => {
onClick: handleLogout,
},
];
const handleContentManagementClick = () => {
navigate("/pc/powerCenter/content-management");
};
return (
<>
<Header className={styles.header}>
<div className={styles.headerLeft}>
<Button
icon={<BarChartOutlined />}
type={!isWeChat() ? "primary" : "default"}
onClick={() => handleMenuClick(0)}
icon={<BarChartOutlined style={{ fontSize: 18 }} />}
type="primary"
onClick={handleMenuClick}
>
{isWeChat() ? "功能中心" : "Ai智能客服"}
<RetweetOutlined style={{ fontSize: 18 }} />
</Button>
<Button
icon={<WechatOutlined />}
type={isWeChat() ? "primary" : "default"}
onClick={() => handleMenuClick(1)}
icon={<CalendarOutlined />}
onClick={handleContentManagementClick}
>
Ai智能客服
</Button>
<span className={styles.title}>{title}</span>
</div>

View File

@@ -82,6 +82,8 @@ export interface KfUserListData {
labels: string[];
lastUpdateTime: string;
isOnline?: boolean;
momentsMax: number;
momentsNum: number;
[key: string]: any;
}
@@ -113,8 +115,9 @@ export interface weChatGroup {
chatroomAvatar: string;
groupId: number;
config?: {
top?: false;
chat?: boolean;
unreadCount: number;
unreadCount?: number;
};
labels?: string[];
notice: string;

View File

@@ -0,0 +1,222 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, Input, Select, Button, message } from "antd";
import { listData, updateMoment } from "./api";
import UploadComponent from "@/components/Upload/ImageUpload/ImageUpload";
import VideoUpload from "@/components/Upload/VideoUpload";
interface EditMomentModalProps {
visible: boolean;
onCancel: () => void;
onSuccess: () => void;
momentData?: listData;
}
const EditMomentModal: React.FC<EditMomentModalProps> = ({
visible,
onCancel,
onSuccess,
momentData,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [contentType, setContentType] = useState<number>(1);
const [resUrls, setResUrls] = useState<string[]>([]);
const [linkData, setLinkData] = useState({
desc: "",
image: "",
url: "",
});
useEffect(() => {
if (visible && momentData) {
// 填充表单数据
form.setFieldsValue({
content: momentData.text,
type: momentData.momentContentType.toString(),
sendTime: momentData.sendTime
? new Date(momentData.sendTime * 1000).toISOString().slice(0, 16)
: "",
});
setContentType(momentData.momentContentType);
setResUrls(momentData.picUrlList || []);
// 处理链接数据
if (momentData.link && momentData.link.length > 0) {
setLinkData({
desc: momentData.link[0] || "",
image: "",
url: momentData.link[0] || "",
});
}
}
}, [visible, momentData, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setLoading(true);
const updateData: any = {
id: momentData?.id,
content: values.content,
type: values.type,
"wechatIds[]": [momentData?.accountCount || 1], // 这里需要根据实际情况调整
};
// 根据内容类型添加相应字段
switch (parseInt(values.type)) {
case 1: // 文本
break;
case 2: // 图文
if (resUrls.length > 0) {
updateData["picUrlList[]"] = resUrls;
}
break;
case 3: // 视频
if (resUrls[0]) {
updateData.videoUrl = resUrls[0];
}
break;
case 4: // 链接
if (linkData.url) {
updateData["link[url]"] = [linkData.url];
if (linkData.desc) {
updateData["link[desc]"] = [linkData.desc];
}
if (linkData.image) {
updateData["link[image]"] = [linkData.image];
}
}
break;
}
// 添加定时发布时间
if (values.sendTime) {
updateData.timingTime = values.sendTime;
}
const success = await updateMoment(updateData);
if (success) {
message.success("更新成功!");
onSuccess();
onCancel();
} else {
message.error("更新失败,请重试");
}
} catch (error) {
console.error("更新失败:", error);
message.error("更新失败,请重试");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
setResUrls([]);
setLinkData({ desc: "", image: "", url: "" });
onCancel();
};
return (
<Modal
title="编辑朋友圈"
open={visible}
onCancel={handleCancel}
width={600}
footer={[
<Button key="cancel" onClick={handleCancel}>
</Button>,
<Button
key="submit"
type="primary"
loading={loading}
onClick={handleSubmit}
>
</Button>,
]}
>
<Form form={form} layout="vertical">
<Form.Item
name="content"
label="内容"
rules={[{ required: true, message: "请输入内容" }]}
>
<Input.TextArea rows={4} placeholder="请输入内容" />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Select
placeholder="请选择类型"
onChange={value => setContentType(parseInt(value))}
>
<Select.Option value="1"></Select.Option>
<Select.Option value="2"></Select.Option>
<Select.Option value="3"></Select.Option>
<Select.Option value="4"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="sendTime" label="发布时间">
<Input type="datetime-local" />
</Form.Item>
{/* 图文类型 */}
{contentType === 2 && (
<Form.Item label="图片">
<UploadComponent value={resUrls} onChange={setResUrls} count={9} />
</Form.Item>
)}
{/* 视频类型 */}
{contentType === 3 && (
<Form.Item label="视频">
<VideoUpload
value={resUrls[0] || ""}
onChange={url => setResUrls([url as string])}
/>
</Form.Item>
)}
{/* 链接类型 */}
{contentType === 4 && (
<>
<Form.Item label="链接地址">
<Input
value={linkData.url}
onChange={e =>
setLinkData(prev => ({ ...prev, url: e.target.value }))
}
placeholder="请输入链接地址"
/>
</Form.Item>
<Form.Item label="链接描述">
<Input
value={linkData.desc}
onChange={e =>
setLinkData(prev => ({ ...prev, desc: e.target.value }))
}
placeholder="请输入链接描述"
/>
</Form.Item>
<Form.Item label="链接封面">
<UploadComponent
value={linkData.image ? [linkData.image] : []}
onChange={urls =>
setLinkData(prev => ({ ...prev, image: urls[0] || "" }))
}
count={1}
/>
</Form.Item>
</>
)}
</Form>
</Modal>
);
};
export default EditMomentModal;

View File

@@ -0,0 +1,200 @@
.momentPublish {
padding: 24px;
background: #fff;
border-radius: 8px;
height: 100%;
.title {
font-size: 18px;
font-weight: 600;
color: #262626;
margin: 0 0 24px 0;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.sectionTitle {
font-size: 14px;
font-weight: 500;
color: #595959;
margin: 0 0 12px 0;
}
.accountSection {
margin-bottom: 24px;
.accountList {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.accountCard {
flex: 1;
min-width: 120px;
cursor: pointer;
border: 2px solid #f0f0f0;
border-radius: 8px;
transition: all 0.3s;
position: relative;
&:hover:not(.disabled) {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #fafafa;
}
:global(.ant-card-body) {
padding: 16px;
}
.accountInfo {
text-align: center;
.accountName {
font-size: 14px;
font-weight: 500;
color: #262626;
margin-bottom: 8px;
}
.usageBadge {
:global(.ant-badge-count) {
font-size: 12px;
min-width: 32px;
height: 20px;
line-height: 18px;
border-radius: 10px;
}
}
.limitText {
font-size: 12px;
color: #ff4d4f;
margin-top: 4px;
}
}
}
}
.contentSection {
margin-bottom: 24px;
.formItem {
margin-bottom: 24px;
.formSelect {
width: 100%;
}
}
.contentInput {
:global(.ant-input) {
border-radius: 8px;
border: 1px solid #d9d9d9;
font-size: 14px;
line-height: 1.5;
resize: none;
&:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
}
:global(.ant-input::placeholder) {
color: #bfbfbf;
}
}
}
.imageSection {
margin-bottom: 24px;
:global(.ant-upload-list-picture-card) {
.ant-upload-list-item {
border-radius: 8px;
border: 1px solid #d9d9d9;
}
}
.uploadButton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #8c8c8c;
font-size: 14px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
transition: all 0.3s;
&:hover {
border-color: #1890ff;
color: #1890ff;
background: #f0f8ff;
}
}
}
.publishSection {
display: flex;
justify-content: center;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
.publishButton {
width: 200px;
height: 40px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
border: none;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.3);
&:hover {
background: linear-gradient(135deg, #40a9ff 0%, #69c0ff 100%);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.momentPublish {
padding: 16px;
.accountSection {
.accountList {
flex-direction: column;
}
.accountCard {
min-width: auto;
}
}
.publishSection {
.publishButton {
width: 100%;
}
}
}
}

View File

@@ -0,0 +1,317 @@
import React, { useState, useEffect, useCallback } from "react";
import { Button, Input, Select, message, Card, Badge } from "antd";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import { addMoment } from "./api";
import styles from "./MomentPublish.module.scss";
import { KfUserListData } from "@/pages/pc/ckbox/data";
import UploadComponent from "@/components/Upload/ImageUpload/ImageUpload";
import VideoUpload from "@/components/Upload/VideoUpload";
interface MomentPublishProps {
onPublishSuccess?: () => void;
}
const MomentPublish: React.FC<MomentPublishProps> = ({ onPublishSuccess }) => {
const [selectedAccounts, setSelectedAccounts] = useState<string[]>([]);
const [content, setContent] = useState<string>("");
// 发布类型1文本 2图文 3视频 4链接
const [contentType, setContentType] = useState<number>(1);
const [timingTime, setTimingTime] = useState<string>("");
const [resUrls, setResUrls] = useState<string[]>([]); // 图片/视频等资源
// 链接
const [linkDesc, setLinkDesc] = useState<string>("");
const [linkImage, setLinkImage] = useState<string>("");
const [linkUrl, setLinkUrl] = useState<string>("");
const [accounts, setAccounts] = useState<KfUserListData[]>([]);
const [loading, setLoading] = useState<boolean>(false);
// 从store获取客服列表
const kfUserList = useCkChatStore(state => state.kfUserList);
// 获取账号使用情况
const fetchAccountUsage = useCallback(async () => {
if (kfUserList.length === 0) return;
// 直接使用客服列表数据不需要额外的API调用
const accountData = kfUserList.map((kf, index) => ({
...kf,
name: kf.nickname || `客服${index + 1}`,
isSelected: selectedAccounts.includes(kf.id.toString()),
isDisabled: kf.momentsNum >= kf.momentsMax,
}));
setAccounts(accountData);
}, [kfUserList, selectedAccounts]);
// 如果没有选中的账号且有可用账号,自动选择第一个可用账号
useEffect(() => {
if (selectedAccounts.length === 0 && accounts.length > 0) {
const firstAvailable = accounts.find(acc => !acc.isDisabled);
if (firstAvailable) {
setSelectedAccounts([firstAvailable.id.toString()]);
}
}
}, [accounts, selectedAccounts.length]);
// 当客服列表变化时,重新获取使用情况
useEffect(() => {
fetchAccountUsage();
}, [kfUserList, selectedAccounts, fetchAccountUsage]);
const handleAccountSelect = (accountId: string) => {
const account = accounts.find(acc => acc.id.toString() === accountId);
if (!account || account.isDisabled) return;
setSelectedAccounts(prev =>
prev.includes(accountId)
? prev.filter(id => id !== accountId)
: [...prev, accountId],
);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
};
const handlePublish = async () => {
if (!content.trim()) {
message.warning("请输入朋友圈内容");
return;
}
if (selectedAccounts.length === 0) {
message.warning("请选择发布账号");
return;
}
setLoading(true);
try {
// 根据API要求构造数据
const publishData: any = {
content: content.trim(),
type: contentType.toString(),
wechatIds: selectedAccounts,
};
// 根据内容类型添加相应字段
switch (contentType) {
case 1: // 文本
// 文本类型只需要content
break;
case 2: // 图文
if (resUrls.length > 0) {
publishData.picUrlList = resUrls;
}
break;
case 3: // 视频
if (resUrls[0]) {
publishData.videoUrl = resUrls[0];
}
break;
case 4: // 链接
if (linkUrl) {
publishData["link[url]"] = [linkUrl];
if (linkDesc) {
publishData["link[desc]"] = [linkDesc];
}
if (linkImage) {
publishData["link[image]"] = [linkImage];
}
}
break;
}
// 添加定时发布时间
if (timingTime) {
// 将 datetime-local 格式转换为 "2025年10月20日17:06:34" 格式
const date = new Date(timingTime);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
const seconds = date.getSeconds().toString().padStart(2, "0");
publishData.timingTime = `${year}${month}${day}${hours}:${minutes}:${seconds}`;
}
console.log(publishData);
await addMoment(publishData);
message.success("发布成功!");
// 重置表单
setContent("");
setResUrls([]);
setLinkDesc("");
setLinkImage("");
setLinkUrl("");
setTimingTime("");
// 重新获取账号使用情况
await fetchAccountUsage();
// 触发父组件的刷新回调
onPublishSuccess?.();
} catch (error) {
console.error("发布失败:", error);
message.error("发布失败,请重试");
} finally {
setLoading(false);
}
};
return (
<div className={styles.momentPublish}>
<h3 className={styles.title}></h3>
{/* 选择发布账号 */}
<div className={styles.accountSection}>
<h4 className={styles.sectionTitle}></h4>
<div className={styles.accountList}>
{accounts.map(account => (
<Card
key={account.id}
className={`${styles.accountCard} ${
selectedAccounts.includes(account.id.toString())
? styles.selected
: ""
} ${account.isDisabled ? styles.disabled : ""}`}
onClick={() => handleAccountSelect(account.id.toString())}
>
<div className={styles.accountInfo}>
<div className={styles.accountName}>{account.name}</div>
<Badge
count={`${account.momentsNum}/${account.momentsMax}`}
className={styles.usageBadge}
color={
account.momentsNum >= account.momentsMax ? "red" : "blue"
}
/>
{account.isDisabled && (
<div className={styles.limitText}></div>
)}
</div>
</Card>
))}
</div>
</div>
{/* 朋友圈内容 */}
<div className={styles.contentSection}>
{/* 发布时间 */}
<div className={styles.formItem}>
<label className={styles.formLabel}></label>
<Input
type="datetime-local"
value={timingTime}
onChange={e => setTimingTime((e.target as HTMLInputElement).value)}
placeholder="请选择发布时间"
className={styles.contentInput}
/>
</div>
{/* 类型选择 */}
<div className={styles.formItem}>
<label className={styles.formLabel}></label>
<div>
<Select
value={contentType}
onChange={value => setContentType(value)}
className={styles.formSelect}
>
<Select.Option value={1}></Select.Option>
<Select.Option value={2}></Select.Option>
<Select.Option value={3}></Select.Option>
<Select.Option value={4}></Select.Option>
</Select>
</div>
</div>
{/* 文本内容 */}
<div className={styles.formItem}>
<label className={styles.formLabel}></label>
<Input.TextArea
value={content}
onChange={handleContentChange}
placeholder="请输入内容"
className={styles.contentInput}
rows={6}
showCount
maxLength={500}
/>
</div>
{/* 链接类型 */}
{contentType === 4 && (
<>
<div className={styles.formItem}>
<label className={styles.formLabel}></label>
<Input
value={linkDesc}
onChange={e =>
setLinkDesc((e.target as HTMLInputElement).value)
}
placeholder="请输入描述"
className={styles.contentInput}
/>
</div>
<div className={styles.formItem}>
<label className={styles.formLabel}></label>
<UploadComponent
value={linkImage ? [linkImage] : []}
onChange={urls => setLinkImage(urls[0] || "")}
count={1}
/>
</div>
<div className={styles.formItem}>
<label className={styles.formLabel}></label>
<Input
value={linkUrl}
onChange={e => setLinkUrl((e.target as HTMLInputElement).value)}
placeholder="请输入链接地址"
className={styles.contentInput}
/>
</div>
</>
)}
{/* 视频类型 */}
{contentType === 3 && (
<div className={styles.formItem}>
<label className={styles.formLabel}></label>
<div style={{ width: "40%" }}>
<VideoUpload
value={resUrls[0] || ""}
onChange={url => setResUrls([url as string])}
/>
</div>
</div>
)}
{/* 图片/小程序 素材上传 */}
{contentType === 2 && (
<div className={styles.formItem}>
<label className={styles.formLabel}> (9)</label>
<UploadComponent value={resUrls} onChange={setResUrls} count={9} />
</div>
)}
{/* 备注已移除 */}
</div>
{/* 发布按钮 */}
<div className={styles.publishSection}>
<Button
type="primary"
size="large"
onClick={handlePublish}
loading={loading}
disabled={selectedAccounts.length === 0 || !content.trim()}
className={styles.publishButton}
>
{loading ? "发布中..." : "发布朋友圈"}
</Button>
</div>
</div>
);
};
export default MomentPublish;

View File

@@ -0,0 +1,285 @@
import React from "react";
import { Modal, Card, Tag, Badge } from "antd";
import {
PictureOutlined,
VideoCameraOutlined,
LinkOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { listData } from "./api";
interface PreviewMomentModalProps {
visible: boolean;
onCancel: () => void;
momentData?: listData;
}
const PreviewMomentModal: React.FC<PreviewMomentModalProps> = ({
visible,
onCancel,
momentData,
}) => {
if (!momentData) return null;
// 获取内容类型信息
const getContentTypeInfo = (type: number) => {
switch (type) {
case 1:
return { icon: <FileTextOutlined />, label: "文本", color: "blue" };
case 2:
return { icon: <PictureOutlined />, label: "图文", color: "green" };
case 3:
return {
icon: <VideoCameraOutlined />,
label: "视频",
color: "purple",
};
case 4:
return { icon: <LinkOutlined />, label: "链接", color: "orange" };
default:
return { icon: <FileTextOutlined />, label: "未知", color: "default" };
}
};
// 格式化时间显示
const formatTime = (timestamp: number) => {
if (!timestamp) return "未设置";
const date = new Date(timestamp * 1000);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
// 获取状态信息
const getStatusInfo = (isSend: number) => {
switch (isSend) {
case 0:
return { status: "processing" as const, text: "待发布" };
case 1:
return { status: "success" as const, text: "已发布" };
case 2:
return { status: "error" as const, text: "发布失败" };
default:
return { status: "default" as const, text: "未知" };
}
};
const contentTypeInfo = getContentTypeInfo(momentData.momentContentType);
const statusInfo = getStatusInfo(momentData.isSend);
return (
<Modal
title="朋友圈预览"
open={visible}
onCancel={onCancel}
width={500}
footer={null}
>
<Card className="preview-card">
<div className="preview-header">
<div className="preview-status">
<Badge status={statusInfo.status} text={statusInfo.text} />
<Tag
color={contentTypeInfo.color}
icon={contentTypeInfo.icon}
style={{ marginLeft: 8 }}
>
{contentTypeInfo.label}
</Tag>
</div>
</div>
<div className="preview-content">
<div className="preview-text">{momentData.text || "无文本内容"}</div>
{/* 图片预览 */}
{momentData.picUrlList && momentData.picUrlList.length > 0 && (
<div className="preview-images">
{momentData.picUrlList.map((image, index) => (
<div key={index} className="preview-image-item">
<img
src={image}
alt={`图片${index + 1}`}
className="preview-image"
onError={e => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
))}
</div>
)}
{/* 视频预览 */}
{momentData.videoUrl && (
<div className="preview-video">
<div className="preview-video-icon">
<VideoCameraOutlined />
</div>
<span className="preview-video-text"></span>
</div>
)}
{/* 链接预览 */}
{momentData.link && momentData.link.length > 0 && (
<div className="preview-link">
<LinkOutlined className="preview-link-icon" />
<span className="preview-link-text">
{momentData.link.length}
</span>
</div>
)}
</div>
<div className="preview-details">
<div className="preview-detail-item">
<span className="preview-detail-label">:</span>
<span className="preview-detail-value">
{formatTime(momentData.sendTime)}
</span>
</div>
<div className="preview-detail-item">
<span className="preview-detail-label">:</span>
<span className="preview-detail-value">
{momentData.accountCount}
</span>
</div>
<div className="preview-detail-item">
<span className="preview-detail-label">:</span>
<span className="preview-detail-value">
{formatTime(momentData.createTime)}
</span>
</div>
</div>
</Card>
<style jsx>{`
.preview-card {
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.preview-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.preview-status {
display: flex;
align-items: center;
}
.preview-content {
margin-bottom: 16px;
}
.preview-text {
font-size: 14px;
line-height: 1.6;
color: #262626;
margin-bottom: 12px;
word-break: break-word;
}
.preview-images {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.preview-image-item {
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #e8e8e8;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-video {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f0f8ff;
border: 1px solid #d6e4ff;
border-radius: 6px;
margin-bottom: 12px;
}
.preview-video-icon {
color: #1890ff;
font-size: 16px;
}
.preview-video-text {
color: #1890ff;
font-size: 14px;
}
.preview-link {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 6px;
margin-bottom: 12px;
}
.preview-link-icon {
color: #fa8c16;
font-size: 16px;
}
.preview-link-text {
color: #fa8c16;
font-size: 14px;
}
.preview-details {
padding: 12px;
background: #fafafa;
border-radius: 6px;
border-left: 3px solid #1890ff;
}
.preview-detail-item {
display: flex;
align-items: center;
font-size: 12px;
margin-bottom: 4px;
}
.preview-detail-item:last-child {
margin-bottom: 0;
}
.preview-detail-label {
color: #8c8c8c;
min-width: 80px;
margin-right: 8px;
}
.preview-detail-value {
color: #595959;
font-weight: 500;
}
`}</style>
</Modal>
);
};
export default PreviewMomentModal;

View File

@@ -0,0 +1,352 @@
.publishSchedule {
padding: 24px;
background: #fff;
border-radius: 8px;
height: 100%;
display: flex;
flex-direction: column;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
.title {
font-size: 18px;
font-weight: 600;
color: #262626;
margin: 0;
}
.headerActions {
display: flex;
gap: 8px;
}
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #8c8c8c;
.loadingText {
margin-top: 12px;
font-size: 14px;
}
}
.scheduleList {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
&:hover {
background: #a8a8a8;
}
}
}
.scheduleCard {
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
:global(.ant-card-body) {
padding: 20px;
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
.statusBadge {
:global(.ant-badge) {
font-size: 12px;
}
:global(.ant-badge-status-text) {
color: #1890ff;
font-weight: 500;
}
}
.typeTag {
font-size: 12px;
border-radius: 4px;
}
}
.headerActions {
display: flex;
align-items: center;
gap: 4px;
.actionButton {
color: #1890ff;
opacity: 0.7;
transition: all 0.3s;
&:hover {
opacity: 1;
background-color: #f0f8ff;
}
}
.deleteButton {
color: #ff4d4f;
opacity: 0.7;
transition: all 0.3s;
&:hover {
opacity: 1;
background-color: #fff2f0;
}
}
}
}
.cardContent {
.postContent {
font-size: 14px;
line-height: 1.6;
color: #262626;
margin-bottom: 12px;
word-break: break-word;
}
.postImages {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
.imagePlaceholder {
width: 60px;
height: 60px;
background: #f5f5f5;
border: 1px solid #e8e8e8;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
.imagePreview {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
.imageIcon {
font-size: 20px;
opacity: 0.6;
display: none;
&.show {
display: flex;
}
}
}
.moreImages {
width: 60px;
height: 60px;
background: rgba(0, 0, 0, 0.6);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
font-weight: 500;
}
}
.videoPreview {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 8px 12px;
background: #f0f8ff;
border: 1px solid #d6e4ff;
border-radius: 6px;
.videoIcon {
color: #1890ff;
font-size: 16px;
}
.videoText {
color: #1890ff;
font-size: 14px;
}
}
.linkPreview {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding: 8px 12px;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 6px;
.linkIcon {
color: #fa8c16;
font-size: 16px;
}
.linkText {
color: #fa8c16;
font-size: 14px;
}
}
.postDetails {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: #fafafa;
border-radius: 6px;
border-left: 3px solid #1890ff;
.detailItem {
display: flex;
align-items: center;
font-size: 12px;
.detailIcon {
color: #8c8c8c;
margin-right: 6px;
font-size: 12px;
}
.detailLabel {
color: #8c8c8c;
min-width: 60px;
margin-right: 8px;
}
.detailValue {
color: #595959;
font-weight: 500;
}
}
}
}
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 8px;
margin: 20px 0;
.emptyIcon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.emptyText {
font-size: 16px;
color: #8c8c8c;
margin-bottom: 8px;
font-weight: 500;
}
.emptySubText {
font-size: 14px;
color: #bfbfbf;
}
}
.paginationInfo {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
text-align: center;
.paginationText {
font-size: 12px;
color: #8c8c8c;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.publishSchedule {
padding: 16px;
.scheduleList {
max-height: calc(100vh - 150px);
}
.scheduleCard {
:global(.ant-card-body) {
padding: 16px;
}
.cardContent {
.postDetails {
.detailItem {
flex-direction: column;
align-items: flex-start;
gap: 4px;
.detailLabel {
min-width: auto;
margin-right: 0;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,371 @@
import React, {
useState,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import {
Card,
Badge,
Button,
message,
Popconfirm,
Empty,
Spin,
Tag,
Tooltip,
} from "antd";
import {
DeleteOutlined,
EditOutlined,
EyeOutlined,
ClockCircleOutlined,
UserOutlined,
PictureOutlined,
VideoCameraOutlined,
LinkOutlined,
FileTextOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import { getMomentList, deleteMoment, listData } from "./api";
import EditMomentModal from "./EditMomentModal";
import PreviewMomentModal from "./PreviewMomentModal";
import styles from "./PublishSchedule.module.scss";
// 定义组件暴露的方法接口
export interface PublishScheduleRef {
refresh: () => void;
}
const PublishSchedule = forwardRef<PublishScheduleRef>((props, ref) => {
const [scheduledPosts, setScheduledPosts] = useState<listData[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0,
});
const [editModalVisible, setEditModalVisible] = useState(false);
const [previewModalVisible, setPreviewModalVisible] = useState(false);
const [selectedMoment, setSelectedMoment] = useState<listData | undefined>();
// 获取内容类型图标和标签
const getContentTypeInfo = (type: number) => {
switch (type) {
case 1:
return { icon: <FileTextOutlined />, label: "文本", color: "blue" };
case 2:
return { icon: <PictureOutlined />, label: "图文", color: "green" };
case 3:
return {
icon: <VideoCameraOutlined />,
label: "视频",
color: "purple",
};
case 4:
return { icon: <LinkOutlined />, label: "链接", color: "orange" };
case 5:
return { icon: <AppstoreOutlined />, label: "小程序", color: "cyan" };
default:
return { icon: <FileTextOutlined />, label: "未知", color: "default" };
}
};
// 格式化时间显示
const formatTime = (timestamp: number) => {
if (!timestamp) return "未设置";
const date = new Date(timestamp * 1000);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
// 获取发布计划列表
const fetchScheduledMoments = useCallback(
async (page = 1) => {
setLoading(true);
try {
const response = await getMomentList({ page, limit: pagination.limit });
setScheduledPosts(response.list);
setPagination(prev => ({ ...prev, page, total: response.total }));
} catch (error) {
console.error("获取发布计划失败:", error);
message.error("获取发布计划失败");
} finally {
setLoading(false);
}
},
[pagination.limit],
);
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
refresh: () => {
fetchScheduledMoments(pagination.page);
},
}));
// 组件挂载时获取数据
useEffect(() => {
fetchScheduledMoments();
}, [fetchScheduledMoments]);
const handleDeletePost = async (postId: number) => {
try {
const success = await deleteMoment({ id: postId });
if (success) {
setScheduledPosts(prev => prev.filter(post => post.id !== postId));
message.success("删除成功");
// 如果当前页没有数据了,回到上一页
if (scheduledPosts.length === 1 && pagination.page > 1) {
fetchScheduledMoments(pagination.page - 1);
}
} else {
message.error("删除失败,请重试");
}
} catch (error) {
console.error("删除发布计划失败:", error);
message.error("删除失败,请重试");
}
};
const handleEditPost = (post: listData) => {
setSelectedMoment(post);
setEditModalVisible(true);
};
const handleViewPost = (post: listData) => {
setSelectedMoment(post);
setPreviewModalVisible(true);
};
const handleEditSuccess = () => {
// 编辑成功后刷新列表
fetchScheduledMoments(pagination.page);
};
const getStatusBadge = (isSend: number) => {
switch (isSend) {
case 0:
return <Badge status="processing" text="待发布" />;
case 1:
return <Badge status="success" text="已发布" />;
case 2:
return <Badge status="error" text="发布失败" />;
default:
return <Badge status="default" text="未知" />;
}
};
const handleRefresh = () => {
fetchScheduledMoments(pagination.page);
};
return (
<div className={styles.publishSchedule}>
<div className={styles.header}>
<h3 className={styles.title}></h3>
<div className={styles.headerActions}>
<Button
type="text"
icon={<ClockCircleOutlined />}
onClick={handleRefresh}
loading={loading}
>
</Button>
</div>
</div>
<div className={styles.scheduleList}>
{loading ? (
<div className={styles.loadingContainer}>
<Spin size="large" />
<div className={styles.loadingText}>...</div>
</div>
) : scheduledPosts.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div>
<div className={styles.emptyText}></div>
<div className={styles.emptySubText}>
</div>
</div>
}
/>
) : (
scheduledPosts.map(post => {
const contentTypeInfo = getContentTypeInfo(post.momentContentType);
return (
<Card key={post.id} className={styles.scheduleCard}>
<div className={styles.cardHeader}>
<div className={styles.headerLeft}>
<div className={styles.statusBadge}>
{getStatusBadge(post.isSend)}
</div>
<Tag
color={contentTypeInfo.color}
icon={contentTypeInfo.icon}
className={styles.typeTag}
>
{contentTypeInfo.label}
</Tag>
</div>
<div className={styles.headerActions}>
<Tooltip title="预览">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewPost(post)}
size="small"
className={styles.actionButton}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleEditPost(post)}
size="small"
className={styles.actionButton}
/>
</Tooltip>
<Popconfirm
title="确定要删除这条发布计划吗?"
onConfirm={() => handleDeletePost(post.id)}
okText="确定"
cancelText="取消"
>
<Tooltip title="删除">
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
className={styles.deleteButton}
/>
</Tooltip>
</Popconfirm>
</div>
</div>
<div className={styles.cardContent}>
<div className={styles.postContent}>
{post.content || "无文本内容"}
</div>
{/* 图片展示 */}
{post.picUrlList && post.picUrlList.length > 0 && (
<div className={styles.postImages}>
{post.picUrlList.slice(0, 3).map((image, index) => (
<div key={index} className={styles.imagePlaceholder}>
<img
src={image}
alt={`图片${index + 1}`}
className={styles.imagePreview}
onError={e => {
(e.target as HTMLImageElement).style.display =
"none";
(
e.target as HTMLImageElement
).nextElementSibling?.classList.add(styles.show);
}}
/>
<div className={styles.imageIcon}>
<PictureOutlined />
</div>
</div>
))}
{post.picUrlList.length > 3 && (
<div className={styles.moreImages}>
+{post.picUrlList.length - 3}
</div>
)}
</div>
)}
{/* 视频展示 */}
{post.videoUrl && (
<div className={styles.videoPreview}>
<div className={styles.videoIcon}>
<VideoCameraOutlined />
</div>
<span className={styles.videoText}></span>
</div>
)}
{/* 链接展示 */}
{post.link && post.link.length > 0 && (
<div className={styles.linkPreview}>
<LinkOutlined className={styles.linkIcon} />
<span className={styles.linkText}>
{post.link.length}
</span>
</div>
)}
<div className={styles.postDetails}>
<div className={styles.detailItem}>
<ClockCircleOutlined className={styles.detailIcon} />
<span className={styles.detailLabel}>:</span>
<span className={styles.detailValue}>
{formatTime(post.sendTime)}
</span>
</div>
<div className={styles.detailItem}>
<UserOutlined className={styles.detailIcon} />
<span className={styles.detailLabel}>:</span>
<span className={styles.detailValue}>
{post.accountCount}
</span>
</div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}>:</span>
<span className={styles.detailValue}>
{formatTime(post.createTime)}
</span>
</div>
</div>
</div>
</Card>
);
})
)}
</div>
{/* 分页信息 */}
{scheduledPosts.length > 0 && (
<div className={styles.paginationInfo}>
<span className={styles.paginationText}>
{pagination.total} {pagination.page}
</span>
</div>
)}
{/* 编辑弹窗 */}
<EditMomentModal
visible={editModalVisible}
onCancel={() => setEditModalVisible(false)}
onSuccess={handleEditSuccess}
momentData={selectedMoment}
/>
{/* 预览弹窗 */}
<PreviewMomentModal
visible={previewModalVisible}
onCancel={() => setPreviewModalVisible(false)}
momentData={selectedMoment}
/>
</div>
);
});
PublishSchedule.displayName = "PublishSchedule";
export default PublishSchedule;

View File

@@ -0,0 +1,86 @@
import request from "@/api/request";
export interface listData {
id: number;
content: "";
momentContentType: number;
picUrlList: string[];
videoUrl: string;
link: string[];
publicMode: number;
isSend: number;
createTime: number;
sendTime: number;
accountCount: number;
}
interface listResponse {
list: listData[];
total: number;
}
// 朋友圈定时发布 - 列表
export const getMomentList = (data: {
page: number;
limit: number;
}): Promise<listResponse> => {
return request("/v1/kefu/moments/list", data, "GET");
};
export interface MomentRequest {
id?: number;
/**
* 朋友圈内容
*/
content: string;
/**
* 标签列表
*/
"labels[]"?: string[];
/**
* 链接信息-描述 type4有效
*/
"link[desc]"?: string[];
/**
* 链接信息-图标 type4有效
*/
"link[image]"?: string[];
/**
* 链接信息-链接 type4有效
*/
"link[url]"?: string[];
/**
* 图片列表 type2有效
*/
"picUrlList[]"?: string[];
/**
* 定时发布时间
*/
timingTime?: string;
/**
* 内容类型 1文本 2图文 3视频 4链接
*/
type: string;
/**
* 视频链接 type3有效
*/
videoUrl?: string;
/**
* 微信账号ID列表
*/
"wechatIds[]"?: string[];
[property: string]: any;
}
// 朋友圈定时发布 - 添加
export const addMoment = (data: MomentRequest) => {
return request("/v1/kefu/moments/add", data, "POST");
};
// 朋友圈定时发布 - 编辑
export const updateMoment = (data: MomentRequest) => {
return request("/v1/kefu/moments/update", data, "POST");
};
// 朋友圈定时发布 - 删除
export const deleteMoment = (data: { id: number }) => {
return request("/v1/kefu/moments/delete", data, "DELETE");
};

View File

@@ -1,9 +1,4 @@
// 管理组件导出
export { default as MaterialManagement } from "./management/MaterialManagement";
export { default as SensitiveWordManagement } from "./management/SensitiveWordManagement";
export { default as KeywordManagement } from "./management/KeywordManagement";
// 模态框组件导出
export { default as MaterialModal } from "./modals/MaterialModal";
export { default as SensitiveWordModal } from "./modals/SensitiveWordModal";
export { default as KeywordModal } from "./modals/KeywordModal";
export { default as MomentPublish } from "./MomentPublish";
export { default as PublishSchedule } from "./PublishSchedule";
export { default as EditMomentModal } from "./EditMomentModal";
export { default as PreviewMomentModal } from "./PreviewMomentModal";

View File

@@ -1,318 +0,0 @@
import React, {
useState,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import {
Button,
Input,
Tag,
Switch,
message,
Popconfirm,
Pagination,
} from "antd";
import {
SearchOutlined,
FormOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import styles from "./index.module.scss";
import {
getKeywordList,
deleteKeyword,
setKeywordStatus,
type KeywordListParams,
} from "../../api";
import KeywordModal from "../modals/KeywordModal";
const { Search } = Input;
interface KeywordItem {
id?: number;
type: number;
replyType: number;
title: string;
keywords: string;
status: number;
content: string;
metailGroupsOptions: { title: string; id: number }[];
level: number;
}
const KeywordManagement = forwardRef<any, Record<string, never>>(
(props, ref) => {
const [searchValue, setSearchValue] = useState<string>("");
const [keywordsList, setKeywordsList] = useState<KeywordItem[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
const [editingKeywordId, setEditingKeywordId] = useState<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: number) => {
switch (replyType) {
case 0:
return "素材回复";
case 1:
return "自定义";
default:
return "未知类型";
}
};
// 回复类型颜色
const getReplyTypeColor = (replyType: number) => {
switch (replyType) {
case 0:
return "blue";
case 1:
return "purple";
default:
return "gray";
}
};
// 获取关键词列表(服务端搜索)
const fetchKeywords = async (params?: KeywordListParams) => {
try {
setLoading(true);
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 || "获取关键词列表失败");
}
} catch (error) {
console.error("获取关键词列表失败:", error);
setKeywordsList([]);
message.error("获取关键词列表失败");
} finally {
setLoading(false);
}
};
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
fetchKeywords,
}));
// 关键词管理相关函数
const handleToggleKeyword = async (id: number) => {
try {
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: number) => {
setEditingKeywordId(id);
setEditModalVisible(true);
};
// 编辑弹窗成功回调
const handleEditSuccess = () => {
fetchKeywords(); // 重新获取数据
};
const handleDeleteKeyword = async (id: number) => {
try {
await deleteKeyword(id);
setKeywordsList(prev => prev.filter(item => item.id !== id));
message.success("删除成功");
} catch (error) {
console.error("删除失败:", error);
message.error("删除失败");
}
};
// 移除本地筛选,改为服务端搜索,列表直接使用 keywordsList
// 搜索处理函数
const handleSearch = (value: string) => {
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}>
<div className={styles.searchSection}>
<Search
placeholder="搜索关键词..."
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
onSearch={handleSearch}
style={{ width: 300 }}
prefix={<SearchOutlined />}
/>
</div>
<div className={styles.keywordList}>
{loading ? (
<div className={styles.loading}>...</div>
) : keywordsList.length === 0 ? (
<div className={styles.empty}></div>
) : (
keywordsList.map(item => (
<div key={item.id} className={styles.keywordItem}>
<div className={styles.itemContent}>
<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>
</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}
mode="edit"
keywordId={editingKeywordId}
onCancel={() => {
setEditModalVisible(false);
setEditingKeywordId(null);
}}
onSuccess={handleEditSuccess}
/>
</div>
);
},
);
KeywordManagement.displayName = "KeywordManagement";
export default KeywordManagement;

View File

@@ -1,320 +0,0 @@
import React, {
useState,
useEffect,
forwardRef,
useImperativeHandle,
useRef,
} from "react";
import { Button, Input, Card, message, Popconfirm, Pagination } from "antd";
import {
SearchOutlined,
FilterOutlined,
FormOutlined,
FileTextOutlined,
FileImageOutlined,
PlayCircleOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import styles from "../../index.module.scss";
import {
getMaterialList,
deleteMaterial,
type MaterialListParams,
} from "../../api";
import MaterialModal from "../modals/MaterialModal";
const { Search } = Input;
interface MaterialItem {
id: number;
companyId: number;
userId: number;
title: string;
content: string;
cover: string;
status: number;
type: string; // 素材类型:文本、图片、视频
createTime: string;
updateTime: string;
isDel: number;
delTime: string | null;
userName: string;
}
const MaterialManagement = forwardRef<any, Record<string, never>>(
(props, ref) => {
const [searchValue, setSearchValue] = useState<string>("");
const [materialsList, setMaterialsList] = useState<MaterialItem[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [editModalVisible, setEditModalVisible] = useState<boolean>(false);
const [editingMaterialId, setEditingMaterialId] = useState<number | null>(
null,
);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
// 使用 ref 来存储最新的分页状态
const paginationRef = useRef(pagination);
paginationRef.current = pagination;
// 获取类型图标
const getTypeIcon = (type: string) => {
switch (type) {
case "文本":
return <FileTextOutlined className={styles.typeIcon} />;
case "图片":
return <FileImageOutlined className={styles.typeIcon} />;
case "视频":
return <PlayCircleOutlined className={styles.typeIcon} />;
default:
return <FileTextOutlined className={styles.typeIcon} />;
}
};
// 获取素材列表
const fetchMaterials = async (params?: MaterialListParams) => {
try {
setLoading(true);
const 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 || "获取素材列表失败");
}
} catch (error) {
console.error("获取素材列表失败:", error);
setMaterialsList([]);
message.error("获取素材列表失败");
} finally {
setLoading(false);
}
};
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
fetchMaterials,
}));
// 素材管理相关函数
const handleDeleteMaterial = async (id: number) => {
try {
await deleteMaterial(id.toString());
setMaterialsList(prev => prev.filter(item => item.id !== id));
message.success("删除成功");
} catch (error) {
message.error("删除失败");
}
};
// 编辑素材
const handleEditMaterial = (id: number) => {
setEditingMaterialId(id);
setEditModalVisible(true);
};
// 编辑弹窗成功回调
const handleEditSuccess = () => {
fetchMaterials(); // 重新获取数据
};
// 搜索处理函数
const handleSearch = (value: string) => {
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();
}, []);
return (
<div className={styles.materialContent}>
<div className={styles.searchSection}>
<Search
placeholder="搜索素材..."
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
onSearch={handleSearch}
style={{ width: 300 }}
prefix={<SearchOutlined />}
/>
<Button icon={<FilterOutlined />}></Button>
</div>
<div className={styles.materialGrid}>
{loading ? (
<div className={styles.loading}>...</div>
) : materialsList.length === 0 ? (
<div className={styles.empty}></div>
) : (
materialsList.map(item => (
<Card
key={item.id}
className={styles.materialCard}
hoverable
actions={[
<Button
key="edit"
type="text"
icon={<FormOutlined />}
onClick={e => {
e.stopPropagation();
handleEditMaterial(item.id);
}}
>
</Button>,
<Popconfirm
key="delete"
title="确认删除"
description="确定要删除这个素材吗?删除后无法恢复。"
onConfirm={() => handleDeleteMaterial(item.id)}
okText="确定"
cancelText="取消"
okType="danger"
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={e => e.stopPropagation()}
>
</Button>
</Popconfirm>,
]}
>
<div
className={styles.thumbnail}
onClick={() => handleEditMaterial(item.id)}
style={{ cursor: "pointer" }}
>
{item.cover ? (
<div
style={{
position: "relative",
width: "100%",
height: "100%",
}}
>
<img
src={item.cover}
alt={item.title}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
<div
style={{
position: "absolute",
top: "4px",
right: "4px",
background: "rgba(0, 0, 0, 0.6)",
color: "white",
padding: "2px 6px",
borderRadius: "4px",
fontSize: "10px",
fontWeight: "500",
}}
>
{item.type}
</div>
</div>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f5f5f5",
color: "#999",
flexDirection: "column",
}}
>
{getTypeIcon(item.type)}
<span style={{ marginTop: "8px", fontSize: "12px" }}>
{item.type}
</span>
</div>
)}
</div>
<div className={styles.cardContent}>
<div className={styles.title}>{item.title}</div>
<div className={styles.meta}>
<div>: {item.userName}</div>
<div>{item.createTime}</div>
</div>
</div>
</Card>
))
)}
</div>
{/* 分页组件 */}
<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}
mode="edit"
materialId={editingMaterialId}
onCancel={() => {
setEditModalVisible(false);
setEditingMaterialId(null);
}}
onSuccess={handleEditSuccess}
/>
</div>
);
},
);
MaterialManagement.displayName = "MaterialManagement";
export default MaterialManagement;

View File

@@ -1,293 +0,0 @@
import React, {
useState,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
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 {
getSensitiveWordList,
deleteSensitiveWord,
setSensitiveWordStatus,
type SensitiveWordListParams,
} from "../../api";
import SensitiveWordModal from "../modals/SensitiveWordModal";
const { Search } = Input;
interface SensitiveWordItem {
id: string;
title: string;
keywords: string;
content: string;
operation: number;
status: number;
}
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 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 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("获取敏感词列表失败");
} finally {
setLoading(false);
}
};
// 暴露方法给父组件
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("状态更新失败");
}
};
const handleEditSensitiveWord = (id: string) => {
setEditingSensitiveWordId(id);
setEditModalVisible(true);
};
// 编辑弹窗成功回调
const handleEditSuccess = () => {
fetchSensitiveWords(); // 重新获取数据
};
const handleDeleteSensitiveWord = async (id: string) => {
try {
await deleteSensitiveWord(id);
setSensitiveWordsList(prev => prev.filter(item => item.id !== id));
message.success("删除成功");
} 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]);
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>
<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>
{/* 分页组件 */}
<div style={{ marginTop: 16, textAlign: "right" }}>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={pagination.total}
showSizeChanger
showQuickJumper
showTotal={(total, range) =>
`${range[0]}-${range[1]} 条/共 ${total}`
}
onChange={handlePageChange}
onShowSizeChange={handlePageChange}
/>
</div>
{/* 编辑弹窗 */}
<SensitiveWordModal
visible={editModalVisible}
mode="edit"
sensitiveWordId={editingSensitiveWordId}
onCancel={() => {
setEditModalVisible(false);
setEditingSensitiveWordId(null);
}}
onSuccess={handleEditSuccess}
/>
</div>
);
},
);
SensitiveWordManagement.displayName = "SensitiveWordManagement";
export default SensitiveWordManagement;

View File

@@ -1,252 +0,0 @@
// 关键词管理样式
.keywordContent {
.searchSection {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
:global(.ant-input-search) {
width: 300px;
}
:global(.ant-btn) {
height: 32px;
border-radius: 6px;
}
}
.keywordList {
display: flex;
flex-direction: column;
gap: 12px;
}
.keywordItem {
padding: 16px 20px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: all 0.3s;
&:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.itemContent {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
.leftSection {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
.titleRow {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
.title {
font-size: 16px;
font-weight: 500;
color: #262626;
margin: 0;
}
.tags {
display: flex;
gap: 8px;
.matchTag,
.priorityTag {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
background: #f0f0f0;
color: #666;
border: none;
}
}
}
.description {
font-size: 14px;
color: #666;
line-height: 1.5;
margin-bottom: 8px;
}
.replyTypeTag {
font-size: 12px;
padding: 2px 8px;
border-radius: 20px;
background: #fff;
color: #fff;
}
}
.rightSection {
display: flex;
align-items: center;
gap: 8px;
.toggleSwitch {
:global(.ant-switch) {
background-color: #d9d9d9;
}
:global(.ant-switch-checked) {
background-color: #1890ff;
}
}
.actionBtn {
width: 28px;
height: 28px;
padding: 0;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: #f5f5f5;
}
.editIcon {
font-size: 14px;
color: #1890ff;
}
.deleteIcon {
font-size: 14px;
color: #ff4d4f;
}
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.container {
padding: 16px;
}
.headerActions {
flex-direction: column;
align-items: stretch;
:global(.ant-btn) {
width: 100%;
}
}
.tabs {
flex-direction: column;
padding: 0;
.tab {
border-bottom: 1px solid #e8e8e8;
border-radius: 0;
&:last-child {
border-bottom: none;
}
}
}
.materialContent {
.searchSection {
flex-direction: column;
gap: 12px;
align-items: stretch;
:global(.ant-input-search) {
width: 100%;
}
}
.materialGrid {
grid-template-columns: 1fr;
gap: 16px;
}
}
.sensitiveContent {
.searchSection {
flex-direction: column;
gap: 12px;
align-items: stretch;
:global(.ant-input-search) {
width: 100%;
}
}
.sensitiveItem {
flex-direction: column;
align-items: stretch;
gap: 12px;
.itemContent {
flex-direction: column;
align-items: flex-start;
gap: 8px;
.categoryName {
min-width: auto;
}
}
.itemActions {
justify-content: flex-end;
}
}
}
.keywordContent {
.searchSection {
flex-direction: column;
gap: 12px;
align-items: stretch;
:global(.ant-input-search) {
width: 100%;
}
}
.keywordItem {
.itemContent {
flex-direction: column;
gap: 12px;
.leftSection {
.titleRow {
flex-direction: column;
align-items: flex-start;
gap: 8px;
.tags {
flex-wrap: wrap;
}
}
}
.rightSection {
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
}
}
}
}

View File

@@ -1,4 +0,0 @@
// 管理组件统一导出
export { default as MaterialManagement } from "./MaterialManagement";
export { default as SensitiveWordManagement } from "./SensitiveWordManagement";
export { default as KeywordManagement } from "./KeywordManagement";

View File

@@ -1,276 +0,0 @@
import React, { useState, useEffect } from "react";
import { Button, Input, Select } from "antd";
import {
PlusOutlined,
DeleteOutlined,
FileTextOutlined,
FileImageOutlined,
PlayCircleOutlined,
FileOutlined,
SoundOutlined,
LinkOutlined,
} from "@ant-design/icons";
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
import VideoUpload from "@/components/Upload/VideoUpload";
import FileUpload from "@/components/Upload/FileUpload";
import AudioUpload from "@/components/Upload/AudioUpload";
import type { ContentItem, LinkData } from "../../api";
const { TextArea } = Input;
const { Option } = Select;
interface ContentManagerProps {
value?: ContentItem[];
onChange?: (content: ContentItem[]) => void;
}
const ContentManager: React.FC<ContentManagerProps> = ({
value = [],
onChange,
}) => {
const [contentItems, setContentItems] = useState<ContentItem[]>(value);
// 内容类型配置
const contentTypes = [
{ value: "text", label: "文本", icon: <FileTextOutlined /> },
{ value: "image", label: "图片", icon: <FileImageOutlined /> },
{ value: "video", label: "视频", icon: <PlayCircleOutlined /> },
{ value: "file", label: "文件", icon: <FileOutlined /> },
{ value: "audio", label: "音频", icon: <SoundOutlined /> },
{ value: "link", label: "链接", icon: <LinkOutlined /> },
];
// 同步外部value到内部state
useEffect(() => {
setContentItems(value);
}, [value]);
// 初始化时添加默认文本内容项
useEffect(() => {
if (contentItems.length === 0) {
const defaultTextItem: ContentItem = {
type: "text",
data: "",
};
setContentItems([defaultTextItem]);
onChange?.([defaultTextItem]);
}
}, [contentItems.length, onChange]);
// 更新内容项
const updateContentItems = (newItems: ContentItem[]) => {
setContentItems(newItems);
onChange?.(newItems);
};
// 添加新内容项
const handleAddItem = () => {
const newItem: ContentItem = {
type: "text",
data: "",
};
const newItems = [...contentItems, newItem];
updateContentItems(newItems);
};
// 删除内容项
const handleDeleteItem = (index: number) => {
const newItems = contentItems.filter((_, i) => i !== index);
updateContentItems(newItems);
};
// 更新内容项数据
const updateItemData = (index: number, data: any) => {
const newItems = [...contentItems];
newItems[index] = { ...newItems[index], data };
updateContentItems(newItems);
};
// 更新内容项类型
const updateItemType = (index: number, newType: string) => {
const newItems = [...contentItems];
// 根据新类型重置数据
let newData: any;
if (newType === "link") {
newData = { title: "", url: "", cover: "" };
} else {
newData = "";
}
newItems[index] = {
type: newType as any,
data: newData,
};
updateContentItems(newItems);
};
// 渲染内容项
const renderContentItem = (item: ContentItem, index: number) => {
return (
<div
key={index}
style={{
border: "1px solid #d9d9d9",
borderRadius: "6px",
padding: "12px",
marginBottom: "8px",
backgroundColor: "#fafafa",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "8px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Select
value={item.type}
onChange={newType => updateItemType(index, newType)}
style={{ width: 120 }}
size="small"
>
{contentTypes.map(type => (
<Option key={type.value} value={type.value}>
{type.icon} {type.label}
</Option>
))}
</Select>
</div>
{index !== 0 && (
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteItem(index)}
/>
)}
</div>
{renderContentInput(item, index)}
</div>
);
};
// 渲染内容输入
const renderContentInput = (item: ContentItem, index: number) => {
switch (item.type) {
case "text":
return (
<TextArea
value={item.data as string}
onChange={e => updateItemData(index, e.target.value)}
placeholder="请输入文本内容"
rows={3}
/>
);
case "image":
return (
<ImageUpload
count={1}
accept="image/*"
value={item.data ? [item.data as string] : []}
onChange={urls => updateItemData(index, urls[0] || "")}
/>
);
case "video":
return (
<VideoUpload
value={item.data as string}
onChange={url => {
const videoUrl = Array.isArray(url) ? url[0] || "" : url || "";
updateItemData(index, videoUrl);
}}
maxSize={50}
showPreview={true}
/>
);
case "file":
return (
<FileUpload
value={item.data as string}
onChange={url => {
const fileUrl = Array.isArray(url) ? url[0] || "" : url || "";
updateItemData(index, fileUrl);
}}
maxSize={10}
showPreview={true}
acceptTypes={["excel", "word", "ppt", "pdf", "txt"]}
/>
);
case "audio":
return (
<AudioUpload
value={item.data as string}
onChange={url => {
const audioUrl = Array.isArray(url) ? url[0] || "" : url || "";
updateItemData(index, audioUrl);
}}
maxSize={50}
showPreview={true}
/>
);
case "link": {
const linkData = (item.data as LinkData) || {
title: "",
url: "",
cover: "",
};
return (
<div>
<Input
value={linkData.title}
onChange={e =>
updateItemData(index, { ...linkData, title: e.target.value })
}
placeholder="链接标题"
style={{ marginBottom: 8 }}
/>
<Input
value={linkData.url}
onChange={e =>
updateItemData(index, { ...linkData, url: e.target.value })
}
placeholder="链接URL"
style={{ marginBottom: 8 }}
/>
<ImageUpload
count={1}
accept="image/*"
value={linkData.cover ? [linkData.cover] : []}
onChange={urls =>
updateItemData(index, { ...linkData, cover: urls[0] || "" })
}
/>
</div>
);
}
default:
return null;
}
};
return (
<div>
{/* 内容列表 */}
{contentItems.map((item, index) => renderContentItem(item, index))}
{/* 添加内容区域 */}
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={handleAddItem}
>
</Button>
</div>
);
};
export default ContentManager;

View File

@@ -1,288 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { Modal, Form, Input, Button, message, Select } from "antd";
import {
addKeyword,
updateKeyword,
getKeywordDetails,
type KeywordAddRequest,
type KeywordUpdateRequest,
} from "../../api";
import MetailSelection from "@/components/MetailSelection";
const { TextArea } = Input;
const { Option } = Select;
interface KeywordModalProps {
visible: boolean;
mode: "add" | "edit";
keywordId?: number | null;
onCancel: () => void;
onSuccess: () => void;
}
const KeywordModal: React.FC<KeywordModalProps> = ({
visible,
mode,
keywordId,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const title = mode === "add" ? "添加关键词回复" : "编辑关键词回复";
const [selectedOptions, setSelectedOptions] = useState<any[]>([]);
// 获取关键词详情
const fetchKeywordDetails = useCallback(
async (id: number) => {
try {
const response = await getKeywordDetails(id);
if (response) {
const keyword = response;
form.setFieldsValue({
title: keyword.title,
keywords: keyword.keywords,
content: keyword.content,
type: keyword.type,
level: keyword.level,
replyType: keyword.replyType,
status: keyword.status,
metailGroups: keyword.metailGroups,
});
setSelectedOptions(keyword.metailGroupsOptions);
}
} catch (error) {
console.error("获取关键词详情失败:", error);
message.error("获取关键词详情失败");
}
},
[form],
);
// 当弹窗打开时处理数据
useEffect(() => {
if (visible) {
if (mode === "edit" && keywordId) {
// 编辑模式:获取详情
fetchKeywordDetails(keywordId);
} else if (mode === "add") {
// 添加模式:重置表单
form.resetFields();
setSelectedOptions([]);
}
}
}, [visible, mode, keywordId, fetchKeywordDetails, form]);
const handleSubmit = async (values: any) => {
try {
setLoading(true);
if (mode === "add") {
const data: KeywordAddRequest = {
title: values.title,
keywords: values.keywords,
content: values.content,
type: values.type,
level: values.level,
replyType: values.replyType,
status: values.status || "1",
metailGroups: values.metailGroups,
};
const response = await addKeyword(data);
if (response) {
message.success("添加关键词成功");
form.resetFields();
onSuccess();
onCancel();
} else {
message.error(response?.message || "添加关键词失败");
}
} else {
const data: KeywordUpdateRequest = {
id: keywordId,
title: values.title,
keywords: values.keywords,
content: values.content,
type: values.type,
level: values.level,
replyType: values.replyType,
status: values.status,
metailGroups: values.metailGroups,
};
const response = await updateKeyword(data);
if (response) {
message.success("更新关键词回复成功");
form.resetFields();
onSuccess();
onCancel();
} else {
message.error(response?.message || "更新关键词回复失败");
}
}
} catch (error) {
console.error(`${mode === "add" ? "添加" : "更新"}关键词失败:`, error);
message.error(`${mode === "add" ? "添加" : "更新"}关键词失败`);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
setSelectedOptions([]);
onCancel();
};
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}
open={visible}
onCancel={handleCancel}
footer={null}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
onValuesChange={handleFormValuesChange}
initialValues={{
status: 1,
type: "模糊匹配",
level: 1,
replyType: 0,
}}
>
<Form.Item
name="title"
label="关键词标题"
rules={[{ required: true, message: "请输入关键词标题" }]}
>
<Input placeholder="请输入关键词标题" />
</Form.Item>
<Form.Item
name="keywords"
label="关键词"
rules={[{ required: true, message: "请输入关键词" }]}
>
<Input placeholder="请输入关键词" />
</Form.Item>
<Form.Item
name="replyType"
label="回复类型"
rules={[{ required: true, message: "请选择回复类型" }]}
>
<Select placeholder="请选择回复类型">
<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>
<Form.Item
name="status"
label="状态"
rules={[{ required: true, message: "请选择状态" }]}
>
<Select placeholder="请选择状态">
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
<Form.Item>
<div
style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}
>
<Button onClick={handleCancel}></Button>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default KeywordModal;

View File

@@ -1,197 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { Modal, Form, Input, Button, message, Select } from "antd";
import {
addMaterial,
updateMaterial,
getMaterialDetails,
type MaterialAddRequest,
type MaterialUpdateRequest,
type ContentItem,
} from "../../api";
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
import ContentManager from "./ContentManager";
const { Option } = Select;
interface MaterialModalProps {
visible: boolean;
mode: "add" | "edit";
materialId?: number | null;
onCancel: () => void;
onSuccess: () => void;
}
const MaterialModal: React.FC<MaterialModalProps> = ({
visible,
mode,
materialId,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [contentItems, setContentItems] = useState<ContentItem[]>([]);
// 获取素材详情
const fetchMaterialDetails = useCallback(
async (id: number) => {
try {
const response = await getMaterialDetails(id.toString());
if (response) {
const material = response;
form.setFieldsValue({
title: material.title,
cover: material.cover ? [material.cover] : [],
status: material.status,
});
// 设置内容项
setContentItems(material.content || []);
}
} catch (error) {
console.error("获取素材详情失败:", error);
message.error("获取素材详情失败");
}
},
[form],
);
// 当弹窗打开时处理数据
useEffect(() => {
if (visible) {
if (mode === "edit" && materialId) {
// 编辑模式:获取详情
fetchMaterialDetails(materialId);
} else if (mode === "add") {
// 添加模式:重置表单
form.resetFields();
setContentItems([]);
}
}
}, [visible, mode, materialId, fetchMaterialDetails, form]);
const handleSubmit = async (values: any) => {
try {
setLoading(true);
// 验证内容项
if (contentItems.length === 0) {
message.warning("请至少添加一个内容项");
return;
}
const coverValue = Array.isArray(values.cover)
? values.cover[0] || ""
: values.cover || "";
const data: MaterialAddRequest = {
title: values.title,
status: values.status || 1,
content: contentItems,
...(coverValue && { cover: coverValue }),
};
if (mode === "add") {
const response = await addMaterial(data);
if (response) {
message.success("添加素材成功");
form.resetFields();
setContentItems([]);
onSuccess();
onCancel();
} else {
message.error(response?.message || "添加素材失败");
}
} else {
const updateData: MaterialUpdateRequest = {
...data,
id: materialId?.toString(),
};
const response = await updateMaterial(updateData);
if (response) {
message.success("更新素材成功");
form.resetFields();
setContentItems([]);
onSuccess();
onCancel();
} else {
message.error(response?.message || "更新素材失败");
}
}
} catch (error) {
console.error(`${mode === "add" ? "添加" : "更新"}素材失败:`, error);
message.error(`${mode === "add" ? "添加" : "更新"}素材失败`);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
setContentItems([]);
onCancel();
};
const title = mode === "add" ? "添加素材" : "编辑素材";
return (
<Modal
title={title}
open={visible}
onCancel={handleCancel}
footer={null}
width={800}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{ status: 1 }}
>
<Form.Item
name="title"
label="素材标题"
rules={[{ required: true, message: "请输入素材标题" }]}
>
<Input placeholder="请输入素材标题" />
</Form.Item>
<Form.Item label="素材内容">
<ContentManager value={contentItems} onChange={setContentItems} />
</Form.Item>
<Form.Item name="cover" label="封面图片">
<ImageUpload
count={1}
accept="image/*"
className="material-cover-upload"
/>
</Form.Item>
<Form.Item
name="status"
label="状态"
rules={[{ required: true, message: "请选择状态" }]}
>
<Select placeholder="请选择状态">
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
<Form.Item>
<div
style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}
>
<Button onClick={handleCancel}></Button>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default MaterialModal;

View File

@@ -1,203 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { Modal, Form, Input, Button, message, Select } from "antd";
import {
addSensitiveWord,
updateSensitiveWord,
getSensitiveWordDetails,
type SensitiveWordAddRequest,
type SensitiveWordUpdateRequest,
} from "../../api";
const { TextArea } = Input;
const { Option } = Select;
interface SensitiveWordModalProps {
visible: boolean;
mode: "add" | "edit";
sensitiveWordId?: string | null;
onCancel: () => void;
onSuccess: () => void;
}
const SensitiveWordModal: React.FC<SensitiveWordModalProps> = ({
visible,
mode,
sensitiveWordId,
onCancel,
onSuccess,
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// 获取敏感词详情
const fetchSensitiveWordDetails = 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("获取敏感词详情失败");
}
},
[form],
);
// 当弹窗打开时处理数据
useEffect(() => {
if (visible) {
if (mode === "edit" && sensitiveWordId) {
// 编辑模式:获取详情
fetchSensitiveWordDetails(sensitiveWordId);
} else if (mode === "add") {
// 添加模式:重置表单
form.resetFields();
}
}
}, [visible, mode, sensitiveWordId, fetchSensitiveWordDetails, form]);
const handleSubmit = async (values: any) => {
try {
setLoading(true);
if (mode === "add") {
const data: SensitiveWordAddRequest = {
title: values.title,
keywords: values.keywords,
content: values.content,
operation: values.operation,
status: values.status || "1",
};
const response = await addSensitiveWord(data);
if (response) {
message.success("添加敏感词成功");
form.resetFields();
onSuccess();
onCancel();
} else {
message.error(response?.message || "添加敏感词失败");
}
} else {
const data: SensitiveWordUpdateRequest = {
id: sensitiveWordId,
title: values.title,
keywords: values.keywords,
content: values.content,
operation: values.operation,
status: values.status,
};
const response = await updateSensitiveWord(data);
if (response) {
message.success("更新敏感词成功");
form.resetFields();
onSuccess();
onCancel();
} else {
message.error(response?.message || "更新敏感词失败");
}
}
} catch (error) {
console.error(`${mode === "add" ? "添加" : "更新"}敏感词失败:`, error);
message.error(`${mode === "add" ? "添加" : "更新"}敏感词失败`);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
const title = mode === "add" ? "添加敏感词" : "编辑敏感词";
return (
<Modal
title={title}
open={visible}
onCancel={handleCancel}
footer={null}
width={600}
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
initialValues={{ status: "1", operation: "1" }}
>
<Form.Item
name="title"
label="敏感词标题"
rules={[{ required: true, message: "请输入敏感词标题" }]}
>
<Input placeholder="请输入敏感词标题" />
</Form.Item>
<Form.Item
name="keywords"
label="关键词"
rules={[{ required: true, message: "请输入关键词" }]}
>
<Input placeholder="请输入关键词" />
</Form.Item>
<Form.Item
name="content"
label="敏感词内容"
rules={[{ required: true, message: "请输入敏感词内容" }]}
>
<TextArea rows={4} placeholder="请输入敏感词内容" />
</Form.Item>
<Form.Item
name="operation"
label="操作类型"
rules={[{ required: true, message: "请选择操作类型" }]}
>
<Select placeholder="请选择操作类型">
<Option value={0}></Option>
<Option value={1}></Option>
<Option value={2}></Option>
<Option value={3}></Option>
<Option value={4}></Option>
</Select>
</Form.Item>
<Form.Item
name="status"
label="状态"
rules={[{ required: true, message: "请选择状态" }]}
>
<Select placeholder="请选择状态">
<Option value={1}></Option>
<Option value={0}></Option>
</Select>
</Form.Item>
<Form.Item>
<div
style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}
>
<Button onClick={handleCancel}></Button>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default SensitiveWordModal;

View File

@@ -1,5 +0,0 @@
// 模态框组件统一导出
export { default as MaterialModal } from "./MaterialModal";
export { default as SensitiveWordModal } from "./SensitiveWordModal";
export { default as KeywordModal } from "./KeywordModal";
export { default as ContentManager } from "./ContentManager";

View File

@@ -53,10 +53,27 @@
}
.content {
background: #fff;
border-radius: 0 0 8px 8px;
padding: 24px;
min-height: 400px;
background: #f5f5f5;
border-radius: 8px;
padding: 0;
min-height: calc(100vh - 120px);
}
.mainLayout {
display: flex;
gap: 24px;
height: 100%;
margin: 24px 0px;
}
.leftSection {
flex: 1;
min-width: 0;
}
.rightSection {
flex: 1;
min-width: 0;
}
.materialContent {
@@ -385,6 +402,17 @@
}
}
.mainLayout {
flex-direction: column;
gap: 16px;
padding: 16px;
}
.leftSection,
.rightSection {
flex: none;
}
.tabs {
flex-direction: column;
padding: 0;

View File

@@ -1,89 +1,19 @@
import React, { useState, useRef } from "react";
import { Button } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import React, { useRef } from "react";
import PowerNavigation from "@/components/PowerNavtion";
import MomentPublish from "./components/MomentPublish";
import PublishSchedule from "./components/PublishSchedule";
import styles from "./index.module.scss";
import {
MaterialManagement,
SensitiveWordManagement,
KeywordManagement,
MaterialModal,
SensitiveWordModal,
KeywordModal,
} from "./components";
const ContentManagement: React.FC = () => {
const [activeTab, setActiveTab] = useState<string>("material");
const [materialModalVisible, setMaterialModalVisible] = useState(false);
const [sensitiveWordModalVisible, setSensitiveWordModalVisible] =
useState(false);
const [keywordModalVisible, setKeywordModalVisible] = useState(false);
// 用于触发 PublishSchedule 刷新的引用
const publishScheduleRef = useRef<{ refresh: () => void }>(null);
// 引用管理组件
const materialManagementRef = useRef<any>(null);
const keywordManagementRef = useRef<any>(null);
const sensitiveWordManagementRef = useRef<any>(null);
const tabs = [
{ key: "material", label: "素材资源库" },
{ key: "sensitive", label: "敏感词管理" },
{ key: "keyword", label: "关键词回复" },
];
// 按钮点击处理函数
const handleAddMaterial = () => {
setMaterialModalVisible(true);
};
const handleAddSensitiveWord = () => {
setSensitiveWordModalVisible(true);
};
const handleAddKeyword = () => {
setKeywordModalVisible(true);
};
// 弹窗成功回调
const handleModalSuccess = () => {
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();
}
};
const renderTabContent = () => {
switch (activeTab) {
case "material":
return (
<MaterialManagement ref={materialManagementRef} {...({} as any)} />
);
case "sensitive":
return (
<SensitiveWordManagement
ref={sensitiveWordManagementRef}
{...({} as any)}
/>
);
case "keyword":
return (
<KeywordManagement ref={keywordManagementRef} {...({} as any)} />
);
default:
return (
<MaterialManagement ref={materialManagementRef} {...({} as any)} />
);
// 发布成功后的回调函数
const handlePublishSuccess = () => {
// 触发 PublishSchedule 组件刷新
if (publishScheduleRef.current) {
publishScheduleRef.current.refresh();
}
};
@@ -94,66 +24,22 @@ const ContentManagement: React.FC = () => {
subtitle="可以讲聊天过程的信息收录到素材库中,也调用。"
showBackButton={true}
backButtonText="返回功能中心"
rightContent={
<div className={styles.headerActions}>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={handleAddMaterial}
>
</Button>
<Button icon={<PlusOutlined />} onClick={handleAddSensitiveWord}>
</Button>
<Button icon={<PlusOutlined />} onClick={handleAddKeyword}>
</Button>
</div>
}
rightContent={<div className={styles.headerActions}></div>}
/>
<div className={styles.tabsSection}>
<br />
<div className={styles.tabs}>
{tabs.map(tab => (
<div
key={tab.key}
className={`${styles.tab} ${
activeTab === tab.key ? styles.tabActive : ""
}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</div>
))}
<div className={styles.content}>
<div className={styles.mainLayout}>
{/* 左侧:发布朋友圈 */}
<div className={styles.leftSection}>
<MomentPublish onPublishSuccess={handlePublishSuccess} />
</div>
{/* 右侧:发布计划 */}
<div className={styles.rightSection}>
<PublishSchedule ref={publishScheduleRef} />
</div>
</div>
</div>
<div className={styles.content}>{renderTabContent()}</div>
{/* 弹窗组件 */}
<MaterialModal
visible={materialModalVisible}
mode="add"
onCancel={() => setMaterialModalVisible(false)}
onSuccess={handleModalSuccess}
/>
<SensitiveWordModal
visible={sensitiveWordModalVisible}
mode="add"
onCancel={() => setSensitiveWordModalVisible(false)}
onSuccess={handleModalSuccess}
/>
<KeywordModal
visible={keywordModalVisible}
mode="add"
keywordId={null}
onCancel={() => setKeywordModalVisible(false)}
onSuccess={handleModalSuccess}
/>
</div>
);
};

View File

@@ -0,0 +1,24 @@
// 系统推荐备注消息样式
.systemRecommendRemarkMessage {
.systemMessageText {
font-size: 14px;
font-weight: 500;
color: #8c8c8c;
line-height: 1.4;
word-break: break-word;
padding: 8px 12px;
border-radius: 8px;
max-width: 320px;
}
}
// 响应式设计
@media (max-width: 768px) {
.systemRecommendRemarkMessage {
.systemMessageText {
font-size: 13px;
padding: 6px 10px;
max-width: 280px;
}
}
}

View File

@@ -0,0 +1,66 @@
import React from "react";
import styles from "./index.module.scss";
import { WarningOutlined } from "@ant-design/icons";
interface SystemRecommendRemarkMessageProps {
content: string;
}
const SystemRecommendRemarkMessage: React.FC<
SystemRecommendRemarkMessageProps
> = ({ content }) => {
// 解析XML内容
const parseSystemMessage = (xmlContent: string) => {
try {
// 使用正则表达式提取关键信息
const templateMatch = xmlContent.match(
/<template><!\[CDATA\[(.*?)\]\]><\/template>/,
);
const phoneMatch = xmlContent.match(/<phone>(.*?)<\/phone>/);
const talkerMatch = xmlContent.match(/<talker>(.*?)<\/talker>/);
const remarkMatch = xmlContent.match(/<remark>(.*?)<\/remark>/);
const template = templateMatch ? templateMatch[1] : "";
const phone = phoneMatch ? phoneMatch[1] : "";
const talker = talkerMatch ? talkerMatch[1] : "";
const remark = remarkMatch ? remarkMatch[1] : "";
// 处理模板文本,替换占位符
let displayText = template;
if (phone) {
displayText = displayText.replace(/\$remark_msg_native_url\$/, phone);
}
return {
template: displayText,
phone,
talker,
remark,
hasRemark: !!remark.trim(),
};
} catch (error) {
console.warn("解析系统推荐备注消息失败:", error);
return {
template: "系统推荐添加备注",
phone: "",
talker: "",
remark: "",
hasRemark: false,
};
}
};
const messageData = parseSystemMessage(content);
return (
<div className={styles.systemRecommendRemarkMessage}>
<div className={styles.systemMessageText}>
<WarningOutlined style={{ fontSize: 16 }} />
&nbsp;
{messageData.template}
</div>
</div>
);
};
export default SystemRecommendRemarkMessage;

View File

@@ -6,6 +6,7 @@ import SmallProgramMessage from "./components/SmallProgramMessage";
import VideoMessage from "./components/VideoMessage";
import ClickMenu from "./components/ClickMeau";
import LocationMessage from "./components/LocationMessage";
import SystemRecommendRemarkMessage from "./components/SystemRecommendRemarkMessage/index";
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { formatWechatTime } from "@/utils/common";
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
@@ -277,6 +278,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
case 49: // 小程序/文章/其他:图文、文件
return <SmallProgramMessage content={content || ""} />;
case 10002: // 系统推荐备注消息
return <SystemRecommendRemarkMessage content={content || ""} />;
default: {
// 兼容旧版本和未知消息类型的处理逻辑
if (typeof content !== "string" || !content.trim()) {
@@ -667,7 +671,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
console.warn("Invalid createTime format:", firstMessage.createTime);
return;
}
timestamp = date.getTime() - 20000;
timestamp = date.getTime() - 24 * 36000 * 1000;
} catch (error) {
console.error("Error parsing createTime:", error);
return;
@@ -753,9 +757,34 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
dangerouslySetInnerHTML={{ __html: msg.content }}
></div>
))}
{group.messages
.filter(v => [570425393].includes(v.msgType))
.map(msg => {
// 解析JSON字符串
let displayContent = msg.content;
try {
const parsedContent = JSON.parse(msg.content);
if (
parsedContent &&
typeof parsedContent === "object" &&
parsedContent.content
) {
displayContent = parsedContent.content;
}
} catch (error) {
// 如果解析失败,使用原始内容
displayContent = msg.content;
}
return (
<div key={`divider-${msg.id}`} className={styles.messageTime}>
{displayContent}
</div>
);
})}
<div className={styles.messageTime}>{group.time}</div>
{group.messages
.filter(v => ![10000].includes(v.msgType))
.filter(v => ![10000, 570425393].includes(v.msgType))
.map(msg => {
return renderMessage(msg);
})}

View File

@@ -16,7 +16,6 @@ interface PersonProps {
const Person: React.FC<PersonProps> = ({ contract }) => {
const [activeKey, setActiveKey] = useState("profile");
const isGroup = "chatroomId" in contract;
console.log(contract);
return (
<Sider width={330} className={styles.profileSider}>
<LayoutFiexd

View File

@@ -108,7 +108,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
/>
<div className={styles.chatHeaderDetails}>
<div className={styles.chatHeaderName}>
{contract.nickname || contract.name}
{contract.conRemark || contract.nickname}
</div>
</div>
</div>

View File

@@ -18,6 +18,26 @@
border-right: 3px solid #1890ff;
}
&.pinned {
background-color: #fff7e6;
border-left: 3px solid #faad14;
position: relative;
&::before {
content: "📌";
position: absolute;
top: 8px;
right: 8px;
font-size: 12px;
opacity: 0.7;
}
.messageName {
color: #d46b08;
font-weight: 600;
}
}
&:last-child {
border-bottom: none;
}
@@ -120,6 +140,43 @@
}
}
// 右键菜单样式
.contextMenu {
background: white;
border: 1px solid #d9d9d9;
border-radius: 6px;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
padding: 4px 0;
min-width: 120px;
z-index: 1000;
.menuItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
color: #262626;
transition: background-color 0.2s;
&:hover {
background-color: #f5f5f5;
}
&:active {
background-color: #e6f7ff;
}
.anticon {
font-size: 14px;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.messageList {

View File

@@ -4,3 +4,28 @@ import request from "@/api/request";
export const getContactList = (params: { prevId: string; count: number }) => {
return request("/api/wechatFriend/list", params, "GET");
};
export interface dataProcessingPost {
/**
* CmdModifyFriendLabel专属
*/
"labels[]"?: string[];
/**
* CmdModifyFriendLabel专属
*/
newRemark?: string;
/**
* 类型 CmdModifyFriendRemark修改备注 CmdModifyFriendLabel好友修改标签
*/
type?: string;
/**
* 公共
*/
wechatAccountId?: number;
wechatFriendId?: number;
[property: string]: any;
}
export const dataProcessing = (params: dataProcessingPost) => {
return request("/v1/kefu/dataProcessing", params, "POST");
};

View File

@@ -27,6 +27,7 @@ export interface ContractData {
lastMessageTime: number;
config: {
unreadCount: number;
top?: boolean;
};
duplicate: boolean;
[key: string]: any;
@@ -43,6 +44,7 @@ export interface ChatSession {
lastTime: string;
config: {
unreadCount: number;
top?: boolean;
};
online: boolean;
members?: string[];

View File

@@ -1,10 +1,22 @@
import React, { useEffect, useState } from "react";
import { List, Avatar, Badge } from "antd";
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
import React, { useEffect, useState, useRef } from "react";
import { List, Avatar, Badge, Modal, Input, message } from "antd";
import {
UserOutlined,
TeamOutlined,
PushpinOutlined,
DeleteOutlined,
EditOutlined,
} from "@ant-design/icons";
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
import {
useCkChatStore,
toggleChatSessionPin,
deleteChatSession,
} from "@/store/module/ckchat/ckchat";
import { useWebSocketStore } from "@/store/module/websocket/websocket";
import { updateConfig } from "@/pages/pc/ckbox/api";
import { dataProcessing } from "./api";
import styles from "./MessageList.module.scss";
import { formatWechatTime } from "@/utils/common";
interface MessageListProps {}
@@ -13,13 +25,201 @@ const MessageList: React.FC<MessageListProps> = () => {
const { setCurrentContact, currentContract } = useWeChatStore();
const getChatSessions = useCkChatStore(state => state.chatSessions);
const kfSelected = useCkChatStore(state => state.kfSelected);
const { sendCommand } = useWebSocketStore();
const onContactClick = (session: ContractData | weChatGroup) => {
console.log(session);
setCurrentContact(session, true);
};
const [chatSessions, setChatSessions] = useState<
(ContractData | weChatGroup)[]
>([]);
const searchKeyword = useCkChatStore(state => state.searchKeyword);
// 右键菜单相关状态
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
session: ContractData | weChatGroup | null;
}>({
visible: false,
x: 0,
y: 0,
session: null,
});
// 修改备注相关状态
const [editRemarkModal, setEditRemarkModal] = useState<{
visible: boolean;
session: ContractData | weChatGroup | null;
remark: string;
}>({
visible: false,
session: null,
remark: "",
});
const contextMenuRef = useRef<HTMLDivElement>(null);
// 右键菜单事件处理
const handleContextMenu = (
e: React.MouseEvent,
session: ContractData | weChatGroup,
) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
session,
});
};
// 隐藏右键菜单
const hideContextMenu = () => {
setContextMenu({
visible: false,
x: 0,
y: 0,
session: null,
});
};
// 置顶/取消置顶
const handleTogglePin = (session: ContractData | weChatGroup) => {
const currentPinned = (session.config as any)?.top || false;
const newPinned = !currentPinned; // 切换置顶状态
updateConfig({
id: session.id,
config: { top: newPinned, chat: true },
})
.then(() => {
// API调用成功更新本地状态
toggleChatSessionPin(session.id!, newPinned);
message.success(`${newPinned ? "置顶" : "取消置顶"}成功`);
})
.catch(() => {
message.error(`${newPinned ? "置顶" : "取消置顶"}失败`);
});
hideContextMenu();
};
// 删除会话
const handleDelete = (session: ContractData | weChatGroup) => {
Modal.confirm({
title: "确认删除",
content: `确定要删除与 ${session.conRemark || session.nickname} 的会话吗?`,
onOk: () => {
updateConfig({
id: session.id,
config: { chat: false },
})
.then(() => {
message.success(`删除成功`);
//根据id删除会话里Item
deleteChatSession(session.id);
})
.catch(() => {
message.error(`删除失败`);
});
hideContextMenu();
},
});
};
// 修改备注
const handleEditRemark = (session: ContractData | weChatGroup) => {
setEditRemarkModal({
visible: true,
session,
remark: session.conRemark || "",
});
hideContextMenu();
};
// 保存备注
const handleSaveRemark = async () => {
if (!editRemarkModal.session) return;
const session = editRemarkModal.session;
const isGroup = "chatroomId" in session;
try {
if (isGroup) {
// 群聊备注修改
sendCommand("CmdModifyGroupRemark", {
wechatAccountId: session.wechatAccountId,
chatroomId: session.chatroomId,
newRemark: editRemarkModal.remark,
});
await dataProcessing({
type: "CmdModifyGroupRemark",
wechatAccountId: session.wechatAccountId,
wechatFriendId: session.id,
newRemark: editRemarkModal.remark,
});
} else {
// 好友备注修改
sendCommand("CmdModifyFriendRemark", {
wechatAccountId: session.wechatAccountId,
wechatFriendId: session.id,
newRemark: editRemarkModal.remark,
});
await dataProcessing({
type: "CmdModifyFriendRemark",
wechatAccountId: session.wechatAccountId,
wechatFriendId: session.id,
newRemark: editRemarkModal.remark,
});
}
// 修改备注后会更新当前的session的conRemark然后使用updateChatSession方法更新会话列表
const updatedSession = {
...session,
conRemark: editRemarkModal.remark,
};
// 更新会话列表中的备注
const { updateChatSession } = useCkChatStore.getState();
updateChatSession(updatedSession);
message.success("备注更新成功");
} catch (error) {
console.error("更新备注失败:", error);
message.error("备注更新失败,请重试");
}
setEditRemarkModal({
visible: false,
session: null,
remark: "",
});
};
// 点击外部隐藏菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(event.target as Node)
) {
hideContextMenu();
}
};
if (contextMenu.visible) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [contextMenu.visible]);
useEffect(() => {
let filteredSessions = getChatSessions;
@@ -51,11 +251,12 @@ const MessageList: React.FC<MessageListProps> = () => {
key={session.id}
className={`${styles.messageItem} ${
currentContract?.id === session.id ? styles.active : ""
}`}
} ${(session.config as any)?.top ? styles.pinned : ""}`}
onClick={() => onContactClick(session)}
onContextMenu={e => handleContextMenu(e, session)}
>
<div className={styles.messageInfo}>
<Badge count={session.unreadCount} size="small">
<Badge count={session.config.unreadCount || 0} size="small">
<Avatar
size={48}
src={session.avatar || session.chatroomAvatar}
@@ -70,28 +271,82 @@ const MessageList: React.FC<MessageListProps> = () => {
</Badge>
<div className={styles.messageDetails}>
<div className={styles.messageHeader}>
<div className={styles.messageName}>{session.nickname}</div>
<div className={styles.messageName}>
{session.conRemark || session.nickname || session.wechatId}
</div>
<div className={styles.messageTime}>
{formatWechatTime(session?.lastUpdateTime)}
</div>
</div>
<div className={styles.messageContent}>
<div
className={styles.lastMessage}
data-count={
session.config.unreadCount > 0
? session.config.unreadCount
: ""
}
>
{session?.lastMessage}
</div>
</div>
</div>
</div>
</List.Item>
)}
/>
{/* 右键菜单 */}
{contextMenu.visible && contextMenu.session && (
<div
ref={contextMenuRef}
className={styles.contextMenu}
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
zIndex: 1000,
}}
>
<div
className={styles.menuItem}
onClick={() => handleTogglePin(contextMenu.session!)}
>
<PushpinOutlined />
{(contextMenu.session.config as any)?.top ? "取消置顶" : "置顶"}
</div>
<div
className={styles.menuItem}
onClick={() => handleEditRemark(contextMenu.session!)}
>
<EditOutlined />
</div>
<div
className={styles.menuItem}
onClick={() => handleDelete(contextMenu.session!)}
>
<DeleteOutlined />
</div>
</div>
)}
{/* 修改备注Modal */}
<Modal
title="修改备注"
open={editRemarkModal.visible}
onOk={handleSaveRemark}
onCancel={() =>
setEditRemarkModal({
visible: false,
session: null,
remark: "",
})
}
okText="保存"
cancelText="取消"
>
<Input
value={editRemarkModal.remark}
onChange={e =>
setEditRemarkModal(prev => ({
...prev,
remark: e.target.value,
}))
}
placeholder="请输入备注"
maxLength={20}
/>
</Modal>
</div>
);
};

View File

@@ -113,11 +113,11 @@ export interface weChatGroup {
chatroomAvatar: string;
groupId: number;
config?: {
top?: false;
chat?: boolean;
unreadCount: number;
};
labels?: string[];
notice: string;
selfDisplyName: string;
wechatChatroomId: number;

View File

@@ -9,14 +9,16 @@ import styles from "./index.module.scss";
const { Content, Sider } = Layout;
import { chatInitAPIdata, initSocket } from "./main";
import { useWeChatStore } from "@/store/module/weChat/weChat";
import { getIsLoadWeChat } from "@/store/module/ckchat/ckchat";
const CkboxPage: React.FC = () => {
// 不要在组件初始化时获取sendCommand而是在需要时动态获取
const [loading, setLoading] = useState(false);
const currentContract = useWeChatStore(state => state.currentContract);
useEffect(() => {
// 方法一:使用 Promise 链式调用处理异步函数
setLoading(true);
if (!getIsLoadWeChat()) {
setLoading(true);
}
chatInitAPIdata()
.then(() => {
// 数据加载完成后初始化WebSocket连接

View File

@@ -19,6 +19,7 @@ import {
getGroupList,
getAgentList,
getLabelsListByGroup,
getMessageList,
} from "./api";
import {
@@ -70,19 +71,42 @@ export const chatInitAPIdata = async () => {
const filterGroupSessions = groupList?.filter(
v => v?.config && v.config?.chat,
);
// 获取显示名称的辅助函数(优先 conRemark其次 nickname
const getDisplayName = (session: any) => {
return session.conRemark || session.nickname || "";
};
const messageList = await getMessageList();
console.log(messageList);
//排序功能
const sortedSessions = [...filterUserSessions, ...filterGroupSessions].sort(
(a, b) => {
// 获取未读消息数量
// 获取置顶状态
const aTop = a.config?.top || false;
const bTop = b.config?.top || false;
// 首先按置顶状态排序(置顶的排在前面)
if (aTop !== bTop) {
return aTop ? -1 : 1;
}
// 如果都是置顶或都不是置顶,则按未读消息数量降序排列(未读消息多的排在前面)
const aUnread = a.config?.unreadCount || 0;
const bUnread = b.config?.unreadCount || 0;
// 首先按未读消息数量降序排列(未读消息多的排在前面)
if (aUnread !== bUnread) {
return bUnread - aUnread;
}
// 如果未读消息数量相同,则按时间降序排列(最新的在前面
// 如果未读消息数量相同,则按显示名称排序(字母排序
const aName = getDisplayName(a).toLowerCase();
const bName = getDisplayName(b).toLowerCase();
if (aName !== bName) {
return aName.localeCompare(bName, "zh-CN");
}
// 如果名称也相同,则按时间降序排列(最新的在前面)
// 如果lastUpdateTime不存在则将其排在最后
if (!a.lastUpdateTime) return 1;
if (!b.lastUpdateTime) return -1;
@@ -168,7 +192,7 @@ export const getAllContactList = async () => {
try {
let allContacts = [];
let page = 1;
const limit = 1000;
const limit = 500;
let hasMore = true;
while (hasMore) {
@@ -236,7 +260,7 @@ export const getAllGroupList = async () => {
try {
let allContacts = [];
let page = 1;
const limit = 1000;
const limit = 500;
let hasMore = true;
while (hasMore) {
@@ -272,24 +296,3 @@ export const getAllGroupList = async () => {
return [];
}
};
//获取token
const getToken = () => {
return new Promise((resolve, reject) => {
const params = {
grant_type: "password",
password: "kr123456",
username: "kr_xf3",
// username: "karuo",
// password: "zhiqun1984",
};
loginWithToken(params)
.then(res => {
login2(res.access_token);
resolve(res.access_token);
})
.catch(err => {
reject(err);
});
});
};

View File

@@ -46,7 +46,7 @@ export interface CkChatState {
newContractList: ContactGroupByLabel[];
getContractList: () => ContractData[];
getSomeContractList: (kfSelected: number) => ContractData[];
getNewContractList: () => ContactGroupByLabel[];
getNewContractList: () => Promise<ContactGroupByLabel[]>;
setSearchKeyword: (keyword: string) => void;
clearSearchKeyword: () => void;
asyncKfSelected: (data: number) => void;

View File

@@ -37,7 +37,7 @@ export const useCkChatStore = createPersistStore<CkChatState>(
set({ kfUserList: data });
},
// 获取客服列表
getkfUserList: async () => {
getkfUserList: () => {
const state = useCkChatStore.getState();
return state.kfUserList;
},
@@ -330,6 +330,12 @@ export const useCkChatStore = createPersistStore<CkChatState>(
clearkfUserList: () => {
set({ kfUserList: [] });
},
// 添加控制终端用户
addCtrlUser: (user: KfUserListData) => {
set(state => ({
kfUserList: [...state.kfUserList, user],
}));
},
// 获取聊天会话 - 使用缓存避免无限循环
getChatSessions: (() => {
let cachedResult: any = null;
@@ -423,6 +429,88 @@ export const useCkChatStore = createPersistStore<CkChatState>(
};
});
},
// 切换会话置顶状态
toggleChatSessionPin: (sessionId: number, isPinned: boolean) => {
set(state => {
const updatedSessions = state.chatSessions.map(item => {
if (item.id === sessionId) {
return {
...item,
config: {
...item.config,
top: isPinned,
},
};
}
return item;
});
// 如果置顶,将会话移到顶部
if (isPinned) {
const sessionIndex = updatedSessions.findIndex(
item => item.id === sessionId,
);
if (sessionIndex !== -1) {
const session = updatedSessions[sessionIndex];
const otherSessions = updatedSessions.filter(
item => item.id !== sessionId,
);
return {
chatSessions: [session, ...otherSessions],
};
}
} else {
// 如果取消置顶,重新排序(置顶>未读>名称>时间)
const sortedSessions = updatedSessions.sort((a, b) => {
// 获取置顶状态
const aTop = (a.config as any)?.top || false;
const bTop = (b.config as any)?.top || false;
// 首先按置顶状态排序(置顶的排在前面)
if (aTop !== bTop) {
return aTop ? -1 : 1;
}
// 如果都是置顶或都不是置顶,则按未读消息数量降序排列
const aUnread = a.config?.unreadCount || 0;
const bUnread = b.config?.unreadCount || 0;
if (aUnread !== bUnread) {
return bUnread - aUnread;
}
// 如果未读消息数量相同,则按显示名称排序
const getDisplayName = (session: any) => {
return session.conRemark || session.nickname || "";
};
const aName = getDisplayName(a).toLowerCase();
const bName = getDisplayName(b).toLowerCase();
if (aName !== bName) {
return aName.localeCompare(bName, "zh-CN");
}
// 如果名称也相同,则按时间降序排列
if (!a.lastUpdateTime) return 1;
if (!b.lastUpdateTime) return -1;
const timeCompare =
new Date(b.lastUpdateTime).getTime() -
new Date(a.lastUpdateTime).getTime();
return timeCompare;
});
return {
chatSessions: sortedSessions,
};
}
return {
chatSessions: updatedSessions,
};
});
},
// 设置用户信息
setUserInfo: (userInfo: CkUserInfo) => {
set({ userInfo, isLoggedIn: true });
@@ -492,8 +580,8 @@ export const useCkChatStore = createPersistStore<CkChatState>(
isLoggedIn: state.isLoggedIn,
kfUserList: state.kfUserList,
}),
onRehydrateStorage: () => state => {
// console.log("CkChat store hydrated:", state);
onRehydrateStorage: () => () => {
// console.log("CkChat store hydrated");
},
},
);
@@ -510,7 +598,7 @@ export const addChatSession = (session: ContractData | weChatGroup) =>
useCkChatStore.getState().addChatSession(session);
export const updateChatSession = (session: ContractData | weChatGroup) =>
useCkChatStore.getState().updateChatSession(session);
export const deleteChatSession = (sessionId: string) =>
export const deleteChatSession = (sessionId: number) =>
useCkChatStore.getState().deleteChatSession(sessionId);
export const getkfUserList = () => useCkChatStore.getState().kfUserList;
export const addCtrlUser = (user: KfUserListData) =>
@@ -555,3 +643,5 @@ export const updateIsLoadWeChat = (isLoadWeChat: boolean) =>
useCkChatStore.getState().updateIsLoadWeChat(isLoadWeChat);
export const getIsLoadWeChat = () =>
useCkChatStore.getState().getIsLoadWeChat();
export const toggleChatSessionPin = (sessionId: number, isPinned: boolean) =>
useCkChatStore.getState().toggleChatSessionPin(sessionId, isPinned);

View File

@@ -201,7 +201,7 @@ export const useWeChatStore = create<WeChatState>()(
wechatAccountId: contact.wechatAccountId,
From: 1,
To: To || +new Date(),
Count: 5,
Count: 20,
olderData: true,
};
@@ -317,7 +317,7 @@ export const useWeChatStore = create<WeChatState>()(
const chatSessions = useCkChatStore.getState().chatSessions;
const session = chatSessions.find(item => item.id == getMessageId);
if (session) {
session.unreadCount = Number(session.unreadCount) + 1;
session.config.unreadCount = Number(session.config.unreadCount) + 1;
updateChatSession(session);
// 将接收到新消息的会话置顶到列表顶部
pinChatSessionToTop(getMessageId);
@@ -328,7 +328,7 @@ export const useWeChatStore = create<WeChatState>()(
if (group) {
addChatSession({
...group,
unreadCount: 1,
config: { unreadCount: 1 },
});
// 新创建的会话会自动添加到列表顶部,无需额外置顶
}
@@ -336,7 +336,7 @@ export const useWeChatStore = create<WeChatState>()(
const [user] = await contractService.findByIds(getMessageId);
addChatSession({
...user,
unreadCount: 1,
config: { unreadCount: 1 },
});
// 新创建的会话会自动添加到列表顶部,无需额外置顶
}

View File

@@ -5,6 +5,7 @@ import { WebSocketMessage } from "./websocket";
import { deepCopy } from "@/utils/common";
import { Messages } from "./msg.data";
import { db } from "@/utils/db";
import { Modal } from "antd";
// 消息处理器类型定义
type MessageHandler = (message: WebSocketMessage) => void;
const addMessage = useWeChatStore.getState().addMessage;
@@ -82,14 +83,31 @@ const messageHandlers: Record<string, MessageHandler> = {
console.log("通知消息", message);
// 在这里添加具体的处理逻辑
if (message.notify == "Auth failed") {
// 被踢出时删除所有缓存数据
localStorage.clear();
// 删除
await db.kfUsers.clear();
await db.weChatGroup.clear();
await db.contracts.clear();
await db.newContractList.clear();
window.location.href = "/login";
// 避免重复弹窗
if ((window as any).__CKB_AUTH_FAILED_SHOWN__) {
return;
}
(window as any).__CKB_AUTH_FAILED_SHOWN__ = true;
Modal.warning({
title: "登录失效",
content: "认证已失效或账号在其他设备登录,请重新登录。",
okText: "重新登录",
onOk: async () => {
try {
// 被踢出时删除所有缓存数据
localStorage.clear();
await db.kfUsers.clear();
await db.weChatGroup.clear();
await db.contracts.clear();
await db.newContractList.clear();
} finally {
(window as any).__CKB_AUTH_FAILED_SHOWN__ = false;
window.location.href = "/login";
}
},
});
return;
}
},