入群欢迎语功能提交
This commit is contained in:
@@ -82,10 +82,15 @@ export default function GroupSelection({
|
||||
{selectedOptions.map(group => (
|
||||
<div key={group.id} className={style.selectedListRow}>
|
||||
<div className={style.selectedListRowContent}>
|
||||
<Avatar src={group.avatar} />
|
||||
<Avatar src={group.groupAvatar || group.avatar} />
|
||||
<div className={style.selectedListRowContentText}>
|
||||
<div>{group.name}</div>
|
||||
<div>{group.chatroomId}</div>
|
||||
<div>{group.groupName || group.name}</div>
|
||||
{group.nickName && (
|
||||
<div style={{ fontSize: 12, color: "#666" }}>归属:{group.nickName}</div>
|
||||
)}
|
||||
{!group.nickName && group.chatroomId && (
|
||||
<div>{group.chatroomId}</div>
|
||||
)}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
.detailContainer {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detailCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
|
||||
:global(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:global(.ant-card-head-title) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.groupList,
|
||||
.robotList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.groupItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
}
|
||||
|
||||
.groupAvatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupName {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.groupOwner {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.messageItem {
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.messageHeader {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.textContent {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.fileContent {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.detailContainer {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Card, Descriptions, Tag, Badge, Button } from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
TeamOutlined,
|
||||
MessageOutlined,
|
||||
ClockCircleOutlined,
|
||||
RobotOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { fetchGroupWelcomeTaskDetail } from "../form/index.api";
|
||||
import { Toast } from "antd-mobile";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const GroupWelcomeDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [taskData, setTaskData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const loadDetail = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetchGroupWelcomeTaskDetail(id);
|
||||
const data = res?.data || res;
|
||||
setTaskData(data);
|
||||
} catch (error) {
|
||||
console.error("加载详情失败:", error);
|
||||
Toast.show({ content: "加载数据失败", position: "top" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadDetail();
|
||||
}, [id]);
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "green";
|
||||
case 2:
|
||||
return "gray";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 2:
|
||||
return "已暂停";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageTypeText = (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
text: "文本",
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
file: "文件",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title="任务详情" backFn={() => navigate("/workspace/group-welcome")} />}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "40px 0" }}>加载中...</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!taskData) {
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title="任务详情" backFn={() => navigate("/workspace/group-welcome")} />}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "40px 0" }}>暂无数据</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const config = taskData.config || {};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon
|
||||
title="任务详情"
|
||||
backFn={() => navigate("/workspace/group-welcome")}
|
||||
right={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => navigate(`/workspace/group-welcome/edit/${id}`)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.detailContainer}>
|
||||
<Card className={styles.detailCard}>
|
||||
<div className={styles.cardHeader}>
|
||||
<h2>{taskData.name}</h2>
|
||||
<Badge
|
||||
color={getStatusColor(taskData.status)}
|
||||
text={getStatusText(taskData.status)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Descriptions column={1} bordered>
|
||||
<Descriptions.Item label="任务名称">{taskData.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="任务状态">
|
||||
<Badge
|
||||
color={getStatusColor(taskData.status)}
|
||||
text={getStatusText(taskData.status)}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="时间间隔">
|
||||
<ClockCircleOutlined /> {config.interval || 0} 分钟
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{taskData.createTime || "暂无"}
|
||||
</Descriptions.Item>
|
||||
{taskData.updateTime && (
|
||||
<Descriptions.Item label="更新时间">
|
||||
{taskData.updateTime}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.detailCard} title={<><TeamOutlined /> 目标群组</>}>
|
||||
<div className={styles.groupList}>
|
||||
{config.wechatGroupsOptions && config.wechatGroupsOptions.length > 0 ? (
|
||||
config.wechatGroupsOptions.map((group: any) => (
|
||||
<div key={group.id} className={styles.groupItem}>
|
||||
{group.groupAvatar && (
|
||||
<img
|
||||
src={group.groupAvatar}
|
||||
alt={group.groupName || "群组头像"}
|
||||
className={styles.groupAvatar}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.groupInfo}>
|
||||
<div className={styles.groupName}>{group.groupName || `群组 ${group.id}`}</div>
|
||||
{group.nickName && (
|
||||
<div className={styles.groupOwner}>归属:{group.nickName}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#999" }}>
|
||||
已选择 {config.wechatGroups?.length || 0} 个群组
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.detailCard} title={<><RobotOutlined /> 机器人</>}>
|
||||
<div className={styles.robotList}>
|
||||
{config.deviceGroupsOptions && config.deviceGroupsOptions.length > 0 ? (
|
||||
config.deviceGroupsOptions.map((robot: any) => (
|
||||
<Tag key={robot.id} color="green" style={{ marginBottom: 8 }}>
|
||||
{robot.memo || robot.wechatId || robot.nickname || `设备 ${robot.id}`}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#999" }}>
|
||||
已选择 {config.deviceGroups?.length || 0} 个机器人
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.detailCard} title={<><MessageOutlined /> 欢迎消息</>}>
|
||||
<div className={styles.messageList}>
|
||||
{config.messages && config.messages.length > 0 ? (
|
||||
config.messages.map((message: any, index: number) => (
|
||||
<div key={message.id || index} className={styles.messageItem}>
|
||||
<div className={styles.messageHeader}>
|
||||
<Tag color="purple">消息 {message.order || index + 1}</Tag>
|
||||
<Tag>{getMessageTypeText(message.type)}</Tag>
|
||||
</div>
|
||||
<div className={styles.messageContent}>
|
||||
{message.type === "text" ? (
|
||||
<div
|
||||
className={styles.textContent}
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: (message.content || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n/g, "<br>")
|
||||
.replace(/@\{好友\}/g, '<span style="color: #1677ff; font-weight: 600; background: #e6f7ff; padding: 2px 4px; border-radius: 3px;">@好友</span>')
|
||||
}}
|
||||
/>
|
||||
) : message.type === "image" ? (
|
||||
<img
|
||||
src={message.content}
|
||||
alt="图片"
|
||||
style={{ maxWidth: "100%", borderRadius: 8 }}
|
||||
/>
|
||||
) : message.type === "video" ? (
|
||||
<video
|
||||
src={message.content}
|
||||
controls
|
||||
style={{ maxWidth: "100%", borderRadius: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.fileContent}>
|
||||
<a href={message.content} target="_blank" rel="noopener noreferrer">
|
||||
查看文件
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: "#999", textAlign: "center", padding: "20px 0" }}>
|
||||
暂无欢迎消息
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupWelcomeDetail;
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, {
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useState,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { Input, Form, Card, Switch, InputNumber, Radio } from "antd";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
defaultValues?: {
|
||||
name: string;
|
||||
status: number; // 0: 否, 1: 是
|
||||
interval: number; // 时间间隔(分钟)
|
||||
pushType?: number; // 0: 定时推送, 1: 立即推送
|
||||
startTime?: string; // 允许推送的开始时间
|
||||
endTime?: string; // 允许推送的结束时间
|
||||
};
|
||||
onNext: (values: any) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface BasicSettingsRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
|
||||
(
|
||||
{
|
||||
defaultValues = {
|
||||
name: "",
|
||||
status: 1, // 默认开启
|
||||
interval: 1, // 默认1分钟
|
||||
pushType: 0, // 默认定时推送
|
||||
startTime: "09:00", // 默认开始时间
|
||||
endTime: "21:00", // 默认结束时间
|
||||
},
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValues) {
|
||||
form.setFieldsValue(defaultValues);
|
||||
}
|
||||
}, [defaultValues, form]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("BasicSettings 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={defaultValues}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
基础设置
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
配置任务的基本信息
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="任务名称"
|
||||
rules={[
|
||||
{ required: true, message: "请输入任务名称" },
|
||||
{ max: 50, message: "任务名称不能超过50个字符" },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入任务名称" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="pushType"
|
||||
label="推送类型"
|
||||
rules={[{ required: true, message: "请选择推送类型" }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value={0}>定时推送</Radio>
|
||||
<Radio value={1}>立即推送</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{/* 允许推送的时间段 - 只在定时推送时显示 */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.pushType !== currentValues.pushType
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
// 只在pushType为0(定时推送)时显示时间段设置
|
||||
return getFieldValue("pushType") === 0 ? (
|
||||
<Form.Item label="允许推送的时间段">
|
||||
<div
|
||||
style={{ display: "flex", gap: 8, alignItems: "center" }}
|
||||
>
|
||||
<Form.Item
|
||||
name="startTime"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请选择开始时间" }]}
|
||||
>
|
||||
<Input type="time" style={{ width: 120 }} size="large" />
|
||||
</Form.Item>
|
||||
<span style={{ color: "#888" }}>至</span>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
noStyle
|
||||
rules={[{ required: true, message: "请选择结束时间" }]}
|
||||
>
|
||||
<Input type="time" style={{ width: 120 }} size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="interval"
|
||||
label="时间间隔(分钟)"
|
||||
rules={[
|
||||
{ required: true, message: "请输入时间间隔" },
|
||||
{ type: "number", min: 1, message: "时间间隔至少为1分钟" },
|
||||
{ type: "number", max: 1440, message: "时间间隔不能超过1440分钟(24小时)" },
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
placeholder="请输入时间间隔"
|
||||
min={1}
|
||||
max={1440}
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
addonAfter="分钟"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="启用状态"
|
||||
valuePropName="checked"
|
||||
getValueFromEvent={(checked) => (checked ? 1 : 0)}
|
||||
getValueProps={(value) => ({ checked: value === 1 })}
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BasicSettings.displayName = "BasicSettings";
|
||||
|
||||
export default BasicSettings;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useImperativeHandle, forwardRef } from "react";
|
||||
import { Form, Card } from "antd";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
|
||||
interface GroupSelectorProps {
|
||||
selectedGroups: GroupSelectionItem[];
|
||||
onPrevious: () => void;
|
||||
onNext: (data: {
|
||||
groups: string[];
|
||||
groupsOptions: GroupSelectionItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface GroupSelectorRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const GroupSelector = forwardRef<GroupSelectorRef, GroupSelectorProps>(
|
||||
({ selectedGroups, onNext }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
form.setFieldsValue({
|
||||
groups: selectedGroups.map(item => String(item.id)),
|
||||
});
|
||||
await form.validateFields(["groups"]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("GroupSelector 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleGroupSelect = (groupsOptions: GroupSelectionItem[]) => {
|
||||
const groups = groupsOptions.map(item => String(item.id));
|
||||
form.setFieldValue("groups", groups);
|
||||
onNext({ groups, groupsOptions });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ groups: selectedGroups }}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
选择群组
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
请选择需要设置欢迎语的群组(可多选)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="groups"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: "array",
|
||||
min: 1,
|
||||
message: "请至少选择一个群组",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<GroupSelection
|
||||
selectedOptions={selectedGroups}
|
||||
onSelect={handleGroupSelect}
|
||||
placeholder="选择群组"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
GroupSelector.displayName = "GroupSelector";
|
||||
|
||||
export default GroupSelector;
|
||||
@@ -0,0 +1,127 @@
|
||||
.messageList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.messageCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(22, 119, 255, 0.06),
|
||||
0 1.5px 4px rgba(0, 0, 0, 0.04);
|
||||
padding: 20px 12px 16px 12px;
|
||||
border: 1.5px solid #f0f3fa;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
border 0.2s,
|
||||
transform 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 8px 24px rgba(22, 119, 255, 0.12),
|
||||
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border: 1.5px solid #1677ff;
|
||||
transform: translateY(-2px) scale(1.01);
|
||||
}
|
||||
}
|
||||
|
||||
.messageHeader {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.messageHeaderContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.messageTypeBtns {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.messageTypeBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
color: #ff4d4f;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 8px;
|
||||
transition: color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: #d9363e;
|
||||
}
|
||||
}
|
||||
|
||||
.addMessageButtons {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.addMessageBtn {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.richTextInput {
|
||||
white-space: pre-wrap; // 保留换行和空格
|
||||
word-wrap: break-word; // 允许长单词换行
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
&:empty:before {
|
||||
content: attr(data-placeholder);
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
// @好友样式
|
||||
:global(.mention-friend) {
|
||||
color: #1677ff !important;
|
||||
font-weight: 600 !important;
|
||||
background: #e6f7ff !important;
|
||||
padding: 2px 4px !important;
|
||||
border-radius: 3px !important;
|
||||
display: inline !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.addMessageButtons {
|
||||
flex-direction: column;
|
||||
|
||||
.addMessageBtn {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.messageTypeBtns {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import React, { useImperativeHandle, forwardRef, useState, useRef, useEffect } from "react";
|
||||
import { Form, Card, Button, Input } from "antd";
|
||||
import { PlusOutlined, CloseOutlined, ClockCircleOutlined, UserAddOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
MessageOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { WelcomeMessage } from "../index.data";
|
||||
import ImageUpload from "@/components/Upload/ImageUpload/ImageUpload";
|
||||
import VideoUpload from "@/components/Upload/VideoUpload";
|
||||
import FileUpload from "@/components/Upload/FileUpload";
|
||||
import styles from "./MessageConfig.module.scss";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
// 富文本编辑器组件
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (text: string) => void;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
onInsertMention?: React.MutableRefObject<{ insertMention: () => void } | null>; // 插入@好友的ref
|
||||
}
|
||||
|
||||
const RichTextEditor: React.FC<RichTextEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "",
|
||||
maxLength = 500,
|
||||
onInsertMention,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
// 暴露插入@好友的方法
|
||||
useEffect(() => {
|
||||
if (onInsertMention && editorRef.current) {
|
||||
onInsertMention.current = {
|
||||
insertMention: () => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
// 获取当前文本(包含换行符)
|
||||
const currentText = getText(editorRef.current.innerHTML);
|
||||
|
||||
// 检查是否已经存在@好友,如果存在则不允许再插入
|
||||
if (currentText.includes('@{好友}')) {
|
||||
// 已经存在@好友,不允许再插入
|
||||
return;
|
||||
}
|
||||
|
||||
// 先保存当前光标位置
|
||||
const saved = saveSelection();
|
||||
const mentionPlaceholder = "@{好友}";
|
||||
|
||||
// 根据光标位置插入@好友
|
||||
let newContent: string;
|
||||
if (!saved) {
|
||||
// 如果没有光标位置,插入到末尾
|
||||
if (!currentText) {
|
||||
newContent = mentionPlaceholder;
|
||||
} else if (currentText.endsWith("\n") || currentText.endsWith(" ")) {
|
||||
newContent = currentText + mentionPlaceholder;
|
||||
} else {
|
||||
newContent = currentText + " " + mentionPlaceholder;
|
||||
}
|
||||
} else {
|
||||
// 在光标位置插入@好友
|
||||
const cursorPos = saved.startOffset;
|
||||
const beforeText = currentText.substring(0, cursorPos);
|
||||
const afterText = currentText.substring(cursorPos);
|
||||
|
||||
// 判断光标前是否需要添加空格
|
||||
const needsSpace = beforeText.length > 0
|
||||
&& !beforeText.endsWith(" ")
|
||||
&& !beforeText.endsWith("\n")
|
||||
&& afterText.length > 0
|
||||
&& !afterText.startsWith(" ");
|
||||
|
||||
if (needsSpace) {
|
||||
newContent = beforeText + " " + mentionPlaceholder + afterText;
|
||||
} else {
|
||||
newContent = beforeText + mentionPlaceholder + afterText;
|
||||
}
|
||||
}
|
||||
|
||||
// 直接更新编辑器内容
|
||||
const formatted = formatContent(newContent);
|
||||
editorRef.current.innerHTML = formatted;
|
||||
|
||||
// 恢复光标位置到插入的@好友后面
|
||||
// 使用双重 requestAnimationFrame 确保 DOM 完全更新
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (editorRef.current) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// 计算新插入的@好友在文本中的位置
|
||||
let newCursorPos: number;
|
||||
if (!saved) {
|
||||
// 如果没有保存的光标位置,放在末尾
|
||||
newCursorPos = newContent.length;
|
||||
} else {
|
||||
// 计算插入@好友后的光标位置
|
||||
const cursorPos = saved.startOffset;
|
||||
const beforeText = currentText.substring(0, cursorPos);
|
||||
const needsSpace = beforeText.length > 0
|
||||
&& !beforeText.endsWith(" ")
|
||||
&& !beforeText.endsWith("\n")
|
||||
&& currentText.substring(cursorPos).length > 0
|
||||
&& !currentText.substring(cursorPos).startsWith(" ");
|
||||
|
||||
// @好友的位置 = 原光标位置 + (如果需要空格则+1)
|
||||
const mentionPos = cursorPos + (needsSpace ? 1 : 0);
|
||||
// 光标位置 = @好友位置 + @好友长度
|
||||
newCursorPos = mentionPos + mentionPlaceholder.length;
|
||||
}
|
||||
|
||||
// 使用文本位置恢复光标
|
||||
restoreSelection({ startOffset: newCursorPos, endOffset: newCursorPos });
|
||||
|
||||
// 确保编辑器获得焦点
|
||||
editorRef.current.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 更新value
|
||||
onChange(newContent);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [onInsertMention, onChange]);
|
||||
|
||||
// 格式化内容,只将系统插入的@{好友}高亮,手动输入的@好友不高亮
|
||||
const formatContent = (text: string) => {
|
||||
if (!text) return "";
|
||||
// 先转义HTML,但保留@{好友}格式用于替换
|
||||
// 将@{好友}替换为特殊标记,避免被转义
|
||||
const parts = text.split(/(@\{好友\})/g);
|
||||
return parts.map((part, index) => {
|
||||
if (part === '@{好友}') {
|
||||
// 在span后面添加零宽空格,确保可以继续输入
|
||||
return '<span class="mention-friend">@好友</span>\u200B';
|
||||
}
|
||||
// 转义其他部分,保留换行符
|
||||
return part
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n/g, "<br>"); // 保留换行符
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// 提取纯文本(将高亮的@好友还原为@{好友}格式)
|
||||
const getText = (html: string) => {
|
||||
// 先处理HTML中的mention-friend元素
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// 将所有的mention-friend元素替换为@{好友}格式
|
||||
const mentions = tempDiv.querySelectorAll('.mention-friend');
|
||||
mentions.forEach((mention) => {
|
||||
const replacement = document.createTextNode('@{好友}');
|
||||
mention.parentNode?.replaceChild(replacement, mention);
|
||||
});
|
||||
|
||||
// 将块级元素转换为换行符:在块级元素前后添加换行
|
||||
const blockElements = Array.from(tempDiv.querySelectorAll('div, p, h1, h2, h3, h4, h5, h6'));
|
||||
blockElements.forEach((block) => {
|
||||
// 在块级元素前添加换行符
|
||||
if (block.previousSibling) {
|
||||
const textNode = document.createTextNode('\n');
|
||||
block.parentNode?.insertBefore(textNode, block);
|
||||
}
|
||||
// 在块级元素后添加换行符
|
||||
if (block.nextSibling) {
|
||||
const textNode = document.createTextNode('\n');
|
||||
block.parentNode?.insertBefore(textNode, block.nextSibling);
|
||||
}
|
||||
});
|
||||
|
||||
// 手动遍历所有节点,提取文本和<br>标签
|
||||
const walker = document.createTreeWalker(
|
||||
tempDiv,
|
||||
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
|
||||
null
|
||||
);
|
||||
|
||||
const textParts: string[] = [];
|
||||
let node: Node | null;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
if (text) {
|
||||
textParts.push(text);
|
||||
}
|
||||
} else if (node.nodeName === 'BR') {
|
||||
textParts.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到任何内容,使用 textContent 作为后备
|
||||
let text = textParts.length > 0
|
||||
? textParts.join('')
|
||||
: (tempDiv.textContent || "");
|
||||
|
||||
// 移除零宽空格
|
||||
text = text.replace(/\u200B/g, '');
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
// 保存和恢复光标位置
|
||||
const saveSelection = () => {
|
||||
if (!editorRef.current) return null;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
// 检查range是否在editor内部
|
||||
if (!editorRef.current.contains(range.commonAncestorContainer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取当前文本内容(包含换行符)
|
||||
const currentText = getText(editorRef.current.innerHTML);
|
||||
|
||||
// 创建一个临时范围来计算光标前的文本长度
|
||||
const preCaretRange = range.cloneRange();
|
||||
preCaretRange.selectNodeContents(editorRef.current);
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
||||
|
||||
// 使用getText函数获取文本(包含换行符),然后计算长度
|
||||
// 创建一个临时div来保存当前HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = editorRef.current.innerHTML;
|
||||
|
||||
// 克隆光标前的内容
|
||||
const clonedRange = preCaretRange.cloneContents();
|
||||
const beforeDiv = document.createElement('div');
|
||||
beforeDiv.appendChild(clonedRange);
|
||||
|
||||
// 获取光标前的文本(包含换行符)
|
||||
// 需要处理.mention-friend元素,将其转换为@{好友}
|
||||
const beforeText = getText(beforeDiv.innerHTML);
|
||||
const startOffset = beforeText.length;
|
||||
|
||||
return {
|
||||
startOffset,
|
||||
endOffset: startOffset + (range.toString().length),
|
||||
currentText, // 保存当前文本,用于后续计算
|
||||
};
|
||||
};
|
||||
|
||||
const restoreSelection = (saved: { startOffset: number; endOffset: number } | null) => {
|
||||
if (!saved || !editorRef.current) return;
|
||||
|
||||
try {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// 获取当前文本内容(包含换行符)
|
||||
const currentText = getText(editorRef.current.innerHTML);
|
||||
const targetOffset = Math.min(saved.startOffset, currentText.length);
|
||||
|
||||
const range = document.createRange();
|
||||
let charCount = 0;
|
||||
let found = false;
|
||||
|
||||
// 遍历所有节点,包括文本节点、<br>元素和.mention-friend元素
|
||||
const walker = document.createTreeWalker(
|
||||
editorRef.current,
|
||||
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
|
||||
null
|
||||
);
|
||||
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode()) && !found) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
// 跳过零宽空格
|
||||
const text = node.textContent || '';
|
||||
const nodeLength = text.replace(/\u200B/g, '').length;
|
||||
if (charCount + nodeLength >= targetOffset) {
|
||||
const offset = targetOffset - charCount;
|
||||
// 计算实际偏移量(考虑零宽空格)
|
||||
let actualOffset = 0;
|
||||
let charIndex = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] !== '\u200B') {
|
||||
if (charIndex >= offset) break;
|
||||
charIndex++;
|
||||
}
|
||||
actualOffset++;
|
||||
}
|
||||
range.setStart(node, Math.max(0, Math.min(actualOffset, text.length)));
|
||||
range.setEnd(node, Math.max(0, Math.min(actualOffset, text.length)));
|
||||
found = true;
|
||||
}
|
||||
charCount += nodeLength;
|
||||
} else if (node.nodeName === 'BR') {
|
||||
if (charCount >= targetOffset) {
|
||||
// 光标应该在<br>之前
|
||||
range.setStartBefore(node);
|
||||
range.setEndBefore(node);
|
||||
found = true;
|
||||
} else if (charCount + 1 >= targetOffset) {
|
||||
// 光标应该在<br>之后
|
||||
range.setStartAfter(node);
|
||||
range.setEndAfter(node);
|
||||
found = true;
|
||||
}
|
||||
charCount += 1;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// 处理.mention-friend元素,它代表@{好友},长度为4
|
||||
const element = node as Element;
|
||||
if (element.classList.contains('mention-friend')) {
|
||||
const mentionLength = 4; // @{好友}的长度
|
||||
if (charCount + mentionLength >= targetOffset) {
|
||||
// 光标应该在mention-friend之后(零宽空格后面)
|
||||
// 查找或创建零宽空格
|
||||
let zwspNode: Node | null = null;
|
||||
const nextSibling = node.nextSibling;
|
||||
|
||||
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE && nextSibling.textContent === '\u200B') {
|
||||
zwspNode = nextSibling;
|
||||
} else {
|
||||
// 如果没有零宽空格,创建一个
|
||||
zwspNode = document.createTextNode('\u200B');
|
||||
node.parentNode?.insertBefore(zwspNode, nextSibling);
|
||||
}
|
||||
|
||||
// 将光标放在零宽空格后面
|
||||
if (zwspNode) {
|
||||
range.setStartAfter(zwspNode);
|
||||
range.setEndAfter(zwspNode);
|
||||
} else {
|
||||
range.setStartAfter(node);
|
||||
range.setEndAfter(node);
|
||||
}
|
||||
found = true;
|
||||
}
|
||||
charCount += mentionLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
// 如果没找到,放在末尾
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editorRef.current);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果恢复失败,将光标放在末尾
|
||||
try {
|
||||
const selection = window.getSelection();
|
||||
if (selection && editorRef.current) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editorRef.current);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 更新内容(只在外部value变化时更新,不干扰用户输入)
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || isComposingRef.current) return;
|
||||
const currentText = getText(editorRef.current.innerHTML);
|
||||
// 只在外部value变化且与当前内容不同时更新
|
||||
if (currentText !== value) {
|
||||
const saved = saveSelection();
|
||||
const formatted = formatContent(value) || "";
|
||||
if (editorRef.current.innerHTML !== formatted) {
|
||||
editorRef.current.innerHTML = formatted;
|
||||
// 延迟恢复光标,确保DOM已更新
|
||||
setTimeout(() => {
|
||||
restoreSelection(saved);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
|
||||
if (isComposingRef.current) return;
|
||||
|
||||
const text = getText(e.currentTarget.innerHTML);
|
||||
if (text.length <= maxLength) {
|
||||
// 先更新文本内容
|
||||
onChange(text);
|
||||
|
||||
// 不在输入时立即格式化,只在失去焦点时格式化
|
||||
// 这样可以避免干扰用户输入
|
||||
} else {
|
||||
// 超出长度,恢复之前的内容
|
||||
const saved = saveSelection();
|
||||
e.currentTarget.innerHTML = formatContent(value);
|
||||
requestAnimationFrame(() => {
|
||||
restoreSelection(saved);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={handleInput}
|
||||
onCompositionStart={() => {
|
||||
isComposingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={(e) => {
|
||||
isComposingRef.current = false;
|
||||
handleInput(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const text = getText(e.currentTarget.innerHTML);
|
||||
onChange(text);
|
||||
// 失去焦点时格式化,确保@好友高亮显示
|
||||
if (text.includes('@{好友}')) {
|
||||
const formatted = formatContent(text);
|
||||
if (e.currentTarget.innerHTML !== formatted) {
|
||||
e.currentTarget.innerHTML = formatted;
|
||||
}
|
||||
}
|
||||
}}
|
||||
data-placeholder={placeholder}
|
||||
style={{
|
||||
minHeight: "80px",
|
||||
maxHeight: "200px",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid #d9d9d9",
|
||||
borderRadius: "6px",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
outline: "none",
|
||||
overflowY: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
className={styles.richTextInput}
|
||||
/>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
bottom: 8,
|
||||
right: 12,
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
pointerEvents: "none",
|
||||
background: "rgba(255, 255, 255, 0.8)",
|
||||
padding: "0 4px"
|
||||
}}>
|
||||
{value.length}/{maxLength}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 消息类型配置
|
||||
const messageTypes = [
|
||||
{ id: "text", icon: MessageOutlined, label: "文本" },
|
||||
{ id: "image", icon: PictureOutlined, label: "图片" },
|
||||
{ id: "video", icon: VideoCameraOutlined, label: "视频" },
|
||||
{ id: "file", icon: FileOutlined, label: "文件" },
|
||||
];
|
||||
|
||||
interface MessageConfigProps {
|
||||
defaultMessages?: WelcomeMessage[];
|
||||
onPrevious: () => void;
|
||||
onNext: (data: { messages: WelcomeMessage[] }) => void;
|
||||
}
|
||||
|
||||
export interface MessageConfigRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const MessageConfig = forwardRef<MessageConfigRef, MessageConfigProps>(
|
||||
({ defaultMessages = [], onNext }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
const [messages, setMessages] = useState<WelcomeMessage[]>(
|
||||
defaultMessages.length > 0
|
||||
? defaultMessages
|
||||
: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "text",
|
||||
content: "",
|
||||
order: 1,
|
||||
sendInterval: 5,
|
||||
intervalUnit: "seconds",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
// 验证至少有一条消息
|
||||
if (messages.length === 0) {
|
||||
form.setFields([
|
||||
{
|
||||
name: "messages",
|
||||
errors: ["请至少配置一条欢迎消息"],
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证每条消息都有内容
|
||||
for (const msg of messages) {
|
||||
if (!msg.content) {
|
||||
form.setFields([
|
||||
{
|
||||
name: "messages",
|
||||
errors: ["请填写所有消息的内容,消息内容不能为空"],
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
// 移除@{好友}格式标记和空白字符后检查是否有实际内容
|
||||
const contentWithoutMention = msg.content
|
||||
.replace(/@\{好友\}/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (contentWithoutMention === "") {
|
||||
form.setFields([
|
||||
{
|
||||
name: "messages",
|
||||
errors: ["请填写所有消息的内容,消息内容不能为空"],
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
form.setFieldsValue({ messages });
|
||||
await form.validateFields(["messages"]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("MessageConfig 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return { messages };
|
||||
},
|
||||
}));
|
||||
|
||||
// 添加消息
|
||||
const handleAddMessage = (type: WelcomeMessage["type"] = "text") => {
|
||||
const newMessage: WelcomeMessage = {
|
||||
id: Date.now().toString(),
|
||||
type,
|
||||
content: "",
|
||||
order: messages.length + 1,
|
||||
sendInterval: 5,
|
||||
intervalUnit: "seconds",
|
||||
};
|
||||
setMessages([...messages, newMessage]);
|
||||
};
|
||||
|
||||
// 删除消息
|
||||
const handleRemoveMessage = (id: string) => {
|
||||
const newMessages = messages
|
||||
.filter(msg => msg.id !== id)
|
||||
.map((msg, index) => ({ ...msg, order: index + 1 }));
|
||||
setMessages(newMessages);
|
||||
};
|
||||
|
||||
// 更新消息
|
||||
const handleUpdateMessage = (id: string, updates: Partial<WelcomeMessage>) => {
|
||||
setMessages(
|
||||
messages.map(msg => (msg.id === id ? { ...msg, ...updates } : msg)),
|
||||
);
|
||||
};
|
||||
|
||||
// 切换时间单位
|
||||
const toggleIntervalUnit = (id: string) => {
|
||||
const message = messages.find(msg => msg.id === id);
|
||||
if (!message) return;
|
||||
const newUnit = message.intervalUnit === "minutes" ? "seconds" : "minutes";
|
||||
handleUpdateMessage(id, { intervalUnit: newUnit });
|
||||
};
|
||||
|
||||
// 存储每个消息的编辑器ref
|
||||
const editorRefs = useRef<Record<string, React.MutableRefObject<{ insertMention: () => void } | null>>>({});
|
||||
|
||||
// 插入@好友占位符(使用特殊格式,只有系统插入的才会高亮)
|
||||
const handleInsertFriendMention = (messageId: string) => {
|
||||
const editorRef = editorRefs.current[messageId];
|
||||
if (editorRef?.current) {
|
||||
editorRef.current.insertMention();
|
||||
}
|
||||
};
|
||||
|
||||
// 将纯文本转换为带样式的HTML(用于富文本显示)
|
||||
const formatContentWithMentions = (content: string) => {
|
||||
if (!content) return "";
|
||||
// 转义HTML特殊字符
|
||||
const escaped = content
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
// 只将@{好友}格式替换为带样式的span,手动输入的@好友不会被高亮
|
||||
return escaped.replace(
|
||||
/@\{好友\}/g,
|
||||
'<span class="mention-friend">@好友</span>'
|
||||
);
|
||||
};
|
||||
|
||||
// 从富文本中提取纯文本
|
||||
const extractTextFromHtml = (html: string) => {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
配置欢迎消息
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
配置多条欢迎消息,新成员入群时将按顺序发送
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="messages"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
validator: () => {
|
||||
if (messages.length === 0) {
|
||||
return Promise.reject("请至少配置一条欢迎消息");
|
||||
}
|
||||
const hasEmptyContent = messages.some((msg) => {
|
||||
if (!msg.content) return true;
|
||||
// 移除@{好友}格式标记和空白字符后检查是否有实际内容
|
||||
const contentWithoutMention = msg.content
|
||||
.replace(/@\{好友\}/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return contentWithoutMention === "";
|
||||
});
|
||||
if (hasEmptyContent) {
|
||||
return Promise.reject("请填写所有消息的内容,消息内容不能为空");
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className={styles.messageList}>
|
||||
{messages.map((message, index) => (
|
||||
<div key={message.id} className={styles.messageCard}>
|
||||
<div className={styles.messageHeader}>
|
||||
{/* 时间间隔设置 */}
|
||||
<div className={styles.messageHeaderContent}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ minWidth: 36 }}>间隔</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(message.sendInterval || 5)}
|
||||
onChange={e =>
|
||||
handleUpdateMessage(message.id, {
|
||||
sendInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
style={{ width: 60 }}
|
||||
min={1}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => toggleIntervalUnit(message.id)}
|
||||
>
|
||||
<ClockCircleOutlined />
|
||||
{message.intervalUnit === "minutes" ? "分钟" : "秒"}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className={styles.removeBtn}
|
||||
onClick={() => handleRemoveMessage(message.id)}
|
||||
title="删除"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
{/* 类型切换按钮 */}
|
||||
<div className={styles.messageTypeBtns}>
|
||||
{messageTypes.map(type => (
|
||||
<Button
|
||||
key={type.id}
|
||||
type={message.type === type.id ? "primary" : "default"}
|
||||
onClick={() =>
|
||||
handleUpdateMessage(message.id, {
|
||||
type: type.id as any,
|
||||
content: "", // 切换类型时清空内容
|
||||
})
|
||||
}
|
||||
className={styles.messageTypeBtn}
|
||||
title={type.label}
|
||||
>
|
||||
<type.icon />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.messageContent}>
|
||||
{/* 文本消息 */}
|
||||
{message.type === "text" && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontSize: 14, color: "#666" }}>消息内容</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UserAddOutlined />}
|
||||
onClick={() => handleInsertFriendMention(message.id)}
|
||||
disabled={message.content?.includes('@{好友}')}
|
||||
style={{ padding: 0, height: "auto", color: message.content?.includes('@{好友}') ? "#ccc" : "#1677ff" }}
|
||||
>
|
||||
@好友
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* 富文本输入框 */}
|
||||
<RichTextEditor
|
||||
value={message.content || ""}
|
||||
onChange={(text) => {
|
||||
if (text.length <= 500) {
|
||||
handleUpdateMessage(message.id, {
|
||||
content: text,
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="请输入欢迎消息内容,点击@好友按钮可插入@好友占位符"
|
||||
maxLength={500}
|
||||
onInsertMention={(() => {
|
||||
if (!editorRefs.current[message.id]) {
|
||||
editorRefs.current[message.id] = React.createRef();
|
||||
}
|
||||
return editorRefs.current[message.id];
|
||||
})()}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "#999" }}>
|
||||
提示:@好友为占位符,系统会根据实际情况自动@相应好友
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片消息 */}
|
||||
{message.type === "image" && (
|
||||
<ImageUpload
|
||||
value={message.content ? [message.content] : []}
|
||||
onChange={(urls) =>
|
||||
handleUpdateMessage(message.id, {
|
||||
content: urls && urls.length > 0 ? urls[0] : "",
|
||||
})
|
||||
}
|
||||
count={1}
|
||||
accept="image/*"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 视频消息 */}
|
||||
{message.type === "video" && (
|
||||
<VideoUpload
|
||||
value={message.content || ""}
|
||||
onChange={(url) =>
|
||||
handleUpdateMessage(message.id, {
|
||||
content: typeof url === "string" ? url : (Array.isArray(url) && url.length > 0 ? url[0] : ""),
|
||||
})
|
||||
}
|
||||
maxSize={50}
|
||||
maxCount={1}
|
||||
showPreview={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件消息 */}
|
||||
{message.type === "file" && (
|
||||
<FileUpload
|
||||
value={message.content || ""}
|
||||
onChange={(url) =>
|
||||
handleUpdateMessage(message.id, {
|
||||
content: typeof url === "string" ? url : (Array.isArray(url) && url.length > 0 ? url[0] : ""),
|
||||
})
|
||||
}
|
||||
maxSize={10}
|
||||
maxCount={1}
|
||||
showPreview={true}
|
||||
acceptTypes={["excel", "word", "ppt"]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles.addMessageButtons}>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddMessage("text")}
|
||||
className={styles.addMessageBtn}
|
||||
>
|
||||
添加文本消息
|
||||
</Button>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddMessage("image")}
|
||||
className={styles.addMessageBtn}
|
||||
>
|
||||
添加图片消息
|
||||
</Button>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddMessage("video")}
|
||||
className={styles.addMessageBtn}
|
||||
>
|
||||
添加视频消息
|
||||
</Button>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => handleAddMessage("file")}
|
||||
className={styles.addMessageBtn}
|
||||
>
|
||||
添加文件消息
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MessageConfig.displayName = "MessageConfig";
|
||||
|
||||
export default MessageConfig;
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useImperativeHandle, forwardRef } from "react";
|
||||
import { Form, Card } from "antd";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
|
||||
interface RobotSelectorProps {
|
||||
selectedRobots: DeviceSelectionItem[];
|
||||
onPrevious: () => void;
|
||||
onNext: (data: {
|
||||
robots: string[];
|
||||
robotsOptions: DeviceSelectionItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface RobotSelectorRef {
|
||||
validate: () => Promise<boolean>;
|
||||
getValues: () => any;
|
||||
}
|
||||
|
||||
const RobotSelector = forwardRef<RobotSelectorRef, RobotSelectorProps>(
|
||||
({ selectedRobots, onNext }, ref) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
validate: async () => {
|
||||
try {
|
||||
form.setFieldsValue({
|
||||
robots: selectedRobots.map(item => String(item.id)),
|
||||
});
|
||||
await form.validateFields(["robots"]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("RobotSelector 表单验证失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
getValues: () => {
|
||||
return form.getFieldsValue();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleRobotSelect = (robotsOptions: DeviceSelectionItem[]) => {
|
||||
const robots = robotsOptions.map(item => String(item.id));
|
||||
form.setFieldValue("robots", robots);
|
||||
onNext({ robots, robotsOptions });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ robots: selectedRobots }}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
选择机器人
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0 0", color: "#666", fontSize: 14 }}>
|
||||
请选择用于发送欢迎消息的机器人
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="robots"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: "array",
|
||||
min: 1,
|
||||
message: "请至少选择一个机器人",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<DeviceSelection
|
||||
selectedOptions={selectedRobots}
|
||||
onSelect={handleRobotSelect}
|
||||
placeholder="选择机器人"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RobotSelector.displayName = "RobotSelector";
|
||||
|
||||
export default RobotSelector;
|
||||
@@ -0,0 +1,16 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 创建入群欢迎语任务
|
||||
export function createGroupWelcomeTask(data: any) {
|
||||
return request("/v1/workbench/create", data, "POST");
|
||||
}
|
||||
|
||||
// 更新入群欢迎语任务
|
||||
export function updateGroupWelcomeTask(data: any) {
|
||||
return request("/v1/workbench/update", data, "POST");
|
||||
}
|
||||
|
||||
// 获取入群欢迎语任务详情
|
||||
export function fetchGroupWelcomeTaskDetail(id: string) {
|
||||
return request("/v1/workbench/detail", { id }, "GET");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
|
||||
// 欢迎消息类型
|
||||
export interface WelcomeMessage {
|
||||
id: string;
|
||||
type: "text" | "image" | "video" | "file";
|
||||
content: string;
|
||||
order: number; // 消息顺序
|
||||
sendInterval?: number; // 发送间隔
|
||||
intervalUnit?: "seconds" | "minutes"; // 间隔单位
|
||||
}
|
||||
|
||||
export interface FormData {
|
||||
name: string;
|
||||
status: number; // 0: 否, 1: 是(开关)
|
||||
interval: number; // 时间间隔(分钟)
|
||||
pushType?: number; // 0: 定时推送, 1: 立即推送
|
||||
startTime?: string; // 允许推送的开始时间
|
||||
endTime?: string; // 允许推送的结束时间
|
||||
groups: string[]; // 群组ID列表
|
||||
groupsOptions: GroupSelectionItem[]; // 群组选项列表
|
||||
robots: string[]; // 机器人(设备)ID列表
|
||||
robotsOptions: DeviceSelectionItem[]; // 机器人选项列表
|
||||
messages: WelcomeMessage[]; // 欢迎消息列表
|
||||
[key: string]: any;
|
||||
}
|
||||
366
Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.tsx
Normal file
366
Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button } from "antd";
|
||||
import { Toast } from "antd-mobile";
|
||||
import {
|
||||
createGroupWelcomeTask,
|
||||
fetchGroupWelcomeTaskDetail,
|
||||
updateGroupWelcomeTask,
|
||||
} from "./index.api";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
|
||||
import GroupSelector, { GroupSelectorRef } from "./components/GroupSelector";
|
||||
import RobotSelector, { RobotSelectorRef } from "./components/RobotSelector";
|
||||
import MessageConfig, { MessageConfigRef } from "./components/MessageConfig";
|
||||
import type { FormData, WelcomeMessage } from "./index.data";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤 2", subtitle: "选择机器人" },
|
||||
{ id: 3, title: "步骤 3", subtitle: "选择群组" },
|
||||
{ id: 4, title: "步骤 4", subtitle: "配置消息" },
|
||||
];
|
||||
|
||||
const NewGroupWelcome: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [groupsOptions, setGroupsOptions] = useState<GroupSelectionItem[]>([]);
|
||||
const [robotsOptions, setRobotsOptions] = useState<DeviceSelectionItem[]>([]);
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
status: 1,
|
||||
interval: 1, // 默认1分钟
|
||||
pushType: 0, // 默认定时推送
|
||||
startTime: "09:00",
|
||||
endTime: "21:00",
|
||||
groups: [],
|
||||
groupsOptions: [],
|
||||
robots: [],
|
||||
robotsOptions: [],
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "text",
|
||||
content: "",
|
||||
order: 1,
|
||||
sendInterval: 5,
|
||||
intervalUnit: "seconds",
|
||||
},
|
||||
],
|
||||
});
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
// 创建子组件的ref
|
||||
const basicSettingsRef = useRef<BasicSettingsRef>(null);
|
||||
const groupSelectorRef = useRef<GroupSelectorRef>(null);
|
||||
const robotSelectorRef = useRef<RobotSelectorRef>(null);
|
||||
const messageConfigRef = useRef<MessageConfigRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setIsEditMode(true);
|
||||
// 加载编辑数据
|
||||
const loadEditData = async () => {
|
||||
try {
|
||||
const res = await fetchGroupWelcomeTaskDetail(id);
|
||||
const data = res?.data || res;
|
||||
const config = data?.config || {};
|
||||
|
||||
// 回填表单数据
|
||||
// 处理 groups:可能是字符串数组或字符串
|
||||
let groupsArray: any[] = [];
|
||||
if (config.wechatGroups && Array.isArray(config.wechatGroups)) {
|
||||
groupsArray = config.wechatGroups;
|
||||
} else if (config.groups) {
|
||||
if (Array.isArray(config.groups)) {
|
||||
groupsArray = config.groups;
|
||||
} else if (typeof config.groups === 'string') {
|
||||
try {
|
||||
groupsArray = JSON.parse(config.groups);
|
||||
} catch {
|
||||
groupsArray = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 robots:可能是字符串数组或字符串
|
||||
let robotsArray: any[] = [];
|
||||
if (config.deviceGroups && Array.isArray(config.deviceGroups)) {
|
||||
robotsArray = config.deviceGroups;
|
||||
} else if (config.robots || config.devices) {
|
||||
const robotsData = config.robots || config.devices;
|
||||
if (Array.isArray(robotsData)) {
|
||||
robotsArray = robotsData;
|
||||
} else if (typeof robotsData === 'string') {
|
||||
try {
|
||||
robotsArray = JSON.parse(robotsData);
|
||||
} catch {
|
||||
robotsArray = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: data.name || "",
|
||||
status: data.status ?? config.status ?? 1,
|
||||
interval: config.interval || 1, // 默认1分钟
|
||||
pushType: config.pushType ?? 0,
|
||||
startTime: config.startTime || "09:00",
|
||||
endTime: config.endTime || "21:00",
|
||||
groups: groupsArray.map((id: any) => String(id)),
|
||||
robots: robotsArray.map((id: any) => String(id)),
|
||||
messages: config.messages || [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "text",
|
||||
content: "",
|
||||
order: 1,
|
||||
sendInterval: 5,
|
||||
intervalUnit: "seconds",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// 回填选项数据
|
||||
// 映射群组选项字段:groupAvatar -> avatar, groupName -> name
|
||||
if (config.wechatGroupsOptions) {
|
||||
const mappedGroups = config.wechatGroupsOptions.map((group: any) => ({
|
||||
...group,
|
||||
avatar: group.groupAvatar || group.avatar,
|
||||
name: group.groupName || group.name,
|
||||
ownerNickname: group.nickName || group.ownerNickname,
|
||||
}));
|
||||
setGroupsOptions(mappedGroups);
|
||||
}
|
||||
if (config.deviceGroupsOptions) {
|
||||
setRobotsOptions(config.deviceGroupsOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载编辑数据失败:", error);
|
||||
Toast.show({ content: "加载数据失败", position: "top" });
|
||||
}
|
||||
};
|
||||
loadEditData();
|
||||
}, [id]);
|
||||
|
||||
const handleBasicSettingsChange = (values: Partial<FormData>) => {
|
||||
setFormData(prev => ({ ...prev, ...values }));
|
||||
};
|
||||
|
||||
// 群组选择
|
||||
const handleGroupsChange = (data: {
|
||||
groups: string[];
|
||||
groupsOptions: GroupSelectionItem[];
|
||||
}) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
groups: data.groups,
|
||||
groupsOptions: data.groupsOptions,
|
||||
}));
|
||||
setGroupsOptions(data.groupsOptions);
|
||||
};
|
||||
|
||||
// 机器人选择
|
||||
const handleRobotsChange = (data: {
|
||||
robots: string[];
|
||||
robotsOptions: DeviceSelectionItem[];
|
||||
}) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
robots: data.robots,
|
||||
robotsOptions: data.robotsOptions,
|
||||
}));
|
||||
setRobotsOptions(data.robotsOptions);
|
||||
};
|
||||
|
||||
// 消息配置
|
||||
const handleMessagesChange = (data: { messages: WelcomeMessage[] }) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
messages: data.messages,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 调用 MessageConfig 的表单校验
|
||||
const isValid = (await messageConfigRef.current?.validate()) || false;
|
||||
if (!isValid) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// 获取基础设置中的值
|
||||
const basicSettingsValues = basicSettingsRef.current?.getValues() || {};
|
||||
const messageConfigValues = messageConfigRef.current?.getValues() || {};
|
||||
|
||||
// 构建 API 请求数据
|
||||
const apiData: any = {
|
||||
name: basicSettingsValues.name || formData.name,
|
||||
type: 7, // 入群欢迎语工作台类型固定为7
|
||||
status: basicSettingsValues.status ?? formData.status,
|
||||
interval: basicSettingsValues.interval || formData.interval,
|
||||
pushType: basicSettingsValues.pushType ?? formData.pushType ?? 0,
|
||||
startTime: basicSettingsValues.startTime || formData.startTime || "09:00",
|
||||
endTime: basicSettingsValues.endTime || formData.endTime || "21:00",
|
||||
wechatGroups: formData.groups.map(id => Number(id)), // 使用 wechatGroups
|
||||
deviceGroups: formData.robots.map(id => Number(id)), // 使用 deviceGroups
|
||||
messages: messageConfigValues.messages || formData.messages,
|
||||
};
|
||||
|
||||
// 更新时需要传递id
|
||||
if (id) {
|
||||
apiData.id = Number(id);
|
||||
}
|
||||
|
||||
// 调用创建或更新 API
|
||||
if (id) {
|
||||
await updateGroupWelcomeTask(apiData);
|
||||
Toast.show({ content: "更新成功", position: "top" });
|
||||
navigate("/workspace/group-welcome");
|
||||
} else {
|
||||
await createGroupWelcomeTask(apiData);
|
||||
Toast.show({ content: "创建成功", position: "top" });
|
||||
navigate("/workspace/group-welcome");
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.show({ content: "保存失败,请稍后重试", position: "top" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep < 4) {
|
||||
try {
|
||||
let isValid = false;
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
// 调用 BasicSettings 的表单校验
|
||||
isValid = (await basicSettingsRef.current?.validate()) || false;
|
||||
if (isValid) {
|
||||
const values = basicSettingsRef.current?.getValues();
|
||||
if (values) {
|
||||
handleBasicSettingsChange(values);
|
||||
}
|
||||
setCurrentStep(2);
|
||||
}
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// 调用 RobotSelector 的表单校验
|
||||
isValid = (await robotSelectorRef.current?.validate()) || false;
|
||||
if (isValid) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
break;
|
||||
|
||||
case 3:
|
||||
// 调用 GroupSelector 的表单校验
|
||||
isValid = (await groupSelectorRef.current?.validate()) || false;
|
||||
if (isValid) {
|
||||
setCurrentStep(4);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("表单验证失败:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
return (
|
||||
<div className="footer-btn-group">
|
||||
{currentStep > 1 && (
|
||||
<Button size="large" onClick={handlePrevious}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 4 ? (
|
||||
<Button size="large" type="primary" onClick={handleSave} loading={loading}>
|
||||
保存
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="large" type="primary" onClick={handleNext}>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title={isEditMode ? "编辑任务" : "新建任务"} />}
|
||||
footer={renderFooter()}
|
||||
>
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
</div>
|
||||
<div>
|
||||
{currentStep === 1 && (
|
||||
<BasicSettings
|
||||
ref={basicSettingsRef}
|
||||
defaultValues={{
|
||||
name: formData.name,
|
||||
status: formData.status,
|
||||
interval: formData.interval,
|
||||
pushType: formData.pushType,
|
||||
startTime: formData.startTime,
|
||||
endTime: formData.endTime,
|
||||
}}
|
||||
onNext={handleBasicSettingsChange}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<RobotSelector
|
||||
ref={robotSelectorRef}
|
||||
selectedRobots={robotsOptions}
|
||||
onPrevious={() => setCurrentStep(1)}
|
||||
onNext={handleRobotsChange}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<GroupSelector
|
||||
ref={groupSelectorRef}
|
||||
selectedGroups={groupsOptions}
|
||||
onPrevious={() => setCurrentStep(2)}
|
||||
onNext={handleGroupsChange}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<MessageConfig
|
||||
ref={messageConfigRef}
|
||||
defaultMessages={formData.messages}
|
||||
onPrevious={() => setCurrentStep(3)}
|
||||
onNext={handleMessagesChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewGroupWelcome;
|
||||
@@ -0,0 +1,27 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 获取入群欢迎语任务列表
|
||||
export async function fetchGroupWelcomeTasks() {
|
||||
return request("/v1/workbench/list", { type: 7 }, "GET");
|
||||
}
|
||||
|
||||
// 删除入群欢迎语任务
|
||||
export async function deleteGroupWelcomeTask(id: string): Promise<ApiResponse> {
|
||||
return request("/v1/workbench/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
export function toggleGroupWelcomeTask(data: { id: string; status: number }): Promise<any> {
|
||||
return request("/v1/workbench/update-status", { ...data, type: 7 }, "POST");
|
||||
}
|
||||
|
||||
// 复制任务
|
||||
export async function copyGroupWelcomeTask(id: string): Promise<ApiResponse> {
|
||||
return request("/v1/workbench/copy", { id }, "POST");
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
.nav-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.searchBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
// 只针对当前模块的refresh-btn按钮进行样式设置
|
||||
&.ant-btn {
|
||||
height: 38px !important;
|
||||
width: 40px !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 8px !important;
|
||||
min-width: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.bg {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.taskList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.emptyCard {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.taskCard {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
padding: 20px 16px 12px 16px;
|
||||
}
|
||||
|
||||
.taskHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.taskTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.taskActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.taskInfoGrid {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.taskFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
border-top: 1px dashed #eee;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
// CardMenu 样式
|
||||
.menu-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 28px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background: #fff2f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.taskCard {
|
||||
padding: 12px 6px 8px 6px;
|
||||
}
|
||||
.taskInfoGrid {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
280
Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.tsx
Normal file
280
Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
TeamOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
MoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
MessageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Card, Button, Input, Badge, Switch } from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import {
|
||||
fetchGroupWelcomeTasks,
|
||||
deleteGroupWelcomeTask,
|
||||
toggleGroupWelcomeTask,
|
||||
copyGroupWelcomeTask,
|
||||
} from "./index.api";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
// 卡片菜单组件
|
||||
interface CardMenuProps {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CardMenu: React.FC<CardMenuProps> = ({ onEdit, onCopy, onDelete }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen(v => !v)} className={styles["menu-btn"]}>
|
||||
<MoreOutlined />
|
||||
</button>
|
||||
{open && (
|
||||
<div ref={menuRef} className={styles["menu-dropdown"]}>
|
||||
<div
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={styles["menu-item"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onCopy();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={styles["menu-item"]}
|
||||
>
|
||||
<CopyOutlined />
|
||||
复制
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`${styles["menu-item"]} ${styles["danger"]}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupWelcome: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchGroupWelcomeTasks();
|
||||
setTasks(result.list || result || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (taskId: string) => {
|
||||
if (!window.confirm("确定要删除该任务吗?")) return;
|
||||
await deleteGroupWelcomeTask(taskId);
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/group-welcome/edit/${taskId}`);
|
||||
};
|
||||
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/group-welcome/${taskId}`);
|
||||
};
|
||||
|
||||
const handleCopy = async (taskId: string) => {
|
||||
await copyGroupWelcomeTask(taskId);
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const toggleTaskStatus = async (taskId: string) => {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
const newStatus = task.status === 1 ? 2 : 1;
|
||||
await toggleGroupWelcomeTask({ id: taskId, status: newStatus });
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate("/workspace/group-welcome/new");
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter(task =>
|
||||
task.name?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "green";
|
||||
case 2:
|
||||
return "gray";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 2:
|
||||
return "已暂停";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
loading={loading}
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="入群欢迎语"
|
||||
backFn={() => navigate("/workspace")}
|
||||
right={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
创建任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.searchBar}>
|
||||
<Input
|
||||
placeholder="搜索任务名称"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
<Button
|
||||
onClick={fetchTasks}
|
||||
size="large"
|
||||
className={styles["refresh-btn"]}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<div className={styles.taskList}>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card className={styles.emptyCard}>
|
||||
<MessageOutlined
|
||||
style={{ fontSize: 48, color: "#ccc", marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ color: "#888", fontSize: 16, marginBottom: 8 }}>
|
||||
暂无欢迎语任务
|
||||
</div>
|
||||
<div style={{ color: "#bbb", fontSize: 13, marginBottom: 16 }}>
|
||||
创建您的第一个入群欢迎语任务
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
创建第一个任务
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map(task => (
|
||||
<Card key={task.id} className={styles.taskCard}>
|
||||
<div className={styles.taskHeader}>
|
||||
<div className={styles.taskTitle}>
|
||||
<span>{task.name}</span>
|
||||
<Badge
|
||||
color={getStatusColor(task.status)}
|
||||
text={getStatusText(task.status)}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.taskActions}>
|
||||
<Switch
|
||||
checked={task.status === 1}
|
||||
onChange={() => toggleTaskStatus(task.id)}
|
||||
/>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.taskInfoGrid}>
|
||||
<div>
|
||||
<TeamOutlined />
|
||||
目标群组:{task.config?.wechatGroups?.length || 0} 个群
|
||||
</div>
|
||||
<div>
|
||||
<MessageOutlined /> 欢迎消息:
|
||||
{task.config?.messages?.length || 0} 条
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.taskFooter}>
|
||||
<div>
|
||||
<ClockCircleOutlined /> 时间间隔:
|
||||
{task.config?.interval || 0} 分钟
|
||||
</div>
|
||||
<div>创建时间:{task.createTime || "暂无"}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupWelcome;
|
||||
@@ -59,6 +59,10 @@ export const routeGroups = {
|
||||
"/workspace/traffic-distribution/new",
|
||||
"/workspace/traffic-distribution/edit/:id",
|
||||
"/workspace/traffic-distribution/:id",
|
||||
"/workspace/group-welcome",
|
||||
"/workspace/group-welcome/new",
|
||||
"/workspace/group-welcome/:id",
|
||||
"/workspace/group-welcome/edit/:id",
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ import AIKnowledgeDetail from "@/pages/mobile/workspace/ai-knowledge/detail";
|
||||
import AIKnowledgeForm from "@/pages/mobile/workspace/ai-knowledge/form";
|
||||
import DistributionManagement from "@/pages/mobile/workspace/distribution-management";
|
||||
import ChannelDetailPage from "@/pages/mobile/workspace/distribution-management/detail";
|
||||
import GroupWelcome from "@/pages/mobile/workspace/group-welcome/list";
|
||||
import FormGroupWelcome from "@/pages/mobile/workspace/group-welcome/form";
|
||||
import DetailGroupWelcome from "@/pages/mobile/workspace/group-welcome/detail";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
|
||||
const workspaceRoutes = [
|
||||
@@ -251,6 +254,27 @@ const workspaceRoutes = [
|
||||
element: <ChannelDetailPage />,
|
||||
auth: true,
|
||||
},
|
||||
// 入群欢迎语
|
||||
{
|
||||
path: "/workspace/group-welcome",
|
||||
element: <GroupWelcome />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/workspace/group-welcome/new",
|
||||
element: <FormGroupWelcome />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/workspace/group-welcome/:id",
|
||||
element: <DetailGroupWelcome />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/workspace/group-welcome/edit/:id",
|
||||
element: <FormGroupWelcome />,
|
||||
auth: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default workspaceRoutes;
|
||||
|
||||
320
Cunkebao/项目分析报告.md
Normal file
320
Cunkebao/项目分析报告.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# 存客宝(Cunkebao)项目分析报告
|
||||
|
||||
## 📋 项目概述
|
||||
|
||||
**项目名称**: 存客宝管理系统
|
||||
**版本**: 3.0.0
|
||||
**技术栈**: React 18 + TypeScript + Vite + Zustand + Ant Design Mobile
|
||||
**项目类型**: 移动端微信营销管理系统(PWA应用)
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 核心技术栈
|
||||
|
||||
- **前端框架**: React 18.2.0
|
||||
- **开发语言**: TypeScript 5.4.5
|
||||
- **构建工具**: Vite 7.0.5
|
||||
- **状态管理**: Zustand 5.0.6
|
||||
- **路由管理**: React Router DOM 6.20.0
|
||||
- **UI组件库**:
|
||||
- Ant Design Mobile 5.39.1(移动端)
|
||||
- Ant Design 5.13.1(部分桌面端组件)
|
||||
- **HTTP客户端**: Axios 1.6.7
|
||||
- **图表库**: ECharts 5.6.0 + echarts-for-react
|
||||
- **样式方案**: SCSS + PostCSS (px转rem)
|
||||
- **工具库**:
|
||||
- dayjs(日期处理)
|
||||
- crypto-js(加密)
|
||||
- react-window(虚拟列表)
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
Cunkebao/
|
||||
├── src/
|
||||
│ ├── api/ # API请求封装
|
||||
│ │ ├── request.ts # 主请求封装(带防抖、拦截器)
|
||||
│ │ ├── request2.ts # 备用请求封装
|
||||
│ │ └── common.ts # 通用API
|
||||
│ ├── components/ # 公共组件库
|
||||
│ │ ├── Layout/ # 布局组件
|
||||
│ │ ├── Upload/ # 文件上传组件(图片、视频、音频等)
|
||||
│ │ ├── Selection/ # 选择器组件(账号、设备、群组、好友等)
|
||||
│ │ ├── InfiniteList/# 无限滚动列表
|
||||
│ │ └── ...
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── mobile/ # 移动端页面
|
||||
│ │ │ ├── home/ # 首页
|
||||
│ │ │ ├── workspace/# 工作台(核心业务)
|
||||
│ │ │ ├── scenarios/# 场景管理
|
||||
│ │ │ ├── mine/ # 我的(个人中心)
|
||||
│ │ │ └── test/ # 测试页面
|
||||
│ │ ├── login/ # 登录页
|
||||
│ │ ├── guide/ # 设备绑定引导
|
||||
│ │ └── iframe/ # 内嵌页面
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ ├── config.ts # 路由配置和权限定义
|
||||
│ │ ├── index.tsx # 路由主入口
|
||||
│ │ └── module/ # 模块化路由
|
||||
│ ├── store/ # 状态管理
|
||||
│ │ ├── module/ # Store模块(user, app, settings)
|
||||
│ │ ├── createPersistStore.ts # 持久化Store创建器
|
||||
│ │ └── persistUtils.ts # 持久化工具
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── apiUrl.ts # API地址配置
|
||||
│ │ ├── common.ts # 通用工具
|
||||
│ │ ├── env.ts # 环境变量
|
||||
│ │ └── ...
|
||||
│ └── styles/ # 全局样式
|
||||
├── public/ # 静态资源
|
||||
├── vite.config.ts # Vite配置
|
||||
└── package.json # 项目依赖
|
||||
```
|
||||
|
||||
## 🎯 核心业务功能
|
||||
|
||||
### 1. 工作台(Workspace)- 核心营销功能
|
||||
|
||||
#### 1.1 群发推送(Group Push)
|
||||
- **功能**: 批量向微信群发送消息
|
||||
- **特性**:
|
||||
- 支持选择设备、群组、内容
|
||||
- 任务列表管理(查看、编辑、删除、复制、启用/禁用)
|
||||
- 任务详情查看
|
||||
|
||||
#### 1.2 自动建群(Group Create)
|
||||
- **功能**: 自动化创建微信群
|
||||
- **特性**:
|
||||
- 设备选择
|
||||
- 流量池选择
|
||||
- 群主/管理员设置
|
||||
- 群组列表管理
|
||||
|
||||
#### 1.3 自动点赞(Auto Like)
|
||||
- **功能**: 自动点赞朋友圈
|
||||
- **特性**:
|
||||
- 任务创建和管理
|
||||
- 执行记录查看
|
||||
|
||||
#### 1.4 朋友圈同步(Moments Sync)
|
||||
- **功能**: 同步朋友圈内容
|
||||
- **特性**:
|
||||
- 任务创建
|
||||
- 同步记录管理
|
||||
|
||||
#### 1.5 自动拉群(Auto Group)
|
||||
- **功能**: 自动将用户拉入群组
|
||||
- **特性**:
|
||||
- 设备选择
|
||||
- 流量池选择
|
||||
- 群组配置
|
||||
|
||||
#### 1.6 流量分发(Traffic Distribution)
|
||||
- **功能**: 智能分配用户流量
|
||||
- **特性**:
|
||||
- 渠道管理
|
||||
- 分发规则配置
|
||||
- 分发记录查看
|
||||
|
||||
#### 1.7 AI助手(AI Assistant)
|
||||
- **功能**: AI智能营销助手
|
||||
- **特性**:
|
||||
- AI知识库管理
|
||||
- 智能分析和推荐
|
||||
|
||||
### 2. 场景管理(Scenarios)
|
||||
|
||||
#### 2.1 场景列表
|
||||
- 展示所有营销场景
|
||||
|
||||
#### 2.2 计划管理(Plan)
|
||||
- **功能**: 创建和管理营销计划
|
||||
- **特性**:
|
||||
- 多步骤表单(基础设置、消息设置、好友设置、群组设置、分发设置)
|
||||
- 计划列表查看
|
||||
- 计划详情和编辑
|
||||
|
||||
### 3. 我的(Mine)- 个人中心
|
||||
|
||||
#### 3.1 设备管理
|
||||
- 微信设备绑定和管理
|
||||
- 设备状态监控
|
||||
|
||||
#### 3.2 微信号管理
|
||||
- 微信账号管理
|
||||
- 账号状态查看
|
||||
|
||||
#### 3.3 流量池管理(Traffic Pool)
|
||||
- **功能**: 用户群体管理
|
||||
- **特性**:
|
||||
- 流量池创建(基本信息、人群筛选、用户列表)
|
||||
- RFM分析
|
||||
- 标签筛选
|
||||
- 自定义条件
|
||||
- 方案推荐
|
||||
|
||||
#### 3.4 内容管理(Content)
|
||||
- **功能**: 营销内容库
|
||||
- **特性**:
|
||||
- 内容创建和编辑
|
||||
- 素材管理(图片、视频、文字等)
|
||||
|
||||
#### 3.5 设置
|
||||
- 账户设置
|
||||
- 安全设置
|
||||
- 应用设置
|
||||
- 关于页面
|
||||
|
||||
## 🔐 权限管理
|
||||
|
||||
### 路由权限系统
|
||||
|
||||
项目实现了基于角色的权限控制(RBAC):
|
||||
|
||||
- **管理员(admin)**: 拥有所有路由访问权限
|
||||
- **普通用户(user)**: 基础功能访问权限
|
||||
- **访客(guest)**: 仅登录页访问权限
|
||||
|
||||
权限检查通过 `PermissionRoute` 组件实现,支持:
|
||||
- 路由级别的权限控制
|
||||
- 动态参数路由匹配
|
||||
- 自动重定向到登录页
|
||||
|
||||
## 📦 状态管理
|
||||
|
||||
### Zustand Store架构
|
||||
|
||||
项目使用 Zustand 进行状态管理,并实现了完整的持久化功能:
|
||||
|
||||
#### 1. User Store (`user.ts`)
|
||||
- **存储内容**: 用户信息、登录状态、token
|
||||
- **持久化**: localStorage
|
||||
- **功能**: 登录、登出、用户信息管理
|
||||
|
||||
#### 2. App Store (`app.ts`)
|
||||
- **存储内容**: 应用状态、主题、调试模式
|
||||
- **持久化**: localStorage
|
||||
|
||||
#### 3. Settings Store (`settings.ts`)
|
||||
- **存储内容**: 应用设置、语言、时区
|
||||
- **持久化**: localStorage
|
||||
|
||||
### 持久化特性
|
||||
|
||||
- ✅ 数据压缩
|
||||
- ✅ 数据加密
|
||||
- ✅ TTL支持(自动过期)
|
||||
- ✅ 批量操作
|
||||
- ✅ 存储监控
|
||||
- ✅ 数据迁移
|
||||
- ✅ 备份恢复
|
||||
|
||||
## 🌐 API架构
|
||||
|
||||
### 请求封装特点
|
||||
|
||||
1. **统一拦截器**:
|
||||
- 请求拦截:自动添加Authorization token
|
||||
- 响应拦截:统一错误处理和401跳转
|
||||
|
||||
2. **防抖机制**:
|
||||
- 默认1秒防抖间隔
|
||||
- 可自定义防抖时间
|
||||
- 防止重复请求
|
||||
|
||||
3. **错误处理**:
|
||||
- 统一Toast提示
|
||||
- 401自动跳转登录
|
||||
- 网络异常处理
|
||||
|
||||
## 🎨 UI/UX设计
|
||||
|
||||
### 设计特点
|
||||
|
||||
1. **移动端优先**: 使用 Ant Design Mobile 组件库
|
||||
2. **响应式设计**: 支持不同屏幕尺寸
|
||||
3. **PWA支持**: 可安装为移动应用
|
||||
4. **主题定制**: 支持主题切换
|
||||
5. **虚拟列表**: 使用 react-window 优化长列表性能
|
||||
|
||||
### 组件库
|
||||
|
||||
- **选择器组件**: 账号、设备、群组、好友、内容、流量池选择
|
||||
- **上传组件**: 图片、视频、音频、文件上传
|
||||
- **布局组件**: 固定布局、导航栏、底部栏
|
||||
- **图表组件**: ECharts集成
|
||||
|
||||
## 🔧 开发工具链
|
||||
|
||||
### 代码质量
|
||||
|
||||
- **ESLint**: 代码检查
|
||||
- **Prettier**: 代码格式化
|
||||
- **TypeScript**: 类型安全
|
||||
|
||||
### 构建优化
|
||||
|
||||
- **代码分割**:
|
||||
- vendor(React核心)
|
||||
- ui(UI组件库)
|
||||
- utils(工具库)
|
||||
- charts(图表库)
|
||||
- **压缩**: esbuild压缩
|
||||
- **源码映射**: 生产环境关闭
|
||||
|
||||
### 开发体验
|
||||
|
||||
- **热更新**: Vite HMR
|
||||
- **路径别名**: `@/` 指向 `src/`
|
||||
- **环境变量**: 支持 `.env` 配置
|
||||
|
||||
## 📱 PWA特性
|
||||
|
||||
- **Manifest配置**: 支持安装为移动应用
|
||||
- **Service Worker**: 离线支持(通过vite-pwa)
|
||||
- **更新通知**: `UpdateNotification` 组件
|
||||
|
||||
## 🔍 项目特点
|
||||
|
||||
### 优势
|
||||
|
||||
1. ✅ **模块化架构**: 清晰的目录结构和组件划分
|
||||
2. ✅ **类型安全**: 完整的TypeScript支持
|
||||
3. ✅ **状态管理**: Zustand + 持久化,轻量高效
|
||||
4. ✅ **权限系统**: 完善的RBAC权限控制
|
||||
5. ✅ **组件复用**: 丰富的公共组件库
|
||||
6. ✅ **性能优化**: 代码分割、虚拟列表、防抖节流
|
||||
7. ✅ **移动端优化**: PWA支持、响应式设计
|
||||
|
||||
### 可改进点
|
||||
|
||||
1. ⚠️ **测试覆盖**: 缺少单元测试和集成测试
|
||||
2. ⚠️ **文档完善**: 部分模块缺少详细文档
|
||||
3. ⚠️ **错误边界**: React错误边界处理可以加强
|
||||
4. ⚠️ **国际化**: 目前仅支持中文,可扩展i18n
|
||||
|
||||
## 📊 代码统计
|
||||
|
||||
- **页面数量**: 20+ 个主要页面
|
||||
- **组件数量**: 30+ 个公共组件
|
||||
- **路由数量**: 50+ 个路由配置
|
||||
- **Store模块**: 3个核心Store
|
||||
|
||||
## 🚀 部署配置
|
||||
|
||||
- **构建命令**: `pnpm build`
|
||||
- **预览命令**: `pnpm preview`
|
||||
- **开发服务器**: `pnpm dev` (端口3000)
|
||||
- **输出目录**: `dist/`
|
||||
|
||||
## 📝 总结
|
||||
|
||||
存客宝是一个功能完善的微信营销管理系统,主要面向移动端用户。项目采用现代化的技术栈,架构清晰,代码组织良好。核心功能包括:
|
||||
|
||||
1. **自动化营销**: 群发、建群、点赞、朋友圈同步等
|
||||
2. **流量管理**: 流量池、流量分发、用户筛选
|
||||
3. **内容管理**: 内容库、素材管理
|
||||
4. **设备管理**: 微信设备绑定和监控
|
||||
5. **AI辅助**: AI助手和知识库
|
||||
|
||||
项目整体质量较高,具有良好的可维护性和扩展性。适合作为企业级微信营销管理平台使用。
|
||||
@@ -805,11 +805,8 @@ class WebSocketController extends BaseController
|
||||
"wechatChatroomId" => 0,
|
||||
"wechatFriendId" => $dataArray['wechatFriendId'],
|
||||
];
|
||||
// 发送请求
|
||||
$this->client->send(json_encode($params));
|
||||
// 接收响应
|
||||
$response = $this->client->receive();
|
||||
$message = json_decode($response, true);
|
||||
// 发送请求并获取响应
|
||||
$message = $this->sendMessage($params);
|
||||
if (!empty($message)) {
|
||||
return json_encode(['code' => 200, 'msg' => '信息发送成功', 'data' => $message]);
|
||||
}
|
||||
@@ -853,12 +850,8 @@ class WebSocketController extends BaseController
|
||||
"wechatChatroomId" => $dataArray['wechatChatroomId'],
|
||||
"wechatFriendId" => 0,
|
||||
];
|
||||
|
||||
// 发送请求
|
||||
$this->client->send(json_encode($params));
|
||||
// 接收响应
|
||||
$response = $this->client->receive();
|
||||
$message = json_decode($response, true);
|
||||
// 发送请求并获取响应
|
||||
$message = $this->sendMessage($params);
|
||||
if (!empty($message)) {
|
||||
return json_encode(['code' => 200, 'msg' => '信息发送成功', 'data' => $message]);
|
||||
}
|
||||
@@ -904,7 +897,7 @@ class WebSocketController extends BaseController
|
||||
$message = [];
|
||||
try {
|
||||
//消息拼接 msgType(1:文本 3:图片 43:视频 47:动图表情包 49:小程序)
|
||||
$result = [
|
||||
$params = [
|
||||
"cmdType" => "CmdSendMessage",
|
||||
"content" => $dataArray['content'],
|
||||
"msgSubType" => 0,
|
||||
@@ -914,15 +907,10 @@ class WebSocketController extends BaseController
|
||||
"wechatChatroomId" => $dataArray['wechatChatroomId'],
|
||||
"wechatFriendId" => 0,
|
||||
];
|
||||
|
||||
$result = json_encode($result);
|
||||
$this->client->send($result);
|
||||
$message = $this->client->receive();
|
||||
//关闭WS链接
|
||||
$this->client->close();
|
||||
// 发送请求并获取响应
|
||||
$message = $this->sendMessage($params);
|
||||
//Log::write('WS群消息发送');
|
||||
//Log::write($message);
|
||||
$message = json_decode($message, 1);
|
||||
} catch (\Exception $e) {
|
||||
$msg = $e->getMessage();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace app\api\controller;
|
||||
use app\api\model\WechatChatroomModel;
|
||||
use app\api\model\WechatChatroomMemberModel;
|
||||
use app\job\WechatChatroomJob;
|
||||
use app\job\WorkbenchGroupWelcomeJob;
|
||||
use think\facade\Request;
|
||||
use think\Queue;
|
||||
|
||||
class WechatChatroomController extends BaseController
|
||||
{
|
||||
@@ -218,8 +220,9 @@ class WechatChatroomController extends BaseController
|
||||
])->find();
|
||||
|
||||
if ($member) {
|
||||
$member->savea($data);
|
||||
$member->save($data);
|
||||
} else {
|
||||
// 新成员,记录首次出现时间
|
||||
$data['createTime'] = time();
|
||||
WechatChatroomMemberModel::create($data);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ return [
|
||||
'workbench:trafficDistribute' => 'app\command\WorkbenchTrafficDistributeCommand', // 工作台流量分发任务
|
||||
'workbench:groupPush' => 'app\command\WorkbenchGroupPushCommand', // 工作台群推送任务
|
||||
'workbench:groupCreate' => 'app\command\WorkbenchGroupCreateCommand', // 工作台群创建任务
|
||||
'workbench:groupWelcome' => 'app\command\WorkbenchGroupWelcomeCommand', // 工作台入群欢迎语任务
|
||||
'workbench:import-contact' => 'app\command\WorkbenchImportContactCommand', // 工作台通讯录导入任务
|
||||
'kf:notice' => 'app\command\KfNoticeCommand', // 客服端消息通知
|
||||
|
||||
|
||||
42
Server/application/command/WorkbenchGroupWelcomeCommand.php
Normal file
42
Server/application/command/WorkbenchGroupWelcomeCommand.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
namespace app\command;
|
||||
|
||||
use app\job\WorkbenchGroupWelcomeJob;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
use think\facade\Log;
|
||||
|
||||
class WorkbenchGroupWelcomeCommand extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('workbench:groupWelcome')
|
||||
->setDescription('工作台入群欢迎语任务队列');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$output->writeln('开始处理工作台入群欢迎语任务...');
|
||||
|
||||
try {
|
||||
$job = new WorkbenchGroupWelcomeJob();
|
||||
$result = $job->processWelcomeMessage([], 0);
|
||||
|
||||
if ($result) {
|
||||
$output->writeln('入群欢迎语任务处理完成');
|
||||
} else {
|
||||
$output->writeln('入群欢迎语任务处理失败');
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
$errorMsg = '工作台入群欢迎语任务执行失败:' . $e->getMessage();
|
||||
Log::error($errorMsg);
|
||||
$output->writeln($errorMsg);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use app\cunkebao\model\WorkbenchImportContact;
|
||||
use app\cunkebao\model\WorkbenchMomentsSync;
|
||||
use app\cunkebao\model\WorkbenchGroupPush;
|
||||
use app\cunkebao\model\WorkbenchGroupCreate;
|
||||
use app\cunkebao\model\WorkbenchGroupWelcome;
|
||||
use app\cunkebao\validate\Workbench as WorkbenchValidate;
|
||||
use think\Controller;
|
||||
use think\Db;
|
||||
@@ -33,6 +34,7 @@ class WorkbenchController extends Controller
|
||||
const TYPE_GROUP_CREATE = 4; // 自动建群
|
||||
const TYPE_TRAFFIC_DISTRIBUTION = 5; // 流量分发
|
||||
const TYPE_IMPORT_CONTACT = 6; // 联系人导入
|
||||
const TYPE_GROUP_WELCOME = 7; // 入群欢迎语
|
||||
|
||||
/**
|
||||
* 创建工作台
|
||||
@@ -49,7 +51,6 @@ class WorkbenchController extends Controller
|
||||
|
||||
// 获取请求参数
|
||||
$param = $this->request->post();
|
||||
|
||||
|
||||
// 根据业务默认值补全参数
|
||||
if (
|
||||
@@ -201,6 +202,30 @@ class WorkbenchController extends Controller
|
||||
$config->createTime = time();
|
||||
$config->save();
|
||||
break;
|
||||
case self::TYPE_GROUP_WELCOME: // 入群欢迎语
|
||||
$config = new WorkbenchGroupWelcome;
|
||||
$config->workbenchId = $workbench->id;
|
||||
$config->devices = json_encode($param['deviceGroups'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
$config->groups = json_encode($param['wechatGroups'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
$config->startTime = $param['startTime'] ?? '';
|
||||
$config->endTime = $param['endTime'] ?? '';
|
||||
$config->interval = isset($param['interval']) ? intval($param['interval']) : 0;
|
||||
// messages 作为 JSON 存储(如果表中有 messages 字段)
|
||||
if (isset($param['messages']) && is_array($param['messages'])) {
|
||||
// 按 order 排序
|
||||
usort($param['messages'], function($a, $b) {
|
||||
$orderA = isset($a['order']) ? intval($a['order']) : 0;
|
||||
$orderB = isset($b['order']) ? intval($b['order']) : 0;
|
||||
return $orderA <=> $orderB;
|
||||
});
|
||||
$config->messages = json_encode($param['messages'], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$config->messages = json_encode([], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$config->createTime = time();
|
||||
$config->updateTime = time();
|
||||
$config->save();
|
||||
break;
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
@@ -456,6 +481,23 @@ class WorkbenchController extends Controller
|
||||
}
|
||||
unset($item->importContact, $item->import_contact);
|
||||
break;
|
||||
case self::TYPE_GROUP_WELCOME:
|
||||
if (!empty($item->groupWelcome)) {
|
||||
$item->config = $item->groupWelcome;
|
||||
$item->config->deviceGroups = json_decode($item->config->devices, true);
|
||||
$item->config->wechatGroups = json_decode($item->config->groups, true);
|
||||
// 解析 messages JSON 字段
|
||||
if (!empty($item->config->messages)) {
|
||||
$item->config->messages = json_decode($item->config->messages, true);
|
||||
if (!is_array($item->config->messages)) {
|
||||
$item->config->messages = [];
|
||||
}
|
||||
} else {
|
||||
$item->config->messages = [];
|
||||
}
|
||||
}
|
||||
unset($item->groupWelcome, $item->group_welcome);
|
||||
break;
|
||||
}
|
||||
// 添加创建人名称
|
||||
$item['creatorName'] = $item->user ? $item->user->username : '';
|
||||
@@ -510,6 +552,9 @@ class WorkbenchController extends Controller
|
||||
'importContact' => function ($query) {
|
||||
$query->field('workbenchId,devices,pools,num,remarkType,remark,clearContact,startTime,endTime');
|
||||
},
|
||||
'groupWelcome' => function ($query) {
|
||||
$query->field('workbenchId,devices,groups,startTime,endTime,interval,messages');
|
||||
},
|
||||
];
|
||||
|
||||
$where = [
|
||||
@@ -773,6 +818,23 @@ class WorkbenchController extends Controller
|
||||
}
|
||||
unset($workbench->importContact, $workbench->import_contact);
|
||||
break;
|
||||
case self::TYPE_GROUP_WELCOME:
|
||||
if (!empty($workbench->groupWelcome)) {
|
||||
$workbench->config = $workbench->groupWelcome;
|
||||
$workbench->config->deviceGroups = json_decode($workbench->config->devices, true);
|
||||
$workbench->config->wechatGroups = json_decode($workbench->config->groups, true);
|
||||
// 解析 messages JSON 字段
|
||||
if (!empty($workbench->config->messages)) {
|
||||
$workbench->config->messages = json_decode($workbench->config->messages, true);
|
||||
if (!is_array($workbench->config->messages)) {
|
||||
$workbench->config->messages = [];
|
||||
}
|
||||
} else {
|
||||
$workbench->config->messages = [];
|
||||
}
|
||||
}
|
||||
unset($workbench->groupWelcome, $workbench->group_welcome);
|
||||
break;
|
||||
}
|
||||
unset(
|
||||
$workbench->autoLike,
|
||||
@@ -873,13 +935,14 @@ class WorkbenchController extends Controller
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 获取群(当targetType=1时)
|
||||
if (!empty($workbench->config->wechatGroups) && isset($workbench->config->targetType) && $workbench->config->targetType == 1) {
|
||||
$groupList = Db::name('wechat_group')->alias('wg')
|
||||
->join('wechat_account wa', 'wa.wechatId = wg.ownerWechatId')
|
||||
->where('wg.id', 'in', $workbench->config->wechatGroups)
|
||||
->order('wg.id', 'desc')
|
||||
->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wa.avatar,wa.alias,wg.avatar as groupAvatar')
|
||||
if (!empty($workbench->config->wechatGroups) && $workbench->type != self::TYPE_GROUP_CREATE) {
|
||||
$groupList = Db::table('s2_wechat_chatroom')->alias('wc')
|
||||
->whereIn('wc.id', $workbench->config->wechatGroups)
|
||||
->where('wc.isDeleted', 0)
|
||||
->order('wc.id', 'desc')
|
||||
->field('wc.id,wc.nickname as groupName,wc.wechatAccountWechatId as ownerWechatId,wc.wechatAccountNickname as nickName,wc.wechatAccountAvatar as avatar,wc.wechatAccountAlias as alias,wc.chatroomAvatar as groupAvatar')
|
||||
->select();
|
||||
$workbench->config->wechatGroupsOptions = $groupList;
|
||||
} else {
|
||||
@@ -887,7 +950,7 @@ class WorkbenchController extends Controller
|
||||
}
|
||||
|
||||
// 获取好友(当targetType=2时)
|
||||
if (!empty($workbench->config->wechatFriends) && isset($workbench->config->targetType) && $workbench->config->targetType == 2) {
|
||||
if (!empty($workbench->config->wechatFriends)) {
|
||||
$friendList = Db::table('s2_wechat_friend')->alias('wf')
|
||||
->join(['s2_wechat_account' => 'wa'], 'wa.id = wf.wechatAccountId', 'left')
|
||||
->where('wf.id', 'in', $workbench->config->wechatFriends)
|
||||
@@ -900,7 +963,7 @@ class WorkbenchController extends Controller
|
||||
}
|
||||
|
||||
// 获取流量池(当targetType=2时)
|
||||
if (!empty($workbench->config->trafficPools) && isset($workbench->config->targetType) && $workbench->config->targetType == 2) {
|
||||
if (!empty($workbench->config->trafficPools)) {
|
||||
$poolList = [];
|
||||
$companyId = $this->request->userInfo['companyId'];
|
||||
|
||||
@@ -1065,9 +1128,7 @@ class WorkbenchController extends Controller
|
||||
}
|
||||
|
||||
$workbench->config->wechatGroupsOptions = $wechatGroupsOptions;
|
||||
} else {
|
||||
$workbench->config->wechatGroupsOptions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取管理员选项(自动建群)
|
||||
if ($workbench->type == self::TYPE_GROUP_CREATE && !empty($workbench->config->admins)) {
|
||||
@@ -1258,6 +1319,30 @@ class WorkbenchController extends Controller
|
||||
$config->save();
|
||||
}
|
||||
break;
|
||||
case self::TYPE_GROUP_WELCOME: // 入群欢迎语
|
||||
$config = WorkbenchGroupWelcome::where('workbenchId', $param['id'])->find();
|
||||
if ($config) {
|
||||
$config->devices = json_encode($param['deviceGroups'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
$config->groups = json_encode($param['wechatGroups'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
$config->startTime = $param['startTime'] ?? '';
|
||||
$config->endTime = $param['endTime'] ?? '';
|
||||
$config->interval = isset($param['interval']) ? intval($param['interval']) : 0;
|
||||
// messages 作为 JSON 存储
|
||||
if (isset($param['messages']) && is_array($param['messages'])) {
|
||||
// 按 order 排序
|
||||
usort($param['messages'], function($a, $b) {
|
||||
$orderA = isset($a['order']) ? intval($a['order']) : 0;
|
||||
$orderB = isset($b['order']) ? intval($b['order']) : 0;
|
||||
return $orderA <=> $orderB;
|
||||
});
|
||||
$config->messages = json_encode($param['messages'], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$config->messages = json_encode([], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$config->updateTime = time();
|
||||
$config->save();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
@@ -1476,6 +1561,22 @@ class WorkbenchController extends Controller
|
||||
$newConfig->save();
|
||||
}
|
||||
break;
|
||||
case self::TYPE_GROUP_WELCOME: // 入群欢迎语
|
||||
$config = WorkbenchGroupWelcome::where('workbenchId', $id)->find();
|
||||
if ($config) {
|
||||
$newConfig = new WorkbenchGroupWelcome;
|
||||
$newConfig->workbenchId = $newWorkbench->id;
|
||||
$newConfig->devices = $config->devices;
|
||||
$newConfig->groups = $config->groups;
|
||||
$newConfig->startTime = $config->startTime;
|
||||
$newConfig->endTime = $config->endTime;
|
||||
$newConfig->interval = $config->interval;
|
||||
$newConfig->messages = $config->messages ?? json_encode([], JSON_UNESCAPED_UNICODE);
|
||||
$newConfig->createTime = time();
|
||||
$newConfig->updateTime = time();
|
||||
$newConfig->save();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Db::commit();
|
||||
|
||||
@@ -67,6 +67,11 @@ class Workbench extends Model
|
||||
return $this->hasOne('WorkbenchImportContact', 'workbenchId', 'id');
|
||||
}
|
||||
|
||||
// 入群欢迎语配置关联
|
||||
public function groupWelcome()
|
||||
{
|
||||
return $this->hasOne('WorkbenchGroupWelcome', 'workbenchId', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户关联
|
||||
|
||||
27
Server/application/cunkebao/model/WorkbenchGroupWelcome.php
Normal file
27
Server/application/cunkebao/model/WorkbenchGroupWelcome.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace app\cunkebao\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 入群欢迎语工作台模型
|
||||
*/
|
||||
class WorkbenchGroupWelcome extends Model
|
||||
{
|
||||
protected $table = 'ck_workbench_group_welcome';
|
||||
protected $pk = 'id';
|
||||
protected $name = 'workbench_group_welcome';
|
||||
|
||||
// 自动写入时间戳
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'createTime';
|
||||
protected $updateTime = 'updateTime';
|
||||
|
||||
// 定义关联的工作台
|
||||
public function workbench()
|
||||
{
|
||||
return $this->belongsTo('Workbench', 'workbenchId', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace app\cunkebao\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 入群欢迎语发送记录模型
|
||||
*/
|
||||
class WorkbenchGroupWelcomeItem extends Model
|
||||
{
|
||||
protected $table = 'ck_workbench_group_welcome_item';
|
||||
protected $pk = 'id';
|
||||
protected $name = 'workbench_group_welcome_item';
|
||||
|
||||
// 自动写入时间戳
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'createTime';
|
||||
protected $updateTime = 'updateTime';
|
||||
|
||||
// 状态常量
|
||||
const STATUS_PENDING = 0; // 待发送
|
||||
const STATUS_SENDING = 1; // 发送中
|
||||
const STATUS_SUCCESS = 2; // 发送成功
|
||||
const STATUS_FAILED = 3; // 发送失败
|
||||
|
||||
/**
|
||||
* 定义关联的工作台
|
||||
*/
|
||||
public function workbench()
|
||||
{
|
||||
return $this->belongsTo('Workbench', 'workbenchId', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态文本
|
||||
* @param int $status 状态值
|
||||
* @return string
|
||||
*/
|
||||
public static function getStatusText($status)
|
||||
{
|
||||
$statusMap = [
|
||||
self::STATUS_PENDING => '待发送',
|
||||
self::STATUS_SENDING => '发送中',
|
||||
self::STATUS_SUCCESS => '发送成功',
|
||||
self::STATUS_FAILED => '发送失败',
|
||||
];
|
||||
return $statusMap[$status] ?? '未知';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ class Workbench extends Validate
|
||||
const TYPE_GROUP_CREATE = 4; // 自动建群
|
||||
const TYPE_TRAFFIC_DISTRIBUTION = 5; // 流量分发
|
||||
const TYPE_IMPORT_CONTACT = 6; // 流量分发
|
||||
const TYPE_GROUP_WELCOME = 7; // 入群欢迎语
|
||||
|
||||
/**
|
||||
* 验证规则
|
||||
*/
|
||||
protected $rule = [
|
||||
'name' => 'require|max:100',
|
||||
'type' => 'require|in:1,2,3,4,5,6',
|
||||
'type' => 'require|in:1,2,3,4,5,6,7',
|
||||
//'autoStart' => 'require|boolean',
|
||||
// 自动点赞特有参数
|
||||
'interval' => 'requireIf:type,1|number|min:1',
|
||||
@@ -62,8 +63,14 @@ class Workbench extends Validate
|
||||
'maxPerDay' => 'requireIf:type,5|number|min:1',
|
||||
'timeType' => 'requireIf:type,5|in:1,2',
|
||||
'accountGroups' => 'requireIf:type,5|array|min:1',
|
||||
// 入群欢迎语特有参数
|
||||
'wechatGroups' => 'requireIf:type,7|array|min:1', // 入群欢迎语必须选择群组
|
||||
'interval' => 'requireIf:type,7|number|min:1', // 间隔时间
|
||||
'startTime' => 'requireIf:type,7|dateFormat:H:i', // 开始时间
|
||||
'endTime' => 'requireIf:type,7|dateFormat:H:i', // 结束时间
|
||||
'messages' => 'requireIf:type,7|array|min:1', // 欢迎消息列表
|
||||
// 通用参数
|
||||
'deviceGroups' => 'requireIf:type,1,2,5|array',
|
||||
'deviceGroups' => 'requireIf:type,1,2,5,7|array',
|
||||
'trafficPools' => 'checkFriendPushPools',
|
||||
];
|
||||
|
||||
@@ -185,6 +192,7 @@ class Workbench extends Validate
|
||||
'announcementContent', 'enableAiRewrite', 'aiRewritePrompt',
|
||||
'groupNameTemplate', 'maxGroupsPerDay', 'groupSizeMin', 'groupSizeMax',
|
||||
'distributeType', 'timeType', 'accountGroups',
|
||||
'messages',
|
||||
],
|
||||
'update_status' => ['id', 'status'],
|
||||
'update' => ['name', 'type', 'autoStart', 'deviceGroups', 'targetGroups',
|
||||
@@ -194,6 +202,7 @@ class Workbench extends Validate
|
||||
'announcementContent', 'enableAiRewrite', 'aiRewritePrompt',
|
||||
'groupNameTemplate', 'maxGroupsPerDay', 'groupSizeMin', 'groupSizeMax',
|
||||
'distributeType', 'timeType', 'accountGroups',
|
||||
'messages',
|
||||
]
|
||||
];
|
||||
|
||||
|
||||
441
Server/application/job/WorkbenchGroupWelcomeJob.php
Normal file
441
Server/application/job/WorkbenchGroupWelcomeJob.php
Normal file
@@ -0,0 +1,441 @@
|
||||
<?php
|
||||
|
||||
namespace app\job;
|
||||
|
||||
use app\api\controller\WebSocketController;
|
||||
use app\cunkebao\model\WorkbenchGroupWelcomeItem;
|
||||
use think\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\Env;
|
||||
use think\queue\Job;
|
||||
|
||||
/**
|
||||
* 入群欢迎语任务
|
||||
*/
|
||||
class WorkbenchGroupWelcomeJob
|
||||
{
|
||||
// 常量定义
|
||||
const MAX_RETRY_ATTEMPTS = 3; // 最大重试次数
|
||||
const RETRY_DELAY = 10; // 重试延迟(秒)
|
||||
const MAX_JOIN_AGE_SECONDS = 86400; // 最大入群时间(1天)
|
||||
const MSG_TYPE_TEXT = 1; // 普通文本消息
|
||||
const MSG_TYPE_AT = 90001; // @人消息
|
||||
const WORKBENCH_TYPE_WELCOME = 7; // 入群欢迎语类型
|
||||
const STATUS_SUCCESS = 2; // 发送成功状态
|
||||
/**
|
||||
* 队列执行方法
|
||||
* @param Job $job 队列任务
|
||||
* @param array $data 任务数据
|
||||
* @return void
|
||||
*/
|
||||
public function fire(Job $job, $data)
|
||||
{
|
||||
try {
|
||||
if ($this->processWelcomeMessage($data, $job->attempts())) {
|
||||
$job->delete();
|
||||
} else {
|
||||
if ($job->attempts() > self::MAX_RETRY_ATTEMPTS) {
|
||||
Log::error('入群欢迎语任务执行失败,已超过重试次数,数据:' . json_encode($data));
|
||||
$job->delete();
|
||||
} else {
|
||||
Log::warning('入群欢迎语任务执行失败,重试次数:' . $job->attempts() . ',数据:' . json_encode($data));
|
||||
$job->release(self::RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('入群欢迎语任务异常:' . $e->getMessage());
|
||||
if ($job->attempts() > self::MAX_RETRY_ATTEMPTS) {
|
||||
$job->delete();
|
||||
} else {
|
||||
$job->release(self::RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理欢迎消息发送
|
||||
* @param array $data 任务数据
|
||||
* @param int $attempts 重试次数
|
||||
* @return bool
|
||||
*/
|
||||
public function processWelcomeMessage($data, $attempts)
|
||||
{
|
||||
try {
|
||||
// 查找该群配置的入群欢迎语工作台
|
||||
$welcomeConfigs = Db::table('ck_workbench_group_welcome')
|
||||
->alias('wgw')
|
||||
->join('ck_workbench w', 'w.id = wgw.workbenchId')
|
||||
->where('w.status', 1) // 工作台启用
|
||||
->where('w.type', self::WORKBENCH_TYPE_WELCOME) // 入群欢迎语类型
|
||||
->field('wgw.*,w.id as workbenchId')
|
||||
->select();
|
||||
|
||||
if (empty($welcomeConfigs)) {
|
||||
return true; // 没有配置欢迎语,不算失败
|
||||
}
|
||||
|
||||
foreach ($welcomeConfigs as $config) {
|
||||
// 解析配置中的群组列表
|
||||
$wechatGroups = json_decode($config['groups'] ?? '[]', true);
|
||||
if (!is_array($wechatGroups) || empty($wechatGroups)) {
|
||||
continue; // 该配置没有配置群组,跳过
|
||||
}
|
||||
|
||||
// 遍历该配置中的每个群ID,处理每个群的欢迎语
|
||||
foreach ($wechatGroups as $groupItemId) {
|
||||
// 检查群是否存在
|
||||
$chatroomExists = Db::table('s2_wechat_chatroom')
|
||||
->where('id', $groupItemId)
|
||||
->where('isDeleted', 0)
|
||||
->count();
|
||||
if (!$chatroomExists) {
|
||||
Log::warning("群ID {$groupItemId} 不存在或已删除,跳过欢迎语处理");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理单个群的欢迎语
|
||||
$this->processSingleGroupWelcome($groupItemId, $config);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('处理入群欢迎语异常:' . $e->getMessage() . ', 数据:' . json_encode($data));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个群的欢迎语发送
|
||||
* @param int $groupId 群ID(s2_wechat_chatroom表的id)
|
||||
* @param array $config 工作台配置
|
||||
* @return void
|
||||
*/
|
||||
protected function processSingleGroupWelcome($groupId, $config)
|
||||
{
|
||||
// 根据groupId获取群信息
|
||||
$chatroom = Db::table('s2_wechat_chatroom')
|
||||
->where('id', $groupId)
|
||||
->where('isDeleted', 0)
|
||||
->field('wechatAccountId,wechatAccountWechatId')
|
||||
->find();
|
||||
if (empty($chatroom)) {
|
||||
Log::warning("群ID {$groupId} 不存在或已删除,跳过欢迎语处理");
|
||||
return;
|
||||
}
|
||||
// 检查时间范围
|
||||
if (!$this->isInTimeRange($config['startTime'] ?? '', $config['endTime'] ?? '')) {
|
||||
return; // 不在工作时间范围内
|
||||
}
|
||||
|
||||
// 解析消息列表
|
||||
$messages = json_decode($config['messages'] ?? '[]', true);
|
||||
if (empty($messages) || !is_array($messages)) {
|
||||
return; // 没有配置消息
|
||||
}
|
||||
|
||||
// interval代表整组消息的时间间隔,在此间隔内进群的成员都需要@
|
||||
$interval = intval($config['interval'] ?? 0); // 秒
|
||||
|
||||
// 查找该群最近一次发送欢迎语的时间
|
||||
$lastWelcomeTime = Db::table('ck_workbench_group_welcome_item')
|
||||
->where('workbenchId', $config['workbenchId'])
|
||||
->where('groupid', $groupId)
|
||||
->where('status', self::STATUS_SUCCESS) // 发送成功
|
||||
->order('sendTime', 'desc')
|
||||
->value('sendTime');
|
||||
// 确定时间窗口起点
|
||||
if (!empty($lastWelcomeTime)) {
|
||||
// 如果上次发送时间在interval内,说明还在同一个时间窗口,需要累积新成员
|
||||
$windowStartTime = max($lastWelcomeTime, time() - $interval);
|
||||
} else {
|
||||
// 第一次发送,从interval前开始
|
||||
$windowStartTime = time() - $interval;
|
||||
}
|
||||
|
||||
// 查询该群在时间窗口内的新成员
|
||||
// 通过关联s2_wechat_chatroom表查询,使用groupId
|
||||
$recentMembers = Db::table('s2_wechat_chatroom_member')
|
||||
->alias('wcm')
|
||||
->join(['s2_wechat_chatroom' => 'wc'], 'wc.chatroomId = wcm.chatroomId')
|
||||
->where('wc.id', $groupId)
|
||||
->where('wcm.createTime', '>=', $windowStartTime)
|
||||
->field('wcm.wechatId,wcm.nickname,wcm.createTime')
|
||||
->select();
|
||||
// 入群太久远的成员不要 @,只保留「近期加入」的成员
|
||||
$minJoinTime = time() - self::MAX_JOIN_AGE_SECONDS;
|
||||
$recentMembers = array_values(array_filter($recentMembers, function ($member) use ($minJoinTime) {
|
||||
$joinTime = intval($member['createTime'] ?? 0);
|
||||
return $joinTime >= $minJoinTime;
|
||||
}));
|
||||
|
||||
if (empty($recentMembers)) {
|
||||
return;
|
||||
}
|
||||
// 如果上次发送时间在interval内,检查是否有新成员
|
||||
if (!empty($lastWelcomeTime) && $lastWelcomeTime >= (time() - $interval)) {
|
||||
// 获取上次发送时的成员列表
|
||||
$lastWelcomeItem = Db::table('ck_workbench_group_welcome_item')
|
||||
->where('workbenchId', $config['workbenchId'])
|
||||
->where('groupid', $groupId)
|
||||
->where('sendTime', $lastWelcomeTime)
|
||||
->field('friendId')
|
||||
->find();
|
||||
$lastMemberIds = json_decode($lastWelcomeItem['friendId'] ?? '[]', true);
|
||||
$currentMemberWechatIds = array_column($recentMembers, 'wechatId');
|
||||
|
||||
// 找出新加入的成员
|
||||
$newMemberWechatIds = array_diff($currentMemberWechatIds, $lastMemberIds);
|
||||
if (empty($newMemberWechatIds)) {
|
||||
return; // 没有新成员,跳过
|
||||
}
|
||||
|
||||
// 只发送给新加入的成员
|
||||
$membersToWelcome = [];
|
||||
foreach ($recentMembers as $member) {
|
||||
if (in_array($member['wechatId'], $newMemberWechatIds)) {
|
||||
$membersToWelcome[] = $member;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不在同一个时间窗口,@所有在时间间隔内的成员
|
||||
$membersToWelcome = $recentMembers;
|
||||
}
|
||||
|
||||
if (empty($membersToWelcome)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取设备信息(用于发送消息)
|
||||
$devices = json_decode($config['devices'] ?? '[]', true);
|
||||
if (empty($devices) || !is_array($devices)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// wechatAccountId 是 s2_wechat_account 表的 id
|
||||
$wechatAccountId = $chatroom['wechatAccountId'] ?? 0;
|
||||
$wechatAccountWechatId = $chatroom['wechatAccountWechatId'] ?? '';
|
||||
|
||||
if (empty($wechatAccountWechatId)) {
|
||||
Log::warning("群ID {$groupId} 的微信账号ID为空,跳过欢迎语发送");
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化WebSocket
|
||||
$username = Env::get('api.username', '');
|
||||
$password = Env::get('api.password', '');
|
||||
$toAccountId = '';
|
||||
if (!empty($username) || !empty($password)) {
|
||||
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
|
||||
}
|
||||
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
|
||||
|
||||
// 按order排序消息
|
||||
usort($messages, function($a, $b) {
|
||||
return (intval($a['order'] ?? 0)) <=> (intval($b['order'] ?? 0));
|
||||
});
|
||||
|
||||
// 发送每条消息
|
||||
foreach ($messages as $messageIndex => $message) {
|
||||
$messageContent = $message['content'] ?? '';
|
||||
$sendInterval = intval($message['sendInterval'] ?? 5); // 秒
|
||||
$intervalUnit = $message['intervalUnit'] ?? 'seconds';
|
||||
|
||||
// 转换间隔单位
|
||||
$sendInterval = $this->convertIntervalToSeconds($sendInterval, $intervalUnit);
|
||||
|
||||
// 替换 @{好友} 占位符
|
||||
$processedContent = $this->replaceFriendPlaceholder($messageContent, $membersToWelcome);
|
||||
|
||||
// 构建@消息格式
|
||||
$atContent = $this->buildAtMessage($processedContent, $membersToWelcome);
|
||||
|
||||
// 判断是否有@人:如果有atId,则使用90001,否则使用1
|
||||
$hasAtMembers = !empty($atContent['atId']);
|
||||
$msgType = $hasAtMembers ? self::MSG_TYPE_AT : self::MSG_TYPE_TEXT;
|
||||
|
||||
// 发送消息
|
||||
// 注意:wechatChatroomId 使用 groupId(数字类型),不是 chatroomId
|
||||
$sendResult = $webSocket->sendCommunitys([
|
||||
'content' => json_encode($atContent, JSON_UNESCAPED_UNICODE),
|
||||
'msgType' => $msgType,
|
||||
'wechatAccountId' => intval($wechatAccountId),
|
||||
'wechatChatroomId' => $groupId, // 使用 groupId(数字类型)
|
||||
]);
|
||||
$sendResultData = json_decode($sendResult, true);
|
||||
$sendSuccess = !empty($sendResultData) && isset($sendResultData['code']) && $sendResultData['code'] == 200;
|
||||
// 记录发送记录
|
||||
$friendIds = array_column($membersToWelcome, 'wechatId');
|
||||
$this->saveWelcomeItem([
|
||||
'workbenchId' => $config['workbenchId'],
|
||||
'groupId' => $groupId,
|
||||
'deviceId' => !empty($devices) ? intval($devices[0]) : 0,
|
||||
'wechatAccountId' => $wechatAccountId,
|
||||
'friendId' => $friendIds,
|
||||
'status' => $sendSuccess ? WorkbenchGroupWelcomeItem::STATUS_SUCCESS : WorkbenchGroupWelcomeItem::STATUS_FAILED,
|
||||
'messageIndex' => $messageIndex,
|
||||
'messageId' => $message['id'] ?? '',
|
||||
'content' => $processedContent,
|
||||
'sendTime' => time(),
|
||||
'errorMsg' => $sendSuccess ? '' : ($sendResultData['msg'] ?? '发送失败'),
|
||||
]);
|
||||
|
||||
// 如果不是最后一条消息,等待间隔时间
|
||||
if ($messageIndex < count($messages) - 1) {
|
||||
sleep($sendInterval);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("入群欢迎语发送成功,工作台ID: {$config['workbenchId']}, 群ID: {$groupId}, 成员数: " . count($membersToWelcome));
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换 @{好友} 占位符为群成员昵称(带@符号)
|
||||
* @param string $content 原始内容
|
||||
* @param array $members 成员列表
|
||||
* @return string 替换后的内容
|
||||
*/
|
||||
protected function replaceFriendPlaceholder($content, $members)
|
||||
{
|
||||
if (empty($members)) {
|
||||
return str_replace('@{好友}', '', $content);
|
||||
}
|
||||
|
||||
// 将所有成员的昵称拼接,每个昵称前添加@符号
|
||||
$atNicknames = [];
|
||||
foreach ($members as $member) {
|
||||
$nickname = $member['nickname'] ?? '';
|
||||
if (!empty($nickname)) {
|
||||
$atNicknames[] = '@' . $nickname;
|
||||
} else {
|
||||
// 如果没有昵称,使用wechatId
|
||||
$wechatId = $member['wechatId'] ?? '';
|
||||
if (!empty($wechatId)) {
|
||||
$atNicknames[] = '@' . $wechatId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$atNicknameStr = implode(' ', $atNicknames);
|
||||
|
||||
// 替换 @{好友} 为 @昵称1 @昵称2 ...
|
||||
$content = str_replace('@{好友}', $atNicknameStr, $content);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建@消息格式
|
||||
* @param string $text 文本内容(已替换@{好友}占位符,已包含@符号)
|
||||
* @param array $members 成员列表
|
||||
* @return array 格式:{"text":"@wong @wong 11111111","atId":"WANGMINGZHENG000,WANGMINGZHENG000"}
|
||||
*/
|
||||
protected function buildAtMessage($text, $members)
|
||||
{
|
||||
$atIds = [];
|
||||
|
||||
// 收集所有成员的wechatId用于atId
|
||||
foreach ($members as $member) {
|
||||
$wechatId = $member['wechatId'] ?? '';
|
||||
if (!empty($wechatId)) {
|
||||
$atIds[] = $wechatId;
|
||||
}
|
||||
}
|
||||
|
||||
// 文本中已经包含了@昵称(在replaceFriendPlaceholder中已添加)
|
||||
// 直接使用处理后的文本
|
||||
return [
|
||||
'text' => trim($text),
|
||||
'atId' => implode(',', $atIds)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在工作时间范围内
|
||||
* @param string $startTime 开始时间(格式:HH:mm)
|
||||
* @param string $endTime 结束时间(格式:HH:mm)
|
||||
* @return bool
|
||||
*/
|
||||
protected function isInTimeRange($startTime, $endTime)
|
||||
{
|
||||
if (empty($startTime) || empty($endTime)) {
|
||||
return true; // 如果没有配置时间,默认全天可用
|
||||
}
|
||||
|
||||
$currentTime = date('H:i');
|
||||
$currentMinutes = $this->timeToMinutes($currentTime);
|
||||
$startMinutes = $this->timeToMinutes($startTime);
|
||||
$endMinutes = $this->timeToMinutes($endTime);
|
||||
|
||||
if ($startMinutes <= $endMinutes) {
|
||||
// 正常情况:09:00 - 21:00
|
||||
return $currentMinutes >= $startMinutes && $currentMinutes <= $endMinutes;
|
||||
} else {
|
||||
// 跨天情况:21:00 - 09:00
|
||||
return $currentMinutes >= $startMinutes || $currentMinutes <= $endMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将时间转换为分钟数
|
||||
* @param string $time 时间(格式:HH:mm)
|
||||
* @return int 分钟数
|
||||
*/
|
||||
protected function timeToMinutes($time)
|
||||
{
|
||||
$parts = explode(':', $time);
|
||||
if (count($parts) != 2) {
|
||||
return 0;
|
||||
}
|
||||
return intval($parts[0]) * 60 + intval($parts[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换间隔单位到秒
|
||||
* @param int $interval 间隔数值
|
||||
* @param string $unit 单位(seconds/minutes/hours)
|
||||
* @return int 秒数
|
||||
*/
|
||||
protected function convertIntervalToSeconds($interval, $unit)
|
||||
{
|
||||
switch ($unit) {
|
||||
case 'minutes':
|
||||
return $interval * 60;
|
||||
case 'hours':
|
||||
return $interval * 3600;
|
||||
case 'seconds':
|
||||
default:
|
||||
return $interval;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存欢迎语发送记录
|
||||
* @param array $data 记录数据
|
||||
* @return void
|
||||
*/
|
||||
protected function saveWelcomeItem($data)
|
||||
{
|
||||
try {
|
||||
$item = new WorkbenchGroupWelcomeItem();
|
||||
$item->workbenchId = $data['workbenchId'];
|
||||
$item->groupId = $data['groupId'];
|
||||
$item->deviceId = $data['deviceId'] ?? 0;
|
||||
$item->wechatAccountId = $data['wechatAccountId'] ?? 0;
|
||||
$item->friendId = json_encode($data['friendId'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
$item->status = $data['status'] ?? WorkbenchGroupWelcomeItem::STATUS_PENDING;
|
||||
$item->messageIndex = $data['messageIndex'] ?? null;
|
||||
$item->messageId = $data['messageId'] ?? '';
|
||||
$item->content = $data['content'] ?? '';
|
||||
$item->sendTime = $data['sendTime'] ?? time();
|
||||
$item->errorMsg = $data['errorMsg'] ?? '';
|
||||
$item->retryCount = 0;
|
||||
$item->createTime = time();
|
||||
$item->updateTime = time();
|
||||
$item->save();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('保存入群欢迎语记录失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,9 @@ crontab -l
|
||||
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupCreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupCreate.log 2>&1
|
||||
# 工作台通讯录导入
|
||||
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:import-contact >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/import_contact.log 2>&1
|
||||
# 工作台入群欢迎语
|
||||
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupWelcome >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupWelcome.log 2>&1
|
||||
|
||||
# 消息提醒
|
||||
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think kf:notice >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/kf_notice.log 2>&1
|
||||
# 客服评分
|
||||
|
||||
20
Server/sql_add_group_welcome_item_indexes.sql
Normal file
20
Server/sql_add_group_welcome_item_indexes.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- 为入群欢迎语发送记录表添加索引
|
||||
-- 注意:如果索引已存在会报错,请先删除已存在的索引
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `ck_workbench_group_welcome_item`
|
||||
-- 状态+工作台ID组合索引(用于查询特定工作台的发送状态)
|
||||
ADD INDEX `idx_status_workbench` (`status`, `workbenchId`),
|
||||
-- 工作台ID+群ID组合索引(用于查询特定群的发送记录)
|
||||
ADD INDEX `idx_workbench_group` (`workbenchId`, `groupid`),
|
||||
-- 微信账号ID索引(用于查询特定账号的发送记录)
|
||||
ADD INDEX `idx_wechat_account` (`wechatAccountId`),
|
||||
-- 设备ID索引(用于查询特定设备的发送记录)
|
||||
ADD INDEX `idx_device` (`deviceId`),
|
||||
-- 群聊ID索引(用于查询验证)
|
||||
ADD INDEX `idx_chatroom_id` (`chatroomId`),
|
||||
-- 发送时间索引(用于时间范围查询和排序)
|
||||
ADD INDEX `idx_send_time` (`sendTime`),
|
||||
-- 创建时间索引(用于时间范围查询和排序)
|
||||
ADD INDEX `idx_create_time` (`createTime`);
|
||||
|
||||
4
Server/sql_add_group_welcome_messages.sql
Normal file
4
Server/sql_add_group_welcome_messages.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 为入群欢迎语表添加 messages 字段
|
||||
ALTER TABLE `ck_workbench_group_welcome`
|
||||
ADD COLUMN `messages` JSON NULL COMMENT '欢迎消息列表(JSON数组)' AFTER `interval`;
|
||||
|
||||
58
Server/sql_check_group_welcome_item.md
Normal file
58
Server/sql_check_group_welcome_item.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 入群欢迎语发送记录表检查结果
|
||||
|
||||
## ✅ 字段检查(完整)
|
||||
|
||||
所有必需字段都已存在:
|
||||
- ✅ `id` - 主键ID
|
||||
- ✅ `workbenchId` - 工作台ID
|
||||
- ✅ `deviceId` - 设备ID
|
||||
- ✅ `wechatAccountId` - 微信账号ID(发送者)
|
||||
- ✅ `friendId` - 好友ID列表(JSON数组)
|
||||
- ✅ `groupid` - 群ID
|
||||
- ✅ `chatroomId` - 群聊ID(用于查询验证)
|
||||
- ✅ `status` - 发送状态
|
||||
- ✅ `messageIndex` - 消息索引
|
||||
- ✅ `messageId` - 消息ID
|
||||
- ✅ `content` - 实际发送内容
|
||||
- ✅ `sendTime` - 实际发送时间
|
||||
- ✅ `errorMsg` - 错误信息
|
||||
- ✅ `retryCount` - 重试次数
|
||||
- ✅ `updateTime` - 更新时间
|
||||
- ✅ `createTime` - 创建时间
|
||||
|
||||
## ❌ 索引缺失
|
||||
|
||||
当前表结构只有 `PRIMARY KEY`,缺少以下重要索引:
|
||||
|
||||
1. **`idx_status_workbench`** - 状态+工作台ID组合索引
|
||||
- 用途:查询特定工作台的发送状态统计
|
||||
- 示例:`WHERE status = 2 AND workbenchId = 123`
|
||||
|
||||
2. **`idx_workbench_group`** - 工作台ID+群ID组合索引
|
||||
- 用途:查询特定群的发送记录
|
||||
- 示例:`WHERE workbenchId = 123 AND groupid = 456`
|
||||
|
||||
3. **`idx_wechat_account`** - 微信账号ID索引
|
||||
- 用途:查询特定账号的发送记录
|
||||
- 示例:`WHERE wechatAccountId = 789`
|
||||
|
||||
4. **`idx_device`** - 设备ID索引
|
||||
- 用途:查询特定设备的发送记录
|
||||
- 示例:`WHERE deviceId = 101`
|
||||
|
||||
5. **`idx_chatroom_id`** - 群聊ID索引
|
||||
- 用途:通过群聊ID查询验证
|
||||
- 示例:`WHERE chatroomId = 'xxx'`
|
||||
|
||||
6. **`idx_send_time`** - 发送时间索引
|
||||
- 用途:时间范围查询和排序
|
||||
- 示例:`WHERE sendTime BETWEEN xxx AND yyy ORDER BY sendTime`
|
||||
|
||||
7. **`idx_create_time`** - 创建时间索引
|
||||
- 用途:时间范围查询和排序
|
||||
- 示例:`WHERE createTime BETWEEN xxx AND yyy ORDER BY createTime`
|
||||
|
||||
## 📝 建议
|
||||
|
||||
执行 `sql_add_group_welcome_item_indexes.sql` 添加索引,以提升查询性能。
|
||||
|
||||
49
Server/sql_improve_group_welcome_item.sql
Normal file
49
Server/sql_improve_group_welcome_item.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- 完善入群欢迎语发送记录表
|
||||
ALTER TABLE `ck_workbench_group_welcome_item`
|
||||
-- 添加设备ID
|
||||
ADD COLUMN `deviceId` int(11) NULL DEFAULT 0 COMMENT '设备ID' AFTER `workbenchId`,
|
||||
-- 添加微信账号ID
|
||||
ADD COLUMN `wechatAccountId` int(11) NULL DEFAULT NULL COMMENT '微信账号ID(发送者)' AFTER `deviceId`,
|
||||
-- 添加群聊ID(用于查询验证)
|
||||
ADD COLUMN `chatroomId` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '群聊ID(用于查询验证)' AFTER `groupid`,
|
||||
-- 添加发送状态:0=待发送,1=发送中,2=发送成功,3=发送失败
|
||||
ADD COLUMN `status` tinyint(2) NOT NULL DEFAULT 0 COMMENT '发送状态:0=待发送,1=发送中,2=发送成功,3=发送失败' AFTER `chatroomId`,
|
||||
-- 添加消息索引(发送的是messages中的第几条消息,从0开始)
|
||||
ADD COLUMN `messageIndex` int(11) NULL DEFAULT NULL COMMENT '消息索引(messages数组中的索引,从0开始)' AFTER `status`,
|
||||
-- 添加消息ID(messages中每条消息的id)
|
||||
ADD COLUMN `messageId` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '消息ID(messages中每条消息的id)' AFTER `messageIndex`,
|
||||
-- 添加实际发送内容(替换了@{好友}占位符后的内容)
|
||||
ADD COLUMN `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '实际发送内容(替换占位符后)' AFTER `messageId`,
|
||||
-- 添加发送时间
|
||||
ADD COLUMN `sendTime` int(11) NULL DEFAULT NULL COMMENT '实际发送时间' AFTER `content`,
|
||||
-- 添加错误信息
|
||||
ADD COLUMN `errorMsg` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '错误信息(发送失败时记录)' AFTER `sendTime`,
|
||||
-- 添加重试次数
|
||||
ADD COLUMN `retryCount` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数' AFTER `errorMsg`,
|
||||
-- 添加更新时间
|
||||
ADD COLUMN `updateTime` int(11) NULL DEFAULT NULL COMMENT '更新时间' AFTER `retryCount`,
|
||||
-- 修改 friendId 字段注释,明确是JSON数组
|
||||
MODIFY COLUMN `friendId` json NULL COMMENT '好友ID列表(JSON数组,可包含多个好友ID)',
|
||||
-- 修改 groupid 字段名和注释
|
||||
MODIFY COLUMN `groupid` int(11) NULL DEFAULT NULL COMMENT '群ID(s2_wechat_group表的id)';
|
||||
|
||||
-- 删除已存在的索引(如果存在,避免重复键名错误)
|
||||
-- 注意:如果索引不存在,这些语句会报错,但可以忽略
|
||||
ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX IF EXISTS `idx_status_workbench`;
|
||||
ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX IF EXISTS `idx_workbench_group`;
|
||||
ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX IF EXISTS `idx_wechat_account`;
|
||||
ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX IF EXISTS `idx_device`;
|
||||
ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX IF EXISTS `idx_chatroom_id`;
|
||||
ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX IF EXISTS `idx_send_time`;
|
||||
ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX IF EXISTS `idx_create_time`;
|
||||
|
||||
-- 添加索引(在单独的语句中执行)
|
||||
ALTER TABLE `ck_workbench_group_welcome_item`
|
||||
ADD INDEX `idx_status_workbench` (`status`, `workbenchId`),
|
||||
ADD INDEX `idx_workbench_group` (`workbenchId`, `groupid`),
|
||||
ADD INDEX `idx_wechat_account` (`wechatAccountId`),
|
||||
ADD INDEX `idx_device` (`deviceId`),
|
||||
ADD INDEX `idx_chatroom_id` (`chatroomId`),
|
||||
ADD INDEX `idx_send_time` (`sendTime`),
|
||||
ADD INDEX `idx_create_time` (`createTime`);
|
||||
|
||||
50
Server/sql_improve_group_welcome_item_compatible.sql
Normal file
50
Server/sql_improve_group_welcome_item_compatible.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- 完善入群欢迎语发送记录表(兼容版本,适用于不支持 DROP INDEX IF EXISTS 的 MySQL 版本)
|
||||
-- 第一步:添加字段和修改字段注释
|
||||
ALTER TABLE `ck_workbench_group_welcome_item`
|
||||
-- 添加设备ID
|
||||
ADD COLUMN `deviceId` int(11) NULL DEFAULT 0 COMMENT '设备ID' AFTER `workbenchId`,
|
||||
-- 添加微信账号ID
|
||||
ADD COLUMN `wechatAccountId` int(11) NULL DEFAULT NULL COMMENT '微信账号ID(发送者)' AFTER `deviceId`,
|
||||
-- 添加群聊ID(用于查询验证)
|
||||
ADD COLUMN `chatroomId` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '群聊ID(用于查询验证)' AFTER `groupid`,
|
||||
-- 添加发送状态:0=待发送,1=发送中,2=发送成功,3=发送失败
|
||||
ADD COLUMN `status` tinyint(2) NOT NULL DEFAULT 0 COMMENT '发送状态:0=待发送,1=发送中,2=发送成功,3=发送失败' AFTER `chatroomId`,
|
||||
-- 添加消息索引(发送的是messages中的第几条消息,从0开始)
|
||||
ADD COLUMN `messageIndex` int(11) NULL DEFAULT NULL COMMENT '消息索引(messages数组中的索引,从0开始)' AFTER `status`,
|
||||
-- 添加消息ID(messages中每条消息的id)
|
||||
ADD COLUMN `messageId` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '消息ID(messages中每条消息的id)' AFTER `messageIndex`,
|
||||
-- 添加实际发送内容(替换了@{好友}占位符后的内容)
|
||||
ADD COLUMN `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '实际发送内容(替换占位符后)' AFTER `messageId`,
|
||||
-- 添加发送时间
|
||||
ADD COLUMN `sendTime` int(11) NULL DEFAULT NULL COMMENT '实际发送时间' AFTER `content`,
|
||||
-- 添加错误信息
|
||||
ADD COLUMN `errorMsg` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '错误信息(发送失败时记录)' AFTER `sendTime`,
|
||||
-- 添加重试次数
|
||||
ADD COLUMN `retryCount` int(11) NOT NULL DEFAULT 0 COMMENT '重试次数' AFTER `errorMsg`,
|
||||
-- 添加更新时间
|
||||
ADD COLUMN `updateTime` int(11) NULL DEFAULT NULL COMMENT '更新时间' AFTER `retryCount`,
|
||||
-- 修改 friendId 字段注释,明确是JSON数组
|
||||
MODIFY COLUMN `friendId` json NULL COMMENT '好友ID列表(JSON数组,可包含多个好友ID)',
|
||||
-- 修改 groupid 字段名和注释
|
||||
MODIFY COLUMN `groupid` int(11) NULL DEFAULT NULL COMMENT '群ID(s2_wechat_group表的id)`;
|
||||
|
||||
-- 第二步:删除已存在的索引(如果索引不存在会报错,可以忽略)
|
||||
-- 请根据实际情况执行,如果索引不存在,可以跳过对应的 DROP INDEX 语句
|
||||
-- ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX `idx_status_workbench`;
|
||||
-- ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX `idx_workbench_group`;
|
||||
-- ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX `idx_wechat_account`;
|
||||
-- ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX `idx_device`;
|
||||
-- ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX `idx_chatroom_id`;
|
||||
-- ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX `idx_send_time`;
|
||||
-- ALTER TABLE `ck_workbench_group_welcome_item` DROP INDEX `idx_create_time`;
|
||||
|
||||
-- 第三步:添加索引
|
||||
ALTER TABLE `ck_workbench_group_welcome_item`
|
||||
ADD INDEX `idx_status_workbench` (`status`, `workbenchId`),
|
||||
ADD INDEX `idx_workbench_group` (`workbenchId`, `groupid`),
|
||||
ADD INDEX `idx_wechat_account` (`wechatAccountId`),
|
||||
ADD INDEX `idx_device` (`deviceId`),
|
||||
ADD INDEX `idx_chatroom_id` (`chatroomId`),
|
||||
ADD INDEX `idx_send_time` (`sendTime`),
|
||||
ADD INDEX `idx_create_time` (`createTime`);
|
||||
|
||||
Reference in New Issue
Block a user