入群欢迎语功能提交

This commit is contained in:
Ghost
2026-01-12 09:41:42 +08:00
parent 5909bd93e7
commit 51be5700d8
16 changed files with 2621 additions and 3 deletions

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// 只将@{好友}格式替换为带样式的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;

View File

@@ -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;

View File

@@ -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");
}

View File

@@ -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;
}

View 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;

View File

@@ -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");
}

View File

@@ -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;
}
}

View 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;

View File

@@ -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",
],
},

View File

@@ -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;