From 66d217d5f1272e49182768080822509264e83ccb Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Fri, 9 Jan 2026 17:05:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A5=E7=BE=A4=E6=AC=A2=E8=BF=8E=E8=AF=AD?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/GroupSelection/index.tsx | 11 +- .../group-welcome/detail/index.module.scss | 125 +++ .../workspace/group-welcome/detail/index.tsx | 245 +++++ .../form/components/BasicSettings.tsx | 175 ++++ .../form/components/GroupSelector.tsx | 89 ++ .../form/components/MessageConfig.module.scss | 127 +++ .../form/components/MessageConfig.tsx | 865 ++++++++++++++++++ .../form/components/RobotSelector.tsx | 89 ++ .../workspace/group-welcome/form/index.api.ts | 16 + .../group-welcome/form/index.data.ts | 27 + .../workspace/group-welcome/form/index.tsx | 366 ++++++++ .../workspace/group-welcome/list/index.api.ts | 27 + .../group-welcome/list/index.module.scss | 154 ++++ .../workspace/group-welcome/list/index.tsx | 280 ++++++ Cunkebao/src/router/config.ts | 4 + Cunkebao/src/router/module/workspace.tsx | 24 + Cunkebao/项目分析报告.md | 320 +++++++ .../api/controller/WebSocketController.php | 26 +- .../controller/WechatChatroomController.php | 5 +- Server/application/command.php | 1 + .../command/WorkbenchGroupWelcomeCommand.php | 42 + .../workbench/WorkbenchController.php | 125 ++- .../application/cunkebao/model/Workbench.php | 5 + .../cunkebao/model/WorkbenchGroupWelcome.php | 27 + .../model/WorkbenchGroupWelcomeItem.php | 51 ++ .../cunkebao/validate/Workbench.php | 13 +- .../job/WorkbenchGroupWelcomeJob.php | 441 +++++++++ Server/crontab_tasks.md | 3 + Server/sql_add_group_welcome_item_indexes.sql | 20 + Server/sql_add_group_welcome_messages.sql | 4 + Server/sql_check_group_welcome_item.md | 58 ++ Server/sql_improve_group_welcome_item.sql | 49 + ..._improve_group_welcome_item_compatible.sql | 50 + 33 files changed, 3827 insertions(+), 37 deletions(-) create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/detail/index.module.scss create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/detail/index.tsx create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/BasicSettings.tsx create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/GroupSelector.tsx create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/MessageConfig.module.scss create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/MessageConfig.tsx create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/RobotSelector.tsx create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.api.ts create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.data.ts create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.tsx create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.api.ts create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.module.scss create mode 100644 Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.tsx create mode 100644 Cunkebao/项目分析报告.md create mode 100644 Server/application/command/WorkbenchGroupWelcomeCommand.php create mode 100644 Server/application/cunkebao/model/WorkbenchGroupWelcome.php create mode 100644 Server/application/cunkebao/model/WorkbenchGroupWelcomeItem.php create mode 100644 Server/application/job/WorkbenchGroupWelcomeJob.php create mode 100644 Server/sql_add_group_welcome_item_indexes.sql create mode 100644 Server/sql_add_group_welcome_messages.sql create mode 100644 Server/sql_check_group_welcome_item.md create mode 100644 Server/sql_improve_group_welcome_item.sql create mode 100644 Server/sql_improve_group_welcome_item_compatible.sql diff --git a/Cunkebao/src/components/GroupSelection/index.tsx b/Cunkebao/src/components/GroupSelection/index.tsx index fe180054..e698a799 100644 --- a/Cunkebao/src/components/GroupSelection/index.tsx +++ b/Cunkebao/src/components/GroupSelection/index.tsx @@ -82,10 +82,15 @@ export default function GroupSelection({ {selectedOptions.map(group => (
- +
-
{group.name}
-
{group.chatroomId}
+
{group.groupName || group.name}
+ {group.nickName && ( +
归属:{group.nickName}
+ )} + {!group.nickName && group.chatroomId && ( +
{group.chatroomId}
+ )}
{!readonly && ( + } + /> + } + > +
+ +
+

{taskData.name}

+ +
+ + + {taskData.name} + + + + + {config.interval || 0} 分钟 + + + {taskData.createTime || "暂无"} + + {taskData.updateTime && ( + + {taskData.updateTime} + + )} + +
+ + 目标群组}> +
+ {config.wechatGroupsOptions && config.wechatGroupsOptions.length > 0 ? ( + config.wechatGroupsOptions.map((group: any) => ( +
+ {group.groupAvatar && ( + {group.groupName + )} +
+
{group.groupName || `群组 ${group.id}`}
+ {group.nickName && ( +
归属:{group.nickName}
+ )} +
+
+ )) + ) : ( +
+ 已选择 {config.wechatGroups?.length || 0} 个群组 +
+ )} +
+
+ + 机器人}> +
+ {config.deviceGroupsOptions && config.deviceGroupsOptions.length > 0 ? ( + config.deviceGroupsOptions.map((robot: any) => ( + + {robot.memo || robot.wechatId || robot.nickname || `设备 ${robot.id}`} + + )) + ) : ( +
+ 已选择 {config.deviceGroups?.length || 0} 个机器人 +
+ )} +
+
+ + 欢迎消息}> +
+ {config.messages && config.messages.length > 0 ? ( + config.messages.map((message: any, index: number) => ( +
+
+ 消息 {message.order || index + 1} + {getMessageTypeText(message.type)} +
+
+ {message.type === "text" ? ( +
/g, ">") + .replace(/\n/g, "
") + .replace(/@\{好友\}/g, '@好友') + }} + /> + ) : message.type === "image" ? ( + 图片 + ) : message.type === "video" ? ( +
+
+ )) + ) : ( +
+ 暂无欢迎消息 +
+ )} +
+ +
+ + ); +}; + +export default GroupWelcomeDetail; diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/BasicSettings.tsx b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/BasicSettings.tsx new file mode 100644 index 00000000..6b4c00b1 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/BasicSettings.tsx @@ -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; + getValues: () => any; +} + +const BasicSettings = forwardRef( + ( + { + 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 ( + +
+
+

+ 基础设置 +

+

+ 配置任务的基本信息 +

+
+ + + + + + + + 定时推送 + 立即推送 + + + + {/* 允许推送的时间段 - 只在定时推送时显示 */} + + prevValues.pushType !== currentValues.pushType + } + > + {({ getFieldValue }) => { + // 只在pushType为0(定时推送)时显示时间段设置 + return getFieldValue("pushType") === 0 ? ( + +
+ + + + + + + +
+
+ ) : null; + }} +
+ + + + + + + + (checked ? 1 : 0)} + getValueProps={(value) => ({ checked: value === 1 })} + > + + +
+
+ ); + }, +); + +BasicSettings.displayName = "BasicSettings"; + +export default BasicSettings; diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/GroupSelector.tsx b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/GroupSelector.tsx new file mode 100644 index 00000000..1e335acc --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/GroupSelector.tsx @@ -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; + getValues: () => any; +} + +const GroupSelector = forwardRef( + ({ 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 ( + +
+
+

+ 选择群组 +

+

+ 请选择需要设置欢迎语的群组(可多选) +

+
+ + + + +
+
+ ); + }, +); + +GroupSelector.displayName = "GroupSelector"; + +export default GroupSelector; diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/MessageConfig.module.scss b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/MessageConfig.module.scss new file mode 100644 index 00000000..9757a838 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/MessageConfig.module.scss @@ -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; + } +} diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/MessageConfig.tsx b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/MessageConfig.tsx new file mode 100644 index 00000000..ab7d5f66 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/MessageConfig.tsx @@ -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 = ({ + value, + onChange, + placeholder = "", + maxLength = 500, + onInsertMention, +}) => { + const editorRef = useRef(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 '@好友\u200B'; + } + // 转义其他部分,保留换行符 + return part + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n/g, "
"); // 保留换行符 + }).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); + } + }); + + // 手动遍历所有节点,提取文本和
标签 + 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; + + // 遍历所有节点,包括文本节点、
元素和.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) { + // 光标应该在
之前 + range.setStartBefore(node); + range.setEndBefore(node); + found = true; + } else if (charCount + 1 >= targetOffset) { + // 光标应该在
之后 + 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) => { + 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 ( + <> +
{ + 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} + /> +
+ {value.length}/{maxLength} +
+ + ); +}; + +// 消息类型配置 +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; + getValues: () => any; +} + +const MessageConfig = forwardRef( + ({ defaultMessages = [], onNext }, ref) => { + const [form] = Form.useForm(); + const [messages, setMessages] = useState( + 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) => { + 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 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, ">"); + // 只将@{好友}格式替换为带样式的span,手动输入的@好友不会被高亮 + return escaped.replace( + /@\{好友\}/g, + '@好友' + ); + }; + + // 从富文本中提取纯文本 + const extractTextFromHtml = (html: string) => { + const div = document.createElement("div"); + div.innerHTML = html; + return div.textContent || div.innerText || ""; + }; + + return ( + +
+
+

+ 配置欢迎消息 +

+

+ 配置多条欢迎消息,新成员入群时将按顺序发送 +

+
+ + { + 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(); + }, + }, + ]} + > +
+ {messages.map((message, index) => ( +
+
+ {/* 时间间隔设置 */} +
+
+ 间隔 + + handleUpdateMessage(message.id, { + sendInterval: Number(e.target.value), + }) + } + style={{ width: 60 }} + min={1} + /> + +
+ +
+ {/* 类型切换按钮 */} +
+ {messageTypes.map(type => ( + + ))} +
+
+ +
+ {/* 文本消息 */} + {message.type === "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]; + })()} + /> +
+
+ 提示:@好友为占位符,系统会根据实际情况自动@相应好友 +
+
+ )} + + {/* 图片消息 */} + {message.type === "image" && ( + + handleUpdateMessage(message.id, { + content: urls && urls.length > 0 ? urls[0] : "", + }) + } + count={1} + accept="image/*" + /> + )} + + {/* 视频消息 */} + {message.type === "video" && ( + + 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" && ( + + 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"]} + /> + )} +
+
+ ))} +
+
+ +
+ + + + +
+
+
+ ); + }, +); + +MessageConfig.displayName = "MessageConfig"; + +export default MessageConfig; diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/RobotSelector.tsx b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/RobotSelector.tsx new file mode 100644 index 00000000..c5bd0d06 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/components/RobotSelector.tsx @@ -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; + getValues: () => any; +} + +const RobotSelector = forwardRef( + ({ 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 ( + +
+
+

+ 选择机器人 +

+

+ 请选择用于发送欢迎消息的机器人 +

+
+ + + + +
+
+ ); + }, +); + +RobotSelector.displayName = "RobotSelector"; + +export default RobotSelector; diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.api.ts b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.api.ts new file mode 100644 index 00000000..44f0dae3 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.api.ts @@ -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"); +} diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.data.ts b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.data.ts new file mode 100644 index 00000000..55d7ccd3 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.data.ts @@ -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; +} diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.tsx b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.tsx new file mode 100644 index 00000000..bcce6e95 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/form/index.tsx @@ -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([]); + const [robotsOptions, setRobotsOptions] = useState([]); + + const [formData, setFormData] = useState({ + 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(null); + const groupSelectorRef = useRef(null); + const robotSelectorRef = useRef(null); + const messageConfigRef = useRef(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) => { + 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 ( +
+ {currentStep > 1 && ( + + )} + {currentStep === 4 ? ( + + ) : ( + + )} +
+ ); + }; + + return ( + } + footer={renderFooter()} + > +
+
+ +
+
+ {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + setCurrentStep(1)} + onNext={handleRobotsChange} + /> + )} + {currentStep === 3 && ( + setCurrentStep(2)} + onNext={handleGroupsChange} + /> + )} + {currentStep === 4 && ( + setCurrentStep(3)} + onNext={handleMessagesChange} + /> + )} +
+
+
+ ); +}; + +export default NewGroupWelcome; diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.api.ts b/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.api.ts new file mode 100644 index 00000000..0fde0c80 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.api.ts @@ -0,0 +1,27 @@ +import request from "@/api/request"; + +interface ApiResponse { + 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 { + return request("/v1/workbench/delete", { id }, "DELETE"); +} + +// 切换任务状态 +export function toggleGroupWelcomeTask(data: { id: string; status: number }): Promise { + return request("/v1/workbench/update-status", { ...data, type: 7 }, "POST"); +} + +// 复制任务 +export async function copyGroupWelcomeTask(id: string): Promise { + return request("/v1/workbench/copy", { id }, "POST"); +} diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.module.scss b/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.module.scss new file mode 100644 index 00000000..8563da06 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.module.scss @@ -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; + } +} diff --git a/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.tsx b/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.tsx new file mode 100644 index 00000000..543a9e3e --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/group-welcome/list/index.tsx @@ -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 = ({ onEdit, onCopy, onDelete }) => { + const [open, setOpen] = useState(false); + const menuRef = useRef(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 ( +
+ + {open && ( +
+
{ + onEdit(); + setOpen(false); + }} + className={styles["menu-item"]} + > + + 编辑 +
+
{ + onCopy(); + setOpen(false); + }} + className={styles["menu-item"]} + > + + 复制 +
+
{ + onDelete(); + setOpen(false); + }} + className={`${styles["menu-item"]} ${styles["danger"]}`} + > + + 删除 +
+
+ )} +
+ ); +}; + +const GroupWelcome: React.FC = () => { + const navigate = useNavigate(); + const [searchTerm, setSearchTerm] = useState(""); + const [tasks, setTasks] = useState([]); + 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 ( + + navigate("/workspace")} + right={ + + } + /> + +
+ setSearchTerm(e.target.value)} + prefix={} + allowClear + size="large" + /> + +
+ + } + > +
+
+ {filteredTasks.length === 0 ? ( + + +
+ 暂无欢迎语任务 +
+
+ 创建您的第一个入群欢迎语任务 +
+ +
+ ) : ( + filteredTasks.map(task => ( + +
+
+ {task.name} + +
+
+ toggleTaskStatus(task.id)} + /> + handleView(task.id)} + onEdit={() => handleEdit(task.id)} + onCopy={() => handleCopy(task.id)} + onDelete={() => handleDelete(task.id)} + /> +
+
+
+
+ + 目标群组:{task.config?.wechatGroups?.length || 0} 个群 +
+
+ 欢迎消息: + {task.config?.messages?.length || 0} 条 +
+
+ +
+
+ 时间间隔: + {task.config?.interval || 0} 分钟 +
+
创建时间:{task.createTime || "暂无"}
+
+
+ )) + )} +
+
+
+ ); +}; + +export default GroupWelcome; diff --git a/Cunkebao/src/router/config.ts b/Cunkebao/src/router/config.ts index d2157c20..f161a4e7 100644 --- a/Cunkebao/src/router/config.ts +++ b/Cunkebao/src/router/config.ts @@ -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", ], }, diff --git a/Cunkebao/src/router/module/workspace.tsx b/Cunkebao/src/router/module/workspace.tsx index 8b160cc0..6a4575b6 100644 --- a/Cunkebao/src/router/module/workspace.tsx +++ b/Cunkebao/src/router/module/workspace.tsx @@ -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: , auth: true, }, + // 入群欢迎语 + { + path: "/workspace/group-welcome", + element: , + auth: true, + }, + { + path: "/workspace/group-welcome/new", + element: , + auth: true, + }, + { + path: "/workspace/group-welcome/:id", + element: , + auth: true, + }, + { + path: "/workspace/group-welcome/edit/:id", + element: , + auth: true, + }, ]; export default workspaceRoutes; diff --git a/Cunkebao/项目分析报告.md b/Cunkebao/项目分析报告.md new file mode 100644 index 00000000..720cf7c2 --- /dev/null +++ b/Cunkebao/项目分析报告.md @@ -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助手和知识库 + +项目整体质量较高,具有良好的可维护性和扩展性。适合作为企业级微信营销管理平台使用。 diff --git a/Server/application/api/controller/WebSocketController.php b/Server/application/api/controller/WebSocketController.php index 5c5e3255..73d6bf32 100644 --- a/Server/application/api/controller/WebSocketController.php +++ b/Server/application/api/controller/WebSocketController.php @@ -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(); } diff --git a/Server/application/api/controller/WechatChatroomController.php b/Server/application/api/controller/WechatChatroomController.php index a1a0f9da..3da5ec6b 100644 --- a/Server/application/api/controller/WechatChatroomController.php +++ b/Server/application/api/controller/WechatChatroomController.php @@ -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); } diff --git a/Server/application/command.php b/Server/application/command.php index 0fa99a64..2bf23db7 100644 --- a/Server/application/command.php +++ b/Server/application/command.php @@ -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', // 客服端消息通知 diff --git a/Server/application/command/WorkbenchGroupWelcomeCommand.php b/Server/application/command/WorkbenchGroupWelcomeCommand.php new file mode 100644 index 00000000..6eb55c2b --- /dev/null +++ b/Server/application/command/WorkbenchGroupWelcomeCommand.php @@ -0,0 +1,42 @@ +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; + } + } +} + + diff --git a/Server/application/cunkebao/controller/workbench/WorkbenchController.php b/Server/application/cunkebao/controller/workbench/WorkbenchController.php index 60bdbef4..3bd41698 100644 --- a/Server/application/cunkebao/controller/workbench/WorkbenchController.php +++ b/Server/application/cunkebao/controller/workbench/WorkbenchController.php @@ -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(); diff --git a/Server/application/cunkebao/model/Workbench.php b/Server/application/cunkebao/model/Workbench.php index edde8b74..44e55518 100644 --- a/Server/application/cunkebao/model/Workbench.php +++ b/Server/application/cunkebao/model/Workbench.php @@ -67,6 +67,11 @@ class Workbench extends Model return $this->hasOne('WorkbenchImportContact', 'workbenchId', 'id'); } + // 入群欢迎语配置关联 + public function groupWelcome() + { + return $this->hasOne('WorkbenchGroupWelcome', 'workbenchId', 'id'); + } /** * 用户关联 diff --git a/Server/application/cunkebao/model/WorkbenchGroupWelcome.php b/Server/application/cunkebao/model/WorkbenchGroupWelcome.php new file mode 100644 index 00000000..f449432a --- /dev/null +++ b/Server/application/cunkebao/model/WorkbenchGroupWelcome.php @@ -0,0 +1,27 @@ +belongsTo('Workbench', 'workbenchId', 'id'); + } +} + diff --git a/Server/application/cunkebao/model/WorkbenchGroupWelcomeItem.php b/Server/application/cunkebao/model/WorkbenchGroupWelcomeItem.php new file mode 100644 index 00000000..c75da4e1 --- /dev/null +++ b/Server/application/cunkebao/model/WorkbenchGroupWelcomeItem.php @@ -0,0 +1,51 @@ +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] ?? '未知'; + } +} + diff --git a/Server/application/cunkebao/validate/Workbench.php b/Server/application/cunkebao/validate/Workbench.php index ffd9fae6..0ba19d78 100644 --- a/Server/application/cunkebao/validate/Workbench.php +++ b/Server/application/cunkebao/validate/Workbench.php @@ -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', ] ]; diff --git a/Server/application/job/WorkbenchGroupWelcomeJob.php b/Server/application/job/WorkbenchGroupWelcomeJob.php new file mode 100644 index 00000000..aa0dfbd2 --- /dev/null +++ b/Server/application/job/WorkbenchGroupWelcomeJob.php @@ -0,0 +1,441 @@ +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()); + } + } +} + diff --git a/Server/crontab_tasks.md b/Server/crontab_tasks.md index 38e61ea5..3f1bdba6 100644 --- a/Server/crontab_tasks.md +++ b/Server/crontab_tasks.md @@ -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 # 客服评分 diff --git a/Server/sql_add_group_welcome_item_indexes.sql b/Server/sql_add_group_welcome_item_indexes.sql new file mode 100644 index 00000000..b3437476 --- /dev/null +++ b/Server/sql_add_group_welcome_item_indexes.sql @@ -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`); + diff --git a/Server/sql_add_group_welcome_messages.sql b/Server/sql_add_group_welcome_messages.sql new file mode 100644 index 00000000..96c2f16b --- /dev/null +++ b/Server/sql_add_group_welcome_messages.sql @@ -0,0 +1,4 @@ +-- 为入群欢迎语表添加 messages 字段 +ALTER TABLE `ck_workbench_group_welcome` +ADD COLUMN `messages` JSON NULL COMMENT '欢迎消息列表(JSON数组)' AFTER `interval`; + diff --git a/Server/sql_check_group_welcome_item.md b/Server/sql_check_group_welcome_item.md new file mode 100644 index 00000000..e62d6a3c --- /dev/null +++ b/Server/sql_check_group_welcome_item.md @@ -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` 添加索引,以提升查询性能。 + diff --git a/Server/sql_improve_group_welcome_item.sql b/Server/sql_improve_group_welcome_item.sql new file mode 100644 index 00000000..538e665f --- /dev/null +++ b/Server/sql_improve_group_welcome_item.sql @@ -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`); + diff --git a/Server/sql_improve_group_welcome_item_compatible.sql b/Server/sql_improve_group_welcome_item_compatible.sql new file mode 100644 index 00000000..fb7580b1 --- /dev/null +++ b/Server/sql_improve_group_welcome_item_compatible.sql @@ -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`); +