Merge branch 'develop' into feature/yongpxu-dev

This commit is contained in:
乘风
2026-01-05 09:55:03 +08:00
73 changed files with 20365 additions and 742 deletions

View File

@@ -142,7 +142,11 @@ export default function SelectionPopup({
// 复制一份selectedOptions到临时变量
setTempSelectedOptions([...selectedOptions]);
fetchGroups(1, "");
} else {
// 弹窗关闭时重置状态
setTempSelectedOptions([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
// 搜索防抖(只在弹窗打开且搜索词变化时执行)

View File

@@ -0,0 +1,329 @@
.container {
width: 100%;
}
.inputWrapper {
position: relative;
margin-bottom: 12px;
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
z-index: 1;
pointer-events: none;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.clearBtn {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
font-size: 16px;
z-index: 1;
}
}
.selectedGroupsList {
display: flex;
flex-direction: column;
gap: 16px;
}
.groupCard {
background: #fff;
border-radius: 12px;
padding: 16px;
border: 1px solid #e5e6eb;
}
.groupHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.groupAvatar {
width: 48px;
height: 48px;
border-radius: 8px;
flex-shrink: 0;
}
.groupDetails {
flex: 1;
min-width: 0;
}
.groupName {
font-size: 16px;
font-weight: 500;
color: #222;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groupId {
font-size: 14px;
color: #888;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deleteGroupBtn {
color: #ff4d4f;
font-size: 18px;
padding: 4px;
min-width: auto;
height: auto;
}
.membersSection {
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.membersLabel {
font-size: 14px;
color: #666;
margin-bottom: 12px;
font-weight: 500;
}
.membersList {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.memberItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
position: relative;
width: 70px;
}
.memberAvatar {
width: 56px;
height: 56px;
border-radius: 50%;
position: relative;
}
.removeMemberBtn {
position: absolute;
top: -4px;
right: -4px;
width: 20px;
height: 20px;
min-width: 20px;
padding: 0;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 50%;
color: #ff4d4f;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.memberName {
font-size: 12px;
color: #222;
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.addMemberBtn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border: 1px dashed #d9d9d9;
border-radius: 50%;
background: #fafafa;
color: #999;
font-size: 20px;
cursor: pointer;
transition: all 0.2s;
gap: 4px;
span {
font-size: 12px;
}
&:active {
background: #f0f0f0;
border-color: #1677ff;
color: #1677ff;
}
}
.memberSelectionPopup {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
.popupHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.popupTitle {
font-size: 18px;
font-weight: 600;
color: #222;
}
.closeBtn {
color: #1677ff;
font-size: 16px;
}
.searchBox {
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.searchInputWrapper {
flex: 1;
position: relative;
display: flex;
align-items: center;
}
.searchInput {
flex: 1;
background: #f5f5f5;
border-radius: 8px;
padding: 8px 12px;
font-size: 14px;
}
.clearSearchBtn {
position: absolute;
right: 8px;
width: 20px;
height: 20px;
min-width: 20px;
padding: 0;
color: #999;
display: flex;
align-items: center;
justify-content: center;
}
.searchBtn {
min-width: 60px;
height: 32px;
}
.memberList {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.memberListItem {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
&:last-child {
border-bottom: none;
}
&.selected {
.memberListItemName {
color: #1677ff;
}
}
}
.memberListItemAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.memberListItemName {
flex: 1;
font-size: 16px;
color: #222;
}
.checkmark {
color: #1677ff;
font-size: 18px;
font-weight: bold;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.loadingText {
font-size: 14px;
color: #999;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.emptyText {
font-size: 14px;
color: #999;
}

View File

@@ -0,0 +1,438 @@
import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined, PlusOutlined, CloseOutlined } from "@ant-design/icons";
import { Button, Input, Popup } from "antd-mobile";
import { Avatar } from "antd-mobile";
import style from "./index.module.scss";
import GroupSelection from "../GroupSelection";
import { GroupSelectionItem } from "../GroupSelection/data";
import request from "@/api/request";
// 群成员接口
export interface GroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
}
// 带成员的群选项
export interface GroupWithMembers extends GroupSelectionItem {
members?: GroupMember[];
groupId?: string; // 用于关联成员和群
}
interface GroupSelectionWithMembersProps {
selectedGroups: GroupWithMembers[];
onSelect: (groups: GroupWithMembers[]) => void;
placeholder?: string;
className?: string;
readonly?: boolean;
}
// 获取群成员列表
const getGroupMembers = async (
groupId: string,
page: number = 1,
limit: number = 100,
keyword: string = "",
): Promise<GroupMember[]> => {
try {
const params: any = {
page,
limit,
groupId,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const response = await request("/v1/kefu/wechatChatroom/members", params, "GET");
// request 拦截器会返回 res.data.data ?? res.data
// 对于 { code: 200, data: { list: [...] } } 的返回,拦截器会返回 { list: [...] }
const memberList = response?.list || response?.data?.list || [];
// 映射接口返回的数据结构到我们的接口
return memberList.map((item: any) => ({
id: String(item.id),
nickname: item.nickname || "",
wechatId: item.wechatId || "",
avatar: item.avatar || "",
gender: undefined, // 接口未返回,暂时设为 undefined
role: undefined, // 接口未返回,暂时设为 undefined
}));
} catch (error) {
console.error("获取群成员失败:", error);
return [];
}
};
const GroupSelectionWithMembers: React.FC<GroupSelectionWithMembersProps> = ({
selectedGroups,
onSelect,
placeholder = "选择聊天群",
className = "",
readonly = false,
}) => {
const [groupSelectionVisible, setGroupSelectionVisible] = useState(false);
const [memberSelectionVisible, setMemberSelectionVisible] = useState<{
visible: boolean;
groupId: string;
}>({ visible: false, groupId: "" });
const [allMembers, setAllMembers] = useState<Record<string, GroupMember[]>>({});
const [selectedMembers, setSelectedMembers] = useState<Record<string, GroupMember[]>>({});
const [loadingMembers, setLoadingMembers] = useState(false);
const [memberSearchKeyword, setMemberSearchKeyword] = useState("");
// 存储完整成员列表(用于搜索时切换回完整列表)
const [fullMembersCache, setFullMembersCache] = useState<Record<string, GroupMember[]>>({});
// 处理群选择
const handleGroupSelect = (groups: GroupSelectionItem[]) => {
const groupsWithMembers: GroupWithMembers[] = groups.map(group => {
const existing = selectedGroups.find(g => g.id === group.id);
return {
...group,
members: existing?.members || [],
};
});
onSelect(groupsWithMembers);
setGroupSelectionVisible(false);
};
// 删除群
const handleRemoveGroup = (groupId: string) => {
if (readonly) return;
const newGroups = selectedGroups.filter(g => g.id !== groupId);
const newSelectedMembers = { ...selectedMembers };
delete newSelectedMembers[groupId];
setSelectedMembers(newSelectedMembers);
onSelect(newGroups);
};
// 打开成员选择弹窗
const handleOpenMemberSelection = async (groupId: string) => {
if (readonly) return;
setMemberSelectionVisible({ visible: true, groupId });
setMemberSearchKeyword(""); // 重置搜索关键词
// 如果还没有加载过该群的成员列表,则加载所有成员(不使用搜索关键词)
if (!allMembers[groupId] && !fullMembersCache[groupId]) {
setLoadingMembers(true);
try {
const members = await getGroupMembers(groupId, 1, 100, "");
setAllMembers(prev => ({ ...prev, [groupId]: members }));
setFullMembersCache(prev => ({ ...prev, [groupId]: members })); // 缓存完整列表
} catch (error) {
console.error("加载群成员失败:", error);
} finally {
setLoadingMembers(false);
}
} else if (fullMembersCache[groupId] && !allMembers[groupId]) {
// 如果有缓存但没有显示列表,恢复完整列表
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
}
};
// 关闭成员选择弹窗
const handleCloseMemberSelection = () => {
setMemberSelectionVisible({ visible: false, groupId: "" });
setMemberSearchKeyword(""); // 重置搜索关键词
};
// 手动触发搜索
const handleSearchMembers = async () => {
const groupId = memberSelectionVisible.groupId;
if (!groupId) return;
const keyword = memberSearchKeyword.trim();
// 如果搜索关键词为空,使用缓存的完整列表
if (!keyword) {
if (fullMembersCache[groupId] && fullMembersCache[groupId].length > 0) {
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
}
return;
}
// 有搜索关键词时,调用 API 搜索
setLoadingMembers(true);
try {
const members = await getGroupMembers(groupId, 1, 100, keyword);
setAllMembers(prev => ({ ...prev, [groupId]: members }));
} catch (error) {
console.error("搜索群成员失败:", error);
} finally {
setLoadingMembers(false);
}
};
// 清空搜索
const handleClearSearch = () => {
setMemberSearchKeyword("");
const groupId = memberSelectionVisible.groupId;
if (groupId && fullMembersCache[groupId] && fullMembersCache[groupId].length > 0) {
setAllMembers(prev => ({ ...prev, [groupId]: fullMembersCache[groupId] }));
}
};
// 选择成员
const handleSelectMember = (groupId: string, member: GroupMember) => {
if (readonly) return;
const currentMembers = selectedMembers[groupId] || [];
const isSelected = currentMembers.some(m => m.id === member.id);
let newSelectedMembers = { ...selectedMembers };
if (isSelected) {
newSelectedMembers[groupId] = currentMembers.filter(m => m.id !== member.id);
} else {
newSelectedMembers[groupId] = [...currentMembers, member];
}
setSelectedMembers(newSelectedMembers);
// 更新群数据
const updatedGroups = selectedGroups.map(group => {
if (group.id === groupId) {
return {
...group,
members: newSelectedMembers[groupId] || [],
};
}
return group;
});
onSelect(updatedGroups);
};
// 移除成员
const handleRemoveMember = (groupId: string, memberId: string) => {
if (readonly) return;
const currentMembers = selectedMembers[groupId] || [];
const newMembers = currentMembers.filter(m => m.id !== memberId);
const newSelectedMembers = { ...selectedMembers };
newSelectedMembers[groupId] = newMembers;
setSelectedMembers(newSelectedMembers);
// 更新群数据
const updatedGroups = selectedGroups.map(group => {
if (group.id === groupId) {
return {
...group,
members: newMembers,
};
}
return group;
});
onSelect(updatedGroups);
};
// 同步 selectedGroups 到 selectedMembers
useEffect(() => {
const membersMap: Record<string, GroupMember[]> = {};
selectedGroups.forEach(group => {
if (group.members && group.members.length > 0) {
membersMap[group.id] = group.members;
}
});
setSelectedMembers(membersMap);
}, [selectedGroups.length]);
// 获取显示文本
const getDisplayText = () => {
if (selectedGroups.length === 0) return "";
return `已选择${selectedGroups.length}个群聊`;
};
const currentGroupMembers = allMembers[memberSelectionVisible.groupId] || [];
const currentSelectedMembers = selectedMembers[memberSelectionVisible.groupId] || [];
return (
<div className={`${style.container} ${className}`}>
{/* 输入框 */}
<div
className={style.inputWrapper}
onClick={() => !readonly && setGroupSelectionVisible(true)}
>
<SearchOutlined className={style.inputIcon} />
<Input
placeholder={placeholder}
value={getDisplayText()}
readOnly
className={style.input}
/>
{!readonly && selectedGroups.length > 0 && (
<Button
fill="none"
size="small"
className={style.clearBtn}
onClick={e => {
e.stopPropagation();
setSelectedMembers({});
onSelect([]);
}}
>
<DeleteOutlined />
</Button>
)}
</div>
{/* 已选群列表 */}
{selectedGroups.length > 0 && (
<div className={style.selectedGroupsList}>
{selectedGroups.map(group => (
<div key={group.id} className={style.groupCard}>
{/* 群信息 */}
<div className={style.groupHeader}>
<div className={style.groupInfo}>
<Avatar src={group.avatar} className={style.groupAvatar} />
<div className={style.groupDetails}>
<div className={style.groupName}>{group.name}</div>
<div className={style.groupId}>ID: {group.chatroomId || group.id}</div>
</div>
</div>
{!readonly && (
<Button
fill="none"
size="small"
className={style.deleteGroupBtn}
onClick={() => handleRemoveGroup(group.id)}
>
<DeleteOutlined />
</Button>
)}
</div>
{/* 成员选择区域 */}
<div className={style.membersSection}>
<div className={style.membersLabel}>
({group.members?.length || 0})
</div>
<div className={style.membersList}>
{group.members?.map(member => (
<div key={member.id} className={style.memberItem}>
<Avatar src={member.avatar} className={style.memberAvatar} />
<div className={style.memberName}>{member.nickname}</div>
{!readonly && (
<Button
fill="none"
size="small"
className={style.removeMemberBtn}
onClick={() => handleRemoveMember(group.id, member.id)}
>
<DeleteOutlined />
</Button>
)}
</div>
))}
{!readonly && (
<div
className={style.addMemberBtn}
onClick={() => handleOpenMemberSelection(group.id)}
>
<PlusOutlined />
<span></span>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* 群选择弹窗 */}
<GroupSelection
selectedOptions={selectedGroups as GroupSelectionItem[]}
onSelect={handleGroupSelect}
placeholder={placeholder}
visible={groupSelectionVisible}
onVisibleChange={setGroupSelectionVisible}
showInput={false}
showSelectedList={false}
/>
{/* 成员选择弹窗 */}
<Popup
visible={memberSelectionVisible.visible}
onMaskClick={handleCloseMemberSelection}
position="bottom"
bodyStyle={{
height: "70vh",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<div className={style.memberSelectionPopup}>
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
<Button
fill="none"
size="small"
onClick={handleCloseMemberSelection}
className={style.closeBtn}
>
</Button>
</div>
<div className={style.searchBox}>
<div className={style.searchInputWrapper}>
<Input
placeholder="搜索成员昵称或微信号"
value={memberSearchKeyword}
onChange={val => setMemberSearchKeyword(val)}
onEnterPress={handleSearchMembers}
className={style.searchInput}
/>
{memberSearchKeyword && (
<Button
fill="none"
size="small"
className={style.clearSearchBtn}
onClick={handleClearSearch}
>
<CloseOutlined />
</Button>
)}
</div>
<Button
color="primary"
size="small"
onClick={handleSearchMembers}
loading={loadingMembers}
className={style.searchBtn}
>
</Button>
</div>
<div className={style.memberList}>
{loadingMembers ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : currentGroupMembers.length > 0 ? (
currentGroupMembers.map(member => {
const isSelected = currentSelectedMembers.some(m => m.id === member.id);
return (
<div
key={member.id}
className={`${style.memberListItem} ${isSelected ? style.selected : ""}`}
onClick={() => handleSelectMember(memberSelectionVisible.groupId, member)}
>
<Avatar src={member.avatar} className={style.memberListItemAvatar} />
<div className={style.memberListItemName}>{member.nickname}</div>
{isSelected && <div className={style.checkmark}></div>}
</div>
);
})
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}></div>
</div>
)}
</div>
</div>
</Popup>
</div>
);
};
export default GroupSelectionWithMembers;

View File

@@ -6,12 +6,13 @@ import { DownOutlined } from "@ant-design/icons";
import NavCommon from "@/components/NavCommon";
import FriendSelection from "@/components/FriendSelection";
import GroupSelection from "@/components/GroupSelection";
import GroupSelectionWithMembers from "@/components/GroupSelectionWithMembers";
import DeviceSelection from "@/components/DeviceSelection";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import request from "@/api/request";
import { getContentLibraryDetail, updateContentLibrary } from "./api";
import { getContentLibraryDetail, updateContentLibrary, createContentLibrary } from "./api";
import { GroupSelectionItem } from "@/components/GroupSelection/data";
import { GroupWithMembers } from "@/components/GroupSelectionWithMembers";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
@@ -49,6 +50,9 @@ export default function ContentForm() {
const [selectedGroupsOptions, setSelectedGroupsOptions] = useState<
GroupSelectionItem[]
>([]);
const [selectedGroupsWithMembers, setSelectedGroupsWithMembers] = useState<
GroupWithMembers[]
>([]);
const [useAI, setUseAI] = useState(false);
const [aiPrompt, setAIPrompt] = useState("重写这条朋友圈 要求: 1、原本的字数和意思不要修改超过10% 2、出现品牌名或个人名字就去除");
const [enabled, setEnabled] = useState(true);
@@ -64,6 +68,7 @@ export default function ContentForm() {
"text",
"image",
"video",
"link",
]);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(false);
@@ -94,12 +99,40 @@ export default function ContentForm() {
: []);
setSelectedDevices(deviceOptions || []);
setSelectedFriends(data.sourceFriends || []);
setSelectedGroups(data.selectedGroups || []);
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
// 使用 wechatGroupsOptions 作为群列表数据
const groupsOptions = data.wechatGroupsOptions || data.selectedGroupsOptions || [];
setSelectedGroupsOptions(groupsOptions);
// 从 groupsOptions 中提取群 ID 列表,如果没有 selectedGroups 的话
const groupIds = data.selectedGroups && data.selectedGroups.length > 0
? data.selectedGroups
: groupsOptions.map((g: any) => String(g.id));
setSelectedGroups(groupIds);
// 处理带成员的群数据
// groupMembersOptions 是一个对象key是群ID字符串value是成员数组
const groupMembersMap = data.groupMembersOptions || {};
const groupsWithMembers: GroupWithMembers[] = groupsOptions.map(
(group: any) => {
const groupIdStr = String(group.id);
const members = groupMembersMap[groupIdStr] || [];
// 映射成员数据结构
return {
...group,
members: members.map((member: any) => ({
id: String(member.id),
nickname: member.nickname || "",
wechatId: member.wechatId || "",
avatar: member.avatar || "",
gender: undefined,
role: undefined,
})),
};
},
);
setSelectedGroupsWithMembers(groupsWithMembers);
setSelectedFriendsOptions(data.friendsGroupsOptions || []);
setKeywordsInclude((data.keywordInclude || []).join(","));
setKeywordsExclude((data.keywordExclude || []).join(","));
setCatchType(data.catchType || ["text", "image", "video"]);
setCatchType(data.catchType || ["text", "image", "video", "link"]);
setAIPrompt(data.aiPrompt || "");
// aiEnabled 为 AI 提示词开关1 开启 0 关闭
if (typeof data.aiEnabled !== "undefined") {
@@ -141,7 +174,15 @@ export default function ContentForm() {
devices: selectedDevices.map(d => d.id),
friendsGroups: friendsGroups,
wechatGroups: selectedGroups,
groupMembers: {},
groupMembers: selectedGroupsWithMembers.reduce(
(acc, group) => {
if (group.members && group.members.length > 0) {
acc[group.id] = group.members.map(m => m.id);
}
return acc;
},
{} as Record<string, string[]>,
),
keywordInclude: keywordsInclude
.split(/,||\n|\s+/)
.map(s => s.trim())
@@ -162,7 +203,7 @@ export default function ContentForm() {
await updateContentLibrary({ id, ...payload });
Toast.show({ content: "保存成功", position: "top" });
} else {
await request("/v1/content/library/create", { ...payload, formType: 0 }, "POST");
await createContentLibrary(payload);
Toast.show({ content: "创建成功", position: "top" });
}
navigate("/mine/content");
@@ -181,6 +222,12 @@ export default function ContentForm() {
setSelectedGroupsOptions(groups);
};
const handleGroupsWithMembersChange = (groups: GroupWithMembers[]) => {
setSelectedGroupsWithMembers(groups);
setSelectedGroups(groups.map(g => g.id.toString()));
setSelectedGroupsOptions(groups);
};
const handleFriendsChange = (friends: FriendSelectionItem[]) => {
setSelectedFriends(friends.map(f => f.id.toString()));
setSelectedFriendsOptions(friends);
@@ -234,7 +281,7 @@ export default function ContentForm() {
</label>
<Tag color="blue" className={style["source-tag"]}>
</Tag>
</div>
@@ -336,9 +383,9 @@ export default function ContentForm() {
/>
</Tabs.Tab>
<Tabs.Tab title="选择聊天群" key="groups">
<GroupSelection
selectedOptions={selectedGroupsOptions}
onSelect={handleGroupsChange}
<GroupSelectionWithMembers
selectedGroups={selectedGroupsWithMembers}
onSelect={handleGroupsWithMembersChange}
placeholder="选择聊天群"
/>
</Tabs.Tab>
@@ -386,7 +433,7 @@ export default function ContentForm() {
<span className={style["content-type-title"]}></span>
</div>
<div className={style["content-type-buttons"]}>
{["text", "image", "video"].map(type => (
{["text", "image", "video", "link"].map(type => (
<button
key={type}
className={`${style["content-type-btn"]} ${
@@ -404,7 +451,9 @@ export default function ContentForm() {
? "文本"
: type === "image"
? "图片"
: "视频"}
: type === "video"
? "视频"
: "链接"}
</button>
))}
</div>

View File

@@ -692,13 +692,13 @@ const WechatAccountDetail: React.FC = () => {
<div className={style["health-score-info"]}>
<div className={style["health-score-status"]}>
<span className={style["status-tag"]}>{overviewData?.healthScoreAssessment?.statusTag || "已添加加人"}</span>
<span className={style["status-time"]}>: {overviewData?.healthScoreAssessment?.lastAddTime || "18:44:14"}</span>
<span className={style["status-time"]}>: {overviewData?.healthScoreAssessment?.lastAddTime || "-"}</span>
</div>
<div className={style["health-score-display"]}>
<div className={style["score-circle-wrapper"]}>
<div className={style["score-circle"]}>
<div className={style["score-number"]}>
{overviewData?.healthScoreAssessment?.score || 67}
{overviewData?.healthScoreAssessment?.score || 0}
</div>
<div className={style["score-label"]}>SCORE</div>
</div>
@@ -810,7 +810,7 @@ const WechatAccountDetail: React.FC = () => {
<div className={style["score-circle-wrapper"]}>
<div className={style["score-circle"]}>
<div className={style["score-number"]}>
{overviewData?.healthScoreAssessment?.score || 67}
{overviewData?.healthScoreAssessment?.score || 0}
</div>
<div className={style["score-label"]}>SCORE</div>
</div>
@@ -869,7 +869,7 @@ const WechatAccountDetail: React.FC = () => {
<div className={style["health-item"]} key={`record-${index}`}>
<div className={style["health-item-label"]}>
<span className={style["health-item-icon-warning"]}></span>
{record.title || record.description || "记录"}
{record.name || record.description || "记录"}
{record.statusTag && (
<span className={style["health-item-tag"]}>
{record.statusTag}

View File

@@ -6,7 +6,7 @@ import { PlanDetail, PlanListResponse, ApiResponse } from "./data";
export function getPlanList(params: {
sceneId: string;
page: number;
pageSize: number;
limit: number;
}): Promise<PlanListResponse> {
return request(`/v1/plan/list`, params, "GET");
}
@@ -36,6 +36,13 @@ export function getUserList(planId: string, type: number) {
}
//获客列表
export function getFriendRequestTaskStats(taskId: string) {
return request(`/v1/dashboard/friendRequestTaskStats`, { taskId }, "GET");
export function getFriendRequestTaskStats(
taskId: string,
params?: { startTime?: string; endTime?: string },
) {
return request(
`/v1/dashboard/friendRequestTaskStats`,
{ taskId, ...params },
"GET",
);
}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { Popup, SpinLoading } from "antd-mobile";
import { Button, message } from "antd";
import { CloseOutlined } from "@ant-design/icons";
import React, { useEffect, useState, useCallback } from "react";
import { Popup, SpinLoading, DatePicker } from "antd-mobile";
import { Button, message, Input } from "antd";
import { CloseOutlined, CalendarOutlined } from "@ant-design/icons";
import style from "./Popups.module.scss";
import { getFriendRequestTaskStats } from "../api";
import LineChart2 from "@/components/LineChart2";
@@ -39,12 +39,52 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
const [xData, setXData] = useState<any[]>([]);
const [yData, setYData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [startTime, setStartTime] = useState<Date | null>(null);
const [endTime, setEndTime] = useState<Date | null>(null);
const [showStartTimePicker, setShowStartTimePicker] = useState(false);
const [showEndTimePicker, setShowEndTimePicker] = useState(false);
// 当弹窗打开且有ruleId时获取数据
// 格式化日期为 YYYY-MM-DD
const formatDate = useCallback((date: Date | null): string => {
if (!date) return "";
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}, []);
// 初始化默认时间近7天
useEffect(() => {
if (visible && ruleId) {
if (visible) {
// 如果时间未设置设置默认值为近7天
if (!startTime || !endTime) {
const today = new Date();
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(today.getDate() - 7);
setStartTime(sevenDaysAgo);
setEndTime(today);
}
} else {
// 弹窗关闭时重置时间,下次打开时重新初始化
setStartTime(null);
setEndTime(null);
}
}, [visible]);
// 当弹窗打开或有ruleId或时间筛选变化时获取数据
useEffect(() => {
if (!visible || !ruleId) return;
setLoading(true);
getFriendRequestTaskStats(ruleId.toString())
const params: { startTime?: string; endTime?: string } = {};
if (startTime) {
params.startTime = formatDate(startTime);
}
if (endTime) {
params.endTime = formatDate(endTime);
}
getFriendRequestTaskStats(ruleId.toString(), params)
.then(res => {
console.log(res);
setXData(res.dateArray);
@@ -57,13 +97,15 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
res.successRateArray,
]);
setStatistics(res.totalStats);
setLoading(false);
})
.catch(error => {
console.error("获取统计数据失败:", error);
message.error("获取统计数据失败");
})
.finally(() => {
setLoading(false);
});
}
}, [visible, ruleId]);
}, [visible, ruleId, startTime, endTime, formatDate]);
const title = ruleName ? `${ruleName} - 累计统计数据` : "累计统计数据";
return (
@@ -89,6 +131,55 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
/>
</div>
{/* 时间筛选 */}
<div className={style.dateFilter}>
<div className={style.dateFilterItem}>
<label className={style.dateFilterLabel}></label>
<Input
readOnly
placeholder="请选择开始时间"
value={startTime ? formatDate(startTime) : ""}
onClick={() => setShowStartTimePicker(true)}
prefix={<CalendarOutlined />}
className={style.dateFilterInput}
/>
<DatePicker
visible={showStartTimePicker}
title="开始时间"
value={startTime}
max={endTime || new Date()}
onClose={() => setShowStartTimePicker(false)}
onConfirm={val => {
setStartTime(val);
setShowStartTimePicker(false);
}}
/>
</div>
<div className={style.dateFilterItem}>
<label className={style.dateFilterLabel}></label>
<Input
readOnly
placeholder="请选择结束时间"
value={endTime ? formatDate(endTime) : ""}
onClick={() => setShowEndTimePicker(true)}
prefix={<CalendarOutlined />}
className={style.dateFilterInput}
/>
<DatePicker
visible={showEndTimePicker}
title="结束时间"
value={endTime}
min={startTime || undefined}
max={new Date()}
onClose={() => setShowEndTimePicker(false)}
onConfirm={val => {
setEndTime(val);
setShowEndTimePicker(false);
}}
/>
</div>
</div>
{/* 统计数据表格 */}
<div className={style.statisticsContent}>
{loading ? (

View File

@@ -663,6 +663,32 @@
color: #666;
}
// 日期筛选样式
.dateFilter {
display: flex;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
}
.dateFilterItem {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.dateFilterLabel {
font-size: 14px;
color: #666;
font-weight: 500;
}
.dateFilterInput {
width: 100%;
}
// 统计数据弹窗样式
.statisticsContent {
flex: 1;

View File

@@ -8,8 +8,9 @@ import {
Popup,
Card,
Tag,
InfiniteScroll,
} from "antd-mobile";
import { Input, Pagination } from "antd";
import { Input } from "antd";
import {
PlusOutlined,
CopyOutlined,
@@ -80,7 +81,7 @@ const ScenarioList: React.FC = () => {
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [total, setTotal] = useState(0);
const pageSize = 20;
const limit = 20;
// 获取计划列表数据
const fetchPlanList = async (page: number, isLoadMore: boolean = false) => {
@@ -96,7 +97,7 @@ const ScenarioList: React.FC = () => {
const response = await getPlanList({
sceneId: scenarioId,
page: page,
pageSize: pageSize,
limit: limit,
});
if (response && response.list) {
@@ -110,7 +111,7 @@ const ScenarioList: React.FC = () => {
// 更新分页信息
setTotal(response.total || 0);
setHasMore(response.list.length === pageSize);
setHasMore(response.list.length === limit);
setCurrentPage(page);
}
} catch (error) {
@@ -149,10 +150,11 @@ const ScenarioList: React.FC = () => {
fetchScenarioData();
}, [scenarioId]);
// 分页改变处理
const handlePageChange = async (page: number) => {
setCurrentPage(page);
await fetchPlanList(page, false);
// 加载更多
const handleLoadMore = async () => {
if (!hasMore || loadingMore || loadingTasks) return;
const nextPage = currentPage + 1;
await fetchPlanList(nextPage, true);
};
const handleCopyPlan = async (taskId: string) => {
@@ -405,18 +407,6 @@ const ScenarioList: React.FC = () => {
</>
}
loading={loading}
footer={
<div className="pagination-container">
<Pagination
total={total}
pageSize={pageSize}
current={currentPage}
onChange={handlePageChange}
showSizeChanger={false}
showQuickJumper={false}
/>
</div>
}
>
<div className={style["scenario-list-page"]}>
{/* 计划列表 */}
@@ -530,6 +520,33 @@ const ScenarioList: React.FC = () => {
</div>
</Card>
))}
{/* 上拉加载更多 */}
<InfiniteScroll
loadMore={handleLoadMore}
hasMore={hasMore}
threshold={100}
>
{loadingMore && (
<div style={{ padding: "20px", textAlign: "center" }}>
<SpinLoading color="primary" style={{ fontSize: 16 }} />
<span style={{ marginLeft: 8, color: "#999", fontSize: 12 }}>
...
</span>
</div>
)}
{!hasMore && filteredTasks.length > 0 && (
<div
style={{
padding: "20px",
textAlign: "center",
color: "#999",
fontSize: 12,
}}
>
</div>
)}
</InfiniteScroll>
</>
)}
</div>

View File

@@ -60,6 +60,7 @@ export const defFormData: FormData = {
enabled: true,
remarkFormat: "",
addFriendInterval: 1,
tips: "请注意消息,稍后加你微信",
posters: [],
device: [],
customTags: [],

View File

@@ -90,7 +90,7 @@ export default function NewPlan() {
setFormData(prev => ({
...prev,
name: detail.name ?? "",
scenario: Number(detail.scenario) || 1,
scenario: Number(detail.sceneId || detail.scenario) || 1,
scenarioTags: detail.scenarioTags ?? [],
customTags: detail.customTags ?? [],
customTagsOptions: detail.customTags ?? [],
@@ -102,7 +102,7 @@ export default function NewPlan() {
startTime: detail.startTime ?? "09:00",
endTime: detail.endTime ?? "18:00",
enabled: detail.enabled ?? true,
sceneId: Number(detail.scenario) || 1,
sceneId: Number(detail.sceneId || detail.scenario) || 1,
remarkFormat: detail.remarkFormat ?? "",
addFriendInterval: detail.addFriendInterval ?? 1,
tips: detail.tips ?? "",
@@ -122,10 +122,19 @@ export default function NewPlan() {
distributionAddReward: detail.addFriendRewardAmount,
}));
} else {
// 新建时,如果是海报场景,设置默认获客成功提示
const defaultTips = "请注意消息,稍后加你微信";
if (scenarioId) {
setFormData(prev => ({
...prev,
...{ scenario: Number(scenarioId) || 1 },
tips: Number(scenarioId) === 1 ? defaultTips : prev.tips || "",
}));
} else {
// 如果没有 scenarioId默认是海报场景scenario === 1设置默认提示
setFormData(prev => ({
...prev,
tips: prev.scenario === 1 ? defaultTips : prev.tips || "",
}));
}
}

View File

@@ -40,6 +40,7 @@ const generatePosterMaterials = (): Material[] => {
};
const BasicSettings: React.FC<BasicSettingsProps> = ({
isEdit,
formData,
onChange,
sceneList,
@@ -100,6 +101,15 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
setTips(formData.tips || "");
}, [formData.tips]);
// 当切换到海报场景且 tips 为空时,设置默认值
useEffect(() => {
if (formData.scenario === 1 && !formData.tips) {
const defaultTips = "请注意消息,稍后加你微信";
onChange({ ...formData, tips: defaultTips });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData.scenario]);
// 选中场景
const handleScenarioSelect = (sceneId: number) => {
@@ -249,16 +259,27 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
) : (
<div className={styles["basic-scene-grid"]}>
{sceneList
.filter(scene => scene.id !== 10)
.filter(scene => {
// 编辑模式下,如果当前选中的场景 id 是 10则显示它
if (isEdit && formData.scenario === 10 && scene.id === 10) {
return true;
}
// 其他情况过滤掉 id 为 10 的场景
return scene.id !== 10;
})
.map(scene => {
const selected = formData.scenario === scene.id;
// 编辑模式下,如果当前场景 id 是 10则禁用所有场景选择
const isDisabled = isEdit && formData.scenario === 10;
return (
<button
key={scene.id}
onClick={() => handleScenarioSelect(scene.id)}
onClick={() => !isDisabled && handleScenarioSelect(scene.id)}
disabled={isDisabled}
className={
styles["basic-scene-btn"] +
(selected ? " " + styles.selected : "")
(selected ? " " + styles.selected : "") +
(isDisabled ? " " + styles.disabled : "")
}
>
{scene.name.replace("获客", "")}

View File

@@ -29,6 +29,17 @@
color: #fff;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.08);
}
.basic-scene-btn.disabled {
opacity: 0.6;
cursor: not-allowed;
background: rgba(#1677ff, 0.1);
color: #1677ff;
}
.basic-scene-btn.disabled.selected {
background: #1677ff;
color: #fff;
opacity: 0.8;
}
.basic-label {
margin-bottom: 12px;
font-weight: 500;

View File

@@ -230,7 +230,7 @@ export default function AutoLikeRecord() {
</div>
<Badge
className={styles.timeBadge}
count={formatDate(record.momentTime || record.likeTime)}
count={formatDate(record.likeTime || record.momentTime)}
style={{
background: "#e8f0fe",
color: "#333",

View File

@@ -221,7 +221,7 @@ const AddChannelModal: React.FC<AddChannelModalProps> = ({
setQrCodeData(null);
setScanning(false);
const timer = setTimeout(() => {
handleGenerateQRCode();
handleGenerateQRCode();
}, 100);
return () => clearTimeout(timer);
}

View File

@@ -33,14 +33,12 @@ import type {
} from "./data";
import styles from "./index.module.scss";
// 格式化金额显示(后端返回的是分,需要转换为元
// 格式化金额显示(后端返回的是元,直接格式化即可
const formatCurrency = (amount: number): string => {
// 将分转换为元
const yuan = amount / 100;
if (yuan >= 10000) {
return "¥" + (yuan / 10000).toFixed(2) + "万";
if (amount >= 10000) {
return "¥" + (amount / 10000).toFixed(2) + "万";
}
return "¥" + yuan.toFixed(2);
return "¥" + amount.toFixed(2);
};
const ChannelDetailPage: React.FC = () => {

View File

@@ -0,0 +1,598 @@
.container {
padding: 16px;
padding-bottom: 100px;
background: #f3f4f6;
min-height: 100vh;
position: relative;
}
.syncOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.syncContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
background: #fff;
padding: 32px 40px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.syncText {
font-size: 14px;
color: #4b5563;
font-weight: 500;
}
.loadingContainer,
.emptyContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.emptyText {
font-size: 16px;
color: #64748b;
margin-bottom: 24px;
}
.backButton {
padding: 8px 16px;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}
.moreButton {
padding: 8px;
margin-right: -8px;
background: none;
border: none;
cursor: pointer;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: color 0.2s;
&:hover {
color: #111827;
}
}
// 群组信息卡片
.groupInfoCard {
background: #fff;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
align-items: center;
}
.groupIconLarge {
width: 80px;
height: 80px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 32px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin: 0 auto 16px;
object-fit: cover;
background-size: cover;
background-position: center;
font-weight: 600;
letter-spacing: 1px;
}
.groupTitle {
font-size: 20px;
font-weight: 700;
color: #111827;
text-align: center;
margin: 0 0 4px 0;
}
.createTimeInfo {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: #6b7280;
margin-bottom: 16px;
}
.createTimeIcon {
font-size: 16px;
}
.actionButtons {
display: flex;
gap: 12px;
width: 100%;
}
.actionButton {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border: none;
border-radius: 12px;
font-weight: 500;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
&:active {
background: rgba(59, 130, 246, 0.2);
}
}
.actionButtonIcon {
font-size: 18px;
}
// 基本信息
.basicInfoCard {
background: #fff;
border-radius: 16px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
}
.sectionTitle {
font-size: 16px;
font-weight: 600;
color: #111827;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.sectionTitleDot {
width: 4px;
height: 16px;
background: #3b82f6;
border-radius: 2px;
}
.basicInfoList {
display: flex;
flex-direction: column;
gap: 0;
}
.basicInfoItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.basicInfoLabel {
font-size: 14px;
color: #6b7280;
}
.basicInfoValue {
font-size: 14px;
font-weight: 500;
color: #111827;
display: flex;
align-items: center;
gap: 4px;
}
.planIcon {
font-size: 16px;
color: #3b82f6;
margin-right: 4px;
}
.adminAvatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: #fef3c7;
color: #92400e;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
margin-right: 8px;
position: relative;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.adminAvatarText {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.hidden {
display: none;
}
.basicInfoDivider {
height: 1px;
background: #f3f4f6;
}
.noAnnouncement {
color: #9ca3af;
display: flex;
align-items: center;
}
.chevronIcon {
font-size: 16px;
margin-left: 4px;
}
// 群成员
.membersCard {
background: #fff;
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
overflow: hidden;
}
.membersHeader {
padding: 20px;
border-bottom: 1px solid #f3f4f6;
display: flex;
justify-content: space-between;
align-items: center;
}
.memberCountBadge {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: #6b7280;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 9999px;
}
.searchButton {
padding: 6px;
color: #6b7280;
background: none;
border: none;
cursor: pointer;
border-radius: 8px;
transition: background 0.2s;
&:active {
background: #f3f4f6;
}
}
.searchBox {
padding: 12px 20px;
border-bottom: 1px solid #f3f4f6;
}
.searchInput {
width: 100%;
padding: 8px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
outline: none;
&:focus {
border-color: #3b82f6;
}
}
.membersList {
max-height: 320px;
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.memberItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f9fafb;
transition: background 0.2s;
&:last-child {
border-bottom: none;
}
&:active {
background: #f9fafb;
}
}
.memberInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.memberInfoText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.memberNameRow {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.memberAvatarWrapper {
position: relative;
}
.memberAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
background: #e5e7eb;
}
.memberAvatarPlaceholder {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e9d5ff;
color: #7c3aed;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
}
.adminBadge {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fbbf24;
border: 2px solid #fff;
display: flex;
align-items: center;
justify-content: center;
}
.adminBadgeIcon {
font-size: 10px;
color: #fff;
}
.memberInfoText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.memberNameRow {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.memberName {
font-size: 14px;
font-weight: 500;
color: #111827;
}
.memberWechatId {
font-size: 12px;
color: #6b7280;
}
.quitTag {
display: inline-flex;
align-items: center;
padding: 2px 6px;
background: #fee2e2;
color: #dc2626;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.joinStatusTag {
display: inline-flex;
align-items: center;
padding: 2px 6px;
background: #dbeafe;
color: #2563eb;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.memberExtraInfo {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #9ca3af;
flex-wrap: wrap;
}
.divider {
color: #d1d5db;
}
.memberJoinTime {
font-size: 12px;
color: #9ca3af;
}
.memberActions {
display: flex;
align-items: center;
}
.adminTag {
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
background: #fef3c7;
color: #92400e;
border: 1px solid rgba(146, 64, 14, 0.3);
}
.removeButton {
color: #9ca3af;
background: none;
border: none;
cursor: pointer;
font-size: 20px;
transition: color 0.2s;
&:active {
color: #ef4444;
}
}
.emptyMembers {
padding: 40px 20px;
text-align: center;
}
.emptyMembersText {
font-size: 14px;
color: #9ca3af;
}
// 操作按钮
.actionsSection {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.actionCard {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: pointer;
transition: background 0.2s;
&:active {
background: #f9fafb;
}
}
.actionCardIcon {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f3f4f6;
color: #4b5563;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all 0.2s;
}
.actionCard:hover .actionCardIcon {
background: #dbeafe;
color: #3b82f6;
}
.actionCardText {
font-size: 14px;
font-weight: 500;
color: #111827;
}
.actionCardDanger {
&:active {
background: #fef2f2;
}
}
.actionCardIconDanger {
background: #fee2e2;
color: #ef4444;
}
.actionCardTextDanger {
color: #dc2626;
}

View File

@@ -0,0 +1,675 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Toast, SpinLoading, Dialog, Input, TextArea } from "antd-mobile";
import {
EditOutlined,
QrcodeOutlined,
SearchOutlined,
SyncOutlined,
LogoutOutlined,
StarOutlined,
CloseCircleOutlined,
ScheduleOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getCreatedGroupDetail, syncGroupInfo, modifyGroupInfo, quitGroup } from "../form/api";
import style from "./group-detail.module.scss";
interface GroupMember {
id?: string;
friendId?: number;
nickname?: string;
wechatId?: string;
avatar?: string;
isOwner?: number; // 1表示是群主0表示不是
isGroupAdmin?: boolean;
joinStatus?: string; // "auto" | "manual" - 入群状态
isQuit?: number; // 0/1 - 是否已退群
joinTime?: string; // 入群时间
alias?: string; // 成员别名
remark?: string; // 成员备注
[key: string]: any;
}
interface GroupDetail {
id: string;
name: string;
createTime?: string;
planName?: string;
groupAdmin?: {
id: string;
nickname?: string;
wechatId?: string;
avatar?: string;
};
announcement?: string;
memberCount?: number;
members?: GroupMember[];
[key: string]: any;
}
const GroupDetailPage: React.FC = () => {
const { id, groupId } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [groupDetail, setGroupDetail] = useState<GroupDetail | null>(null);
const [searchKeyword, setSearchKeyword] = useState("");
const [showSearch, setShowSearch] = useState(false);
const [syncing, setSyncing] = useState(false);
const [editNameVisible, setEditNameVisible] = useState(false);
const [editNameValue, setEditNameValue] = useState("");
const [editAnnouncementVisible, setEditAnnouncementVisible] = useState(false);
const [editAnnouncementValue, setEditAnnouncementValue] = useState("");
useEffect(() => {
if (!id || !groupId) return;
fetchGroupDetail();
}, [id, groupId]);
const fetchGroupDetail = async () => {
if (!id || !groupId) return;
setLoading(true);
try {
const res = await getCreatedGroupDetail({
workbenchId: id,
groupId: groupId,
});
const data = res?.data || res || {};
// 处理成员数据确保有id字段
const members = (data.members || []).map((member: any) => ({
...member,
id: String(member.friendId || member.id || member.wechatId || ''),
}));
const detailData: GroupDetail = {
id: String(data.id || groupId),
name: data.groupName || data.name || "未命名群组",
createTime: data.createTime || "",
planName: data.workbenchName || data.planName || "",
groupAdmin: data.ownerWechatId || data.ownerNickname
? {
id: String(data.ownerWechatId || ""),
nickname: data.ownerNickname,
wechatId: data.ownerWechatId,
avatar: data.ownerAvatar,
}
: undefined,
announcement: data.announce || data.announcement, // 接口返回的字段是 announce
memberCount: data.memberCount || members.length || 0,
members: members,
avatar: data.avatar || data.groupAvatar,
groupAvatar: data.groupAvatar,
};
setGroupDetail(detailData);
} catch (e: any) {
Toast.show({ content: e?.message || "获取群组详情失败", position: "top" });
navigate(-1);
} finally {
setLoading(false);
}
};
const handleEditName = () => {
if (!groupDetail) return;
setEditNameValue(groupDetail.name);
setEditNameVisible(true);
};
const handleConfirmEditName = async () => {
if (!id || !groupId || !editNameValue.trim()) {
Toast.show({ content: "群名称不能为空", position: "top" });
return;
}
try {
await modifyGroupInfo({
workbenchId: id,
groupId: groupId,
chatroomName: editNameValue.trim(),
});
Toast.show({ content: "修改成功", position: "top" });
setEditNameVisible(false);
// 刷新群详情
fetchGroupDetail();
} catch (e: any) {
Toast.show({
content: e?.message || "修改群名称失败",
position: "top"
});
}
};
const handleEditAnnouncement = () => {
if (!groupDetail) return;
setEditAnnouncementValue(groupDetail.announcement || "");
setEditAnnouncementVisible(true);
};
const handleConfirmEditAnnouncement = async () => {
if (!id || !groupId) {
return;
}
try {
await modifyGroupInfo({
workbenchId: id,
groupId: groupId,
announce: editAnnouncementValue.trim() || undefined,
});
Toast.show({ content: "修改成功", position: "top" });
setEditAnnouncementVisible(false);
setEditAnnouncementValue("");
// 刷新群详情
fetchGroupDetail();
} catch (e: any) {
Toast.show({
content: e?.message || "修改群公告失败",
position: "top"
});
}
};
const handleShowQRCode = () => {
Toast.show({ content: "群二维码功能待实现", position: "top" });
};
const handleSyncGroup = async () => {
if (!id || !groupId) {
Toast.show({ content: "参数错误", position: "top" });
return;
}
setSyncing(true);
try {
const res = await syncGroupInfo({
workbenchId: id,
groupId: groupId,
});
const data = res?.data || res || {};
const successMessages: string[] = [];
if (data.groupInfoSynced) {
successMessages.push("群信息同步成功");
}
if (data.memberInfoSynced) {
successMessages.push("群成员信息同步成功");
}
if (successMessages.length > 0) {
Toast.show({
content: successMessages.join(""),
position: "top"
});
// 同步成功后刷新群详情
await fetchGroupDetail();
} else {
Toast.show({
content: res?.msg || "同步完成",
position: "top"
});
}
} catch (e: any) {
Toast.show({
content: e?.message || "同步群信息失败",
position: "top"
});
} finally {
setSyncing(false);
}
};
const handleDisbandGroup = () => {
Dialog.confirm({
content: "确定要退出该群组吗?",
confirmText: "确定",
cancelText: "取消",
onConfirm: async () => {
if (!id || !groupId) {
Toast.show({ content: "参数错误", position: "top" });
return;
}
try {
await quitGroup({
workbenchId: id,
groupId: groupId,
});
Toast.show({ content: "退出群组成功", position: "top" });
// 退出成功后返回上一页
navigate(-1);
} catch (e: any) {
Toast.show({
content: e?.message || "退出群组失败",
position: "top"
});
}
},
});
};
const handleRemoveMember = (memberId: string) => {
Dialog.confirm({
content: "确定要移除此成员吗?",
confirmText: "确定",
cancelText: "取消",
onConfirm: () => {
Toast.show({ content: "移除成员功能待实现", position: "top" });
},
});
};
// 过滤成员
const filteredMembers = groupDetail?.members?.filter((member) => {
if (!searchKeyword) return true;
const keyword = searchKeyword.toLowerCase();
return (
member.nickname?.toLowerCase().includes(keyword) ||
member.wechatId?.toLowerCase().includes(keyword)
);
}) || [];
// 群组图标颜色
const iconColors = {
from: "#3b82f6",
to: "#4f46e5",
};
if (loading) {
return (
<Layout
header={<NavCommon title="群详情" backFn={() => navigate(-1)} />}
>
<div className={style.loadingContainer}>
<SpinLoading style={{ "--size": "48px" }} />
</div>
</Layout>
);
}
if (!groupDetail) {
return (
<Layout
header={<NavCommon title="群详情" backFn={() => navigate(-1)} />}
>
<div className={style.emptyContainer}>
<div className={style.emptyText}></div>
<button
className={style.backButton}
onClick={() => navigate(-1)}
>
</button>
</div>
</Layout>
);
}
return (
<Layout
header={
<NavCommon
title="群详情"
backFn={() => navigate(-1)}
/>
}
>
<div className={style.container}>
{/* 同步遮罩层 */}
{syncing && (
<div className={style.syncOverlay}>
<div className={style.syncContent}>
<SpinLoading style={{ "--size": "32px" }} />
<div className={style.syncText}>...</div>
</div>
</div>
)}
{/* 群组信息卡片 */}
<section className={style.groupInfoCard}>
{groupDetail.avatar || groupDetail.groupAvatar ? (
<img
className={style.groupIconLarge}
src={groupDetail.avatar || groupDetail.groupAvatar}
alt={groupDetail.name || ""}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.nextElementSibling as HTMLElement;
if (fallback) {
fallback.style.display = 'flex';
}
}}
/>
) : null}
<div
className={style.groupIconLarge}
style={{
display: groupDetail.avatar || groupDetail.groupAvatar ? 'none' : 'flex',
background: `linear-gradient(to bottom right, ${iconColors.from}, ${iconColors.to})`,
}}
>
{(groupDetail.name || '').charAt(0) || '👥'}
</div>
<h2 className={style.groupTitle}>{groupDetail.name}</h2>
<div className={style.createTimeInfo}>
<ScheduleOutlined className={style.createTimeIcon} />
{groupDetail.createTime || "-"}
</div>
<div className={style.actionButtons}>
<button className={style.actionButton} onClick={handleEditName}>
<EditOutlined className={style.actionButtonIcon} />
</button>
{/* 群二维码功能暂时隐藏 */}
{/* <button className={style.actionButton} onClick={handleShowQRCode}>
<QrcodeOutlined className={style.actionButtonIcon} />
群二维码
</button> */}
</div>
</section>
{/* 基本信息 */}
<section className={style.basicInfoCard}>
<h3 className={style.sectionTitle}>
<span className={style.sectionTitleDot}></span>
</h3>
<div className={style.basicInfoList}>
<div className={style.basicInfoItem}>
<span className={style.basicInfoLabel}></span>
<div className={style.basicInfoValue}>
<span className={style.planIcon}>📋</span>
{groupDetail.planName || "-"}
</div>
</div>
<div className={style.basicInfoDivider}></div>
<div className={style.basicInfoItem}>
<span className={style.basicInfoLabel}></span>
<div className={style.basicInfoValue}>
{groupDetail.groupAdmin ? (
<>
<span className={style.adminAvatar}>
{groupDetail.groupAdmin.avatar ? (
<img
src={groupDetail.groupAdmin.avatar}
alt={groupDetail.groupAdmin.nickname || ""}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
(e.target as HTMLImageElement).nextElementSibling?.classList.remove(
style.hidden
);
}}
/>
) : null}
<span className={style.adminAvatarText}>
{groupDetail.groupAdmin.nickname?.charAt(0)?.toUpperCase() || "A"}
</span>
</span>
{groupDetail.groupAdmin.wechatId || "-"}
</>
) : (
"-"
)}
</div>
</div>
<div className={style.basicInfoDivider}></div>
<div className={style.basicInfoItem}>
<span className={style.basicInfoLabel}></span>
<div
className={style.basicInfoValue}
style={{ cursor: "pointer" }}
onClick={handleEditAnnouncement}
>
{groupDetail.announcement ? (
groupDetail.announcement
) : (
<span className={style.noAnnouncement}>
<span className={style.chevronIcon}></span>
</span>
)}
</div>
</div>
</div>
</section>
{/* 群成员 */}
<section className={style.membersCard}>
<div className={style.membersHeader}>
<h3 className={style.sectionTitle}>
<span className={style.sectionTitleDot}></span>
<span className={style.memberCountBadge}>
{groupDetail.memberCount || 0}
</span>
</h3>
<button
className={style.searchButton}
onClick={() => setShowSearch(!showSearch)}
title="搜索成员"
>
<SearchOutlined />
</button>
</div>
{showSearch && (
<div className={style.searchBox}>
<input
className={style.searchInput}
placeholder="搜索成员..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
</div>
)}
<div className={style.membersList}>
{filteredMembers.length > 0 ? (
filteredMembers.map((member) => {
const isGroupAdmin = member.isOwner === 1 || member.isGroupAdmin || member.id === groupDetail.groupAdmin?.id;
return (
<div key={member.friendId || member.id || member.wechatId} className={style.memberItem}>
<div className={style.memberInfo}>
<div className={style.memberAvatarWrapper}>
{member.avatar ? (
<img
className={style.memberAvatar}
src={member.avatar}
alt={member.nickname || ""}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className={style.memberAvatarPlaceholder}>
{member.nickname?.charAt(0)?.toUpperCase() || "?"}
</div>
)}
{isGroupAdmin && (
<div className={style.adminBadge}>
<StarOutlined className={style.adminBadgeIcon} />
</div>
)}
</div>
<div className={style.memberInfoText}>
<div className={style.memberNameRow}>
<span className={style.memberName}>
{member.nickname || "-"}
</span>
{member.isQuit === 1 && (
<span className={style.quitTag}>退</span>
)}
{member.joinStatus && (
<span className={style.joinStatusTag}>
{member.joinStatus === "auto" ? "自动" : "手动"}
</span>
)}
</div>
<div className={style.memberWechatId}>
{member.wechatId || "-"}
</div>
{(member.alias || member.remark) && (
<div className={style.memberExtraInfo}>
{member.alias && <span>{member.alias}</span>}
{member.alias && member.remark && <span className={style.divider}>|</span>}
{member.remark && <span>{member.remark}</span>}
</div>
)}
{member.joinTime && (
<div className={style.memberJoinTime}>
{member.joinTime}
</div>
)}
</div>
</div>
<div className={style.memberActions}>
{isGroupAdmin ? (
<span className={style.adminTag}></span>
) : (
// 移除成员功能暂时隐藏
// <button
// className={style.removeButton}
// onClick={() => handleRemoveMember(String(member.friendId || member.id || member.wechatId || ''))}
// >
// <CloseCircleOutlined />
// </button>
null
)}
</div>
</div>
);
})
) : (
<div className={style.emptyMembers}>
<div className={style.emptyMembersText}>
{searchKeyword ? "未找到匹配的成员" : "暂无成员"}
</div>
</div>
)}
</div>
</section>
{/* 操作按钮 */}
<section className={style.actionsSection}>
<button className={style.actionCard} onClick={handleSyncGroup}>
<div className={style.actionCardIcon}>
<SyncOutlined />
</div>
<span className={style.actionCardText}></span>
</button>
<button
className={`${style.actionCard} ${style.actionCardDanger}`}
onClick={handleDisbandGroup}
>
<div className={`${style.actionCardIcon} ${style.actionCardIconDanger}`}>
<LogoutOutlined />
</div>
<span className={`${style.actionCardText} ${style.actionCardTextDanger}`}>
/退
</span>
</button>
</section>
</div>
{/* 修改群名称弹窗 */}
<Dialog
visible={editNameVisible}
content={
<div style={{ padding: "16px 0" }}>
<div style={{ marginBottom: "8px", fontSize: "14px", color: "#666", fontWeight: 500 }}>
</div>
<Input
placeholder="请输入群名称"
value={editNameValue}
onChange={(val) => setEditNameValue(val)}
style={{
fontSize: "14px",
padding: "10px 12px",
border: "1px solid #e5e5e5",
borderRadius: "6px",
backgroundColor: "#fff",
}}
maxLength={30}
/>
</div>
}
closeOnAction
onClose={() => {
setEditNameVisible(false);
setEditNameValue("");
}}
actions={[
{
key: "cancel",
text: "取消",
onClick: () => {
setEditNameVisible(false);
setEditNameValue("");
},
},
{
key: "confirm",
text: "确定",
primary: true,
onClick: handleConfirmEditName,
},
]}
/>
{/* 修改群公告弹窗 */}
<Dialog
visible={editAnnouncementVisible}
content={
<div style={{ padding: "16px 0" }}>
<div style={{ marginBottom: "8px", fontSize: "14px", color: "#666", fontWeight: 500 }}>
</div>
<TextArea
placeholder="请输入群公告"
value={editAnnouncementValue}
onChange={(val) => setEditAnnouncementValue(val)}
style={{
fontSize: "14px",
padding: "10px 12px",
border: "1px solid #e5e5e5",
borderRadius: "6px",
backgroundColor: "#fff",
minHeight: "120px",
}}
rows={5}
maxLength={500}
showCount
/>
</div>
}
closeOnAction
onClose={() => {
setEditAnnouncementVisible(false);
setEditAnnouncementValue("");
}}
actions={[
{
key: "cancel",
text: "取消",
onClick: () => {
setEditAnnouncementVisible(false);
setEditAnnouncementValue("");
},
},
{
key: "confirm",
text: "确定",
primary: true,
onClick: handleConfirmEditAnnouncement,
},
]}
/>
</Layout>
);
};
export default GroupDetailPage;

View File

@@ -0,0 +1,275 @@
.container {
padding: 16px;
padding-bottom: 100px;
background: #f3f4f6;
min-height: 100vh;
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.moreButton {
padding: 8px;
margin-right: -8px;
background: none;
border: none;
cursor: pointer;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: color 0.2s;
&:hover {
color: #111827;
}
}
// 搜索和筛选区域
.filterSection {
position: sticky;
top: 0;
z-index: 40;
background: #f3f4f6;
padding-top: 8px;
padding-bottom: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.searchBox {
position: relative;
display: flex;
align-items: center;
}
.searchIcon {
position: absolute;
left: 12px;
font-size: 18px;
color: #9ca3af;
pointer-events: none;
transition: color 0.2s;
}
.searchInput {
width: 100%;
padding: 10px 12px 10px 40px;
border: none;
border-radius: 12px;
background: #fff;
color: #111827;
font-size: 14px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&::placeholder {
color: #9ca3af;
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
&:focus + .searchIcon,
&:focus ~ .searchIcon {
color: #3b82f6;
}
}
.filterButtons {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.filterButton {
display: flex;
align-items: center;
padding: 6px 12px;
border-radius: 8px;
background: #fff;
border: 1px solid #e5e7eb;
color: #4b5563;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: all 0.2s;
&:active {
background: #f9fafb;
}
}
.filterButtonActive {
background: #3b82f6;
color: #fff;
border-color: #3b82f6;
box-shadow: 0 1px 2px 0 rgba(59, 130, 246, 0.3);
&:active {
background: #2563eb;
}
}
.filterIcon {
font-size: 16px;
margin-left: 4px;
display: flex;
align-items: center;
}
// 群组列表
.groupsList {
display: flex;
flex-direction: column;
gap: 12px;
}
.groupCard {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
cursor: pointer;
&:active {
transform: scale(0.98);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
&:hover {
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.12);
border-color: #d1d5db;
}
}
.groupCardContent {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.groupIconWrapper {
position: relative;
width: 48px;
height: 48px;
flex-shrink: 0;
}
.groupIconImg,
.groupIconFallback {
width: 48px;
height: 48px;
border-radius: 10px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
}
.groupIconImg {
display: block;
object-fit: cover;
width: 100%;
height: 100%;
}
.groupIconFallback {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: 600;
background-size: cover;
background-position: center;
letter-spacing: 0.5px;
}
.groupInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.groupName {
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 8px;
}
.groupMeta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
}
.memberCount {
display: inline-flex;
align-items: center;
gap: 4px;
color: #4b5563;
background: #f3f4f6;
padding: 3px 8px;
border-radius: 6px;
font-weight: 500;
line-height: 1.4;
}
.createTime {
color: #6b7280;
font-weight: 400;
}
.emptyState {
padding: 60px 20px;
text-align: center;
}
.emptyText {
font-size: 14px;
color: #9ca3af;
}
.footer {
margin-top: 32px;
text-align: center;
padding: 20px 0;
}
.footerText {
font-size: 12px;
color: #9ca3af;
margin: 0;
}

View File

@@ -0,0 +1,290 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Toast, SpinLoading, InfiniteScroll } from "antd-mobile";
import { SearchOutlined, DownOutlined, UpOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getCreatedGroupsList } from "../form/api";
import style from "./groups-list.module.scss";
interface Group {
id: number | string;
name?: string;
groupName?: string;
memberCount: number;
memberCountText?: string;
createTime: string;
avatar?: string;
groupAvatar?: string;
memberAvatars?: Array<{
avatar?: string;
wechatId?: string;
nickname?: string;
}>;
ownerNickname?: string;
ownerWechatId?: string;
ownerAvatar?: string;
[key: string]: any;
}
interface GroupListPageProps {
planId?: string;
}
const GroupListPage: React.FC<GroupListPageProps> = ({ planId: propPlanId }) => {
const { id } = useParams();
const navigate = useNavigate();
const planId = propPlanId || id;
const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<Group[]>([]);
const [searchKeyword, setSearchKeyword] = useState("");
const [sortBy, setSortBy] = useState<"all" | "createTime" | "memberCount">("all");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
useEffect(() => {
if (!planId) return;
fetchGroups(true);
}, [planId]);
useEffect(() => {
if (!planId) return;
const timer = setTimeout(() => {
fetchGroups(true);
}, 500);
return () => clearTimeout(timer);
}, [searchKeyword]);
const fetchGroups = async (reset = false) => {
if (!planId) return;
const page = reset ? 1 : currentPage;
if (reset) {
setLoading(true);
}
try {
const res = await getCreatedGroupsList({
workbenchId: planId,
page,
limit: 20,
keyword: searchKeyword || undefined,
});
// 注意request 拦截器已经提取了 data 字段,所以 res 就是 data 对象
// 接口返回结构:{ list: [...], total: 2, page: "1", limit: "10" }
const groupsData = res?.list || (Array.isArray(res) ? res : []);
if (reset) {
setGroups(groupsData);
setCurrentPage(2); // 重置后下一页是2
} else {
setGroups(prev => [...prev, ...groupsData]);
setCurrentPage(prev => prev + 1);
}
setHasMore(groupsData.length >= 20);
} catch (e: any) {
// request拦截器在code !== 200时会reject并显示Toast
// 如果拦截器没有显示错误(比如网络错误),这里才显示
// 注意拦截器已经在错误时显示了Toast所以这里通常不需要再显示
} finally {
if (reset) {
setLoading(false);
}
}
};
const handleLoadMore = async () => {
if (hasMore && !loading) {
await fetchGroups(false);
}
};
// 排序(搜索和过滤由接口完成)
const sortedGroups = [...groups].sort((a, b) => {
if (sortBy === "createTime") {
const timeA = new Date(a.createTime || 0).getTime();
const timeB = new Date(b.createTime || 0).getTime();
return sortOrder === "asc" ? timeA - timeB : timeB - timeA;
} else if (sortBy === "memberCount") {
return sortOrder === "asc" ? a.memberCount - b.memberCount : b.memberCount - a.memberCount;
}
return 0;
});
const handleSortClick = (type: "createTime" | "memberCount") => {
if (sortBy === type) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(type);
setSortOrder("desc");
}
};
const getSortIcon = (type: "createTime" | "memberCount") => {
if (sortBy !== type) return <span style={{ fontSize: '12px' }}></span>;
return sortOrder === "asc" ? <UpOutlined /> : <DownOutlined />;
};
// 群组图标颜色配置
const iconColors = [
{ from: "#3b82f6", to: "#4f46e5" }, // blue to indigo
{ from: "#6366f1", to: "#8b5cf6" }, // indigo to purple
{ from: "#a855f7", to: "#ec4899" }, // purple to pink
{ from: "#f472b6", to: "#f43f5e" }, // pink to rose
{ from: "#fb923c", to: "#ef4444" }, // orange to red
{ from: "#14b8a6", to: "#10b981" }, // teal to emerald
{ from: "#22d3ee", to: "#3b82f6" }, // cyan to blue
];
const getGroupIcon = (index: number) => {
const colors = iconColors[index % iconColors.length];
return { from: colors.from, to: colors.to };
};
const getGroupIconEmoji = (group: Group, index: number) => {
// 可以根据群组名称或其他属性返回不同的图标
const icons = ["groups", "star", "fiber_new", "local_fire_department", "campaign", "forum", "school"];
return icons[index % icons.length];
};
if (loading) {
return (
<Layout
header={<NavCommon title="所有群组列表" backFn={() => navigate(-1)} />}
>
<div className={style.loadingContainer}>
<SpinLoading style={{ "--size": "48px" }} />
</div>
</Layout>
);
}
return (
<Layout
header={
<NavCommon
title="所有群组列表"
backFn={() => navigate(-1)}
/>
}
>
<div className={style.container}>
{/* 搜索和筛选区域 */}
<div className={style.filterSection}>
<div className={style.searchBox}>
<SearchOutlined className={style.searchIcon} />
<input
className={style.searchInput}
placeholder="搜索群名称、群ID、群主昵称..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
</div>
<div className={style.filterButtons}>
<button
className={`${style.filterButton} ${sortBy === "all" ? style.filterButtonActive : ""}`}
onClick={() => {
setSortBy("all");
setSortOrder("desc");
}}
>
</button>
<button
className={`${style.filterButton} ${sortBy === "createTime" ? style.filterButtonActive : ""}`}
onClick={() => handleSortClick("createTime")}
>
<span className={style.filterIcon}>{getSortIcon("createTime")}</span>
</button>
<button
className={`${style.filterButton} ${sortBy === "memberCount" ? style.filterButtonActive : ""}`}
onClick={() => handleSortClick("memberCount")}
>
<span className={style.filterIcon}>{getSortIcon("memberCount")}</span>
</button>
</div>
</div>
{/* 群组列表 */}
<div className={style.groupsList}>
{sortedGroups.length > 0 ? (
sortedGroups.map((group, index) => {
const iconColors = getGroupIcon(index);
return (
<div
key={String(group.id)}
className={style.groupCard}
onClick={() => navigate(`/workspace/group-create/${planId}/groups/${String(group.id)}`)}
>
<div className={style.groupCardContent}>
<div className={style.groupIconWrapper}>
{group.avatar || group.groupAvatar ? (
<img
className={style.groupIconImg}
src={group.avatar || group.groupAvatar}
alt={group.groupName || group.name || ""}
onError={(e) => {
// 加载失败时显示默认图标
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.parentElement?.querySelector(`.${style.groupIconFallback}`) as HTMLElement;
if (fallback) {
fallback.style.display = 'flex';
}
}}
/>
) : null}
<div
className={style.groupIconFallback}
style={{
display: group.avatar || group.groupAvatar ? 'none' : 'flex',
background: `linear-gradient(to bottom right, ${iconColors.from}, ${iconColors.to})`,
}}
>
{(group.groupName || group.name || '').charAt(0) || '👥'}
</div>
</div>
<div className={style.groupInfo}>
<h4 className={style.groupName}>{group.groupName || group.name || `群组 ${index + 1}`}</h4>
<div className={style.groupMeta}>
<span className={style.memberCount}>
👤 {group.memberCountText || `${group.memberCount || 0}`}
</span>
<span className={style.createTime}>{group.createTime || "-"}</span>
</div>
</div>
</div>
</div>
);
})
) : (
<div className={style.emptyState}>
<div className={style.emptyText}></div>
</div>
)}
</div>
{/* 无限滚动加载 */}
{sortedGroups.length > 0 && (
<InfiniteScroll
loadMore={handleLoadMore}
hasMore={hasMore}
threshold={100}
>
{hasMore ? (
<div className={style.footer}>
<SpinLoading style={{ "--size": "24px" }} />
</div>
) : (
<div className={style.footer}>
<p className={style.footerText}> {sortedGroups.length} </p>
</div>
)}
</InfiniteScroll>
)}
</div>
</Layout>
);
};
export default GroupListPage;

View File

@@ -0,0 +1,482 @@
.container {
padding: 16px;
padding-bottom: 100px;
background: #f3f4f6;
min-height: 100vh;
}
.loadingContainer,
.emptyContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.emptyText {
font-size: 16px;
color: #64748b;
margin-bottom: 24px;
}
// 基本信息卡片
.infoCard {
background: #fff;
border-radius: 16px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
}
.infoHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.infoTitle {
font-size: 20px;
font-weight: 700;
color: #111827;
margin: 0 0 4px 0;
}
.infoSubtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
border: 1px solid transparent;
}
.statusRunning {
background: #d1fae5;
color: #065f46;
border-color: #a7f3d0;
}
.statusStopped {
background: #f3f4f6;
color: #6b7280;
border-color: #e5e7eb;
}
.statusDot {
width: 6px;
height: 6px;
background: #10b981;
border-radius: 50%;
margin-right: 6px;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.infoMeta {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding-top: 16px;
border-top: 1px solid #f3f4f6;
}
.metaItem {
display: flex;
flex-direction: column;
gap: 4px;
}
.metaLabel {
font-size: 12px;
color: #9ca3af;
}
.metaValue {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #374151;
}
.metaIcon {
font-size: 16px;
color: #9ca3af;
margin-right: 4px;
}
// 统计信息
.statsSection {
margin-bottom: 24px;
}
.sectionTitle {
font-size: 16px;
font-weight: 600;
color: #111827;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.sectionTitleDot {
width: 4px;
height: 16px;
background: #3b82f6;
border-radius: 2px;
}
.statsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.statCard {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.statIcon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin-bottom: 8px;
}
.statIconBlue {
background: #dbeafe;
color: #3b82f6;
}
.statIconGreen {
background: #d1fae5;
color: #10b981;
}
.statIconPurple {
background: #f3e8ff;
color: #8b5cf6;
}
.statNumber {
font-size: 24px;
font-weight: 700;
color: #111827;
line-height: 1;
margin-bottom: 4px;
}
.statLabel {
font-size: 12px;
color: #6b7280;
}
// 配置信息
.configCard {
background: #fff;
border-radius: 16px;
padding: 20px;
margin-bottom: 24px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
}
.configList {
display: flex;
flex-direction: column;
gap: 0;
}
.configItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
}
.configLabel {
font-size: 14px;
color: #6b7280;
}
.configValue {
font-size: 14px;
font-weight: 500;
color: #111827;
display: flex;
align-items: center;
}
.deviceIcon {
font-size: 16px;
margin-right: 4px;
}
.configDivider {
height: 1px;
background: #f3f4f6;
}
.wechatTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.wechatTag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
background: #f3f4f6;
color: #374151;
border: 1px solid #e5e7eb;
}
.wechatTagAdmin {
background: #fef3c7;
color: #92400e;
border-color: #fde68a;
}
.adminBadge {
margin-left: 4px;
padding: 1px 4px;
border-radius: 4px;
font-size: 10px;
border: 1px solid rgba(146, 64, 14, 0.3);
background: rgba(254, 243, 199, 0.5);
}
// 群列表
.groupsSection {
margin-bottom: 24px;
}
.groupsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.viewAllButton {
font-size: 12px;
font-weight: 500;
color: #3b82f6;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.groupsList {
display: flex;
flex-direction: column;
gap: 12px;
}
.groupCard {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.08);
border: 1px solid #e5e7eb;
transition: all 0.2s ease;
cursor: pointer;
&:active {
transform: scale(0.98);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
&:hover {
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.12);
border-color: #d1d5db;
}
}
.groupCardHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.groupCardLeft {
display: flex;
gap: 12px;
align-items: flex-start;
flex: 1;
}
.groupIconWrapper {
position: relative;
width: 48px;
height: 48px;
flex-shrink: 0;
}
.groupIconImg,
.groupIconFallback {
width: 48px;
height: 48px;
border-radius: 10px;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08);
}
.groupIconImg {
display: block;
object-fit: cover;
width: 100%;
height: 100%;
}
.groupIconFallback {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: 600;
background-size: cover;
background-position: center;
letter-spacing: 0.5px;
}
.groupName {
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0 0 6px 0;
line-height: 1.4;
}
.groupMeta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #6b7280;
}
.groupMemberCount {
display: inline-flex;
align-items: center;
gap: 4px;
background: #f3f4f6;
padding: 3px 8px;
border-radius: 6px;
font-weight: 500;
color: #4b5563;
line-height: 1.4;
}
.groupDate {
color: #6b7280;
font-weight: 400;
}
.chevronIcon {
font-size: 24px;
color: #d1d5db;
flex-shrink: 0;
}
.groupMembers {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 12px;
margin-top: 8px;
margin-left: 20px;
border-top: 1px solid #f3f4f6;
}
.memberAvatars {
display: flex;
align-items: center;
margin-left: -10px;
}
.memberAvatar {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #fff;
margin-left: -10px;
object-fit: cover;
position: relative;
}
.memberMore {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid #fff;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 500;
color: #6b7280;
margin-left: -10px;
position: relative;
z-index: 0;
}
.viewDetailText {
font-size: 12px;
color: #9ca3af;
font-weight: 400;
}
.emptyGroups {
padding: 40px 20px;
text-align: center;
}
.emptyGroupsText {
font-size: 14px;
color: #9ca3af;
}

View File

@@ -0,0 +1,401 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Button, Toast, SpinLoading } from "antd-mobile";
import { EditOutlined, ScheduleOutlined, HistoryOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getGroupCreateDetail, getCreatedGroupsList } from "../form/api";
import style from "./index.module.scss";
interface GroupCreateDetail {
id: string;
name: string;
planType: number; // 0-全局计划, 1-独立计划
status: number; // 1-启用, 0-禁用
createTime?: string;
updateTime?: string;
createdGroupsCount?: number; // 已建群数
totalMembersCount?: number; // 总人数
groupSizeMax?: number; // 群组最大人数
config?: {
deviceGroupsOptions?: Array<{
id: number;
nickname?: string;
memo?: string;
wechatId?: string;
}>;
wechatGroupsOptions?: Array<{
id: number;
wechatId?: string;
nickname?: string;
}>;
groupAdminWechatId?: number;
groupNameTemplate?: string;
};
groups?: Array<{
id: string;
name: string;
memberCount: number;
createTime: string;
members?: Array<{
id: string;
avatar?: string;
nickname?: string;
}>;
}>;
[key: string]: any;
}
const GroupCreateDetail: React.FC = () => {
const { id } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<GroupCreateDetail | null>(null);
const [groups, setGroups] = useState<Array<{
id: number | string;
name?: string;
groupName?: string;
memberCount: number;
memberCountText?: string;
createTime: string;
avatar?: string;
groupAvatar?: string;
memberAvatars?: Array<{
avatar?: string;
wechatId?: string;
nickname?: string;
}>;
ownerNickname?: string;
ownerWechatId?: string;
ownerAvatar?: string;
}>>([]);
useEffect(() => {
if (!id) return;
fetchDetail();
fetchGroups();
}, [id]);
const fetchGroups = async () => {
if (!id) return;
try {
const res = await getCreatedGroupsList({
workbenchId: id,
page: 1,
limit: 10,
});
const groupsData = res?.list || res?.data?.list || res?.data || [];
setGroups(groupsData);
} catch (e: any) {
// 静默失败,不影响主详情展示
console.error("获取群组列表失败:", e);
}
};
const fetchDetail = async () => {
setLoading(true);
try {
const res = await getGroupCreateDetail(id!);
const config = res.config || {};
const stats = config.stats || {};
const detailData: GroupCreateDetail = {
id: String(res.id),
name: res.name || "",
planType: config.planType ?? res.planType ?? 1,
status: res.status ?? 1,
createTime: res.createTime || "",
updateTime: res.updateTime || res.createTime || "",
createdGroupsCount: stats.createdGroupsCount ?? res.createdGroupsCount ?? 0,
totalMembersCount: stats.totalMembersCount ?? res.totalMembersCount ?? 0,
groupSizeMax: config.groupSizeMax || 38,
config: {
deviceGroupsOptions: config.deviceGroupsOptions || [],
wechatGroupsOptions: config.wechatGroupsOptions || [],
groupAdminWechatId: config.groupAdminWechatId,
groupNameTemplate: config.groupNameTemplate || "",
},
groups: [], // 群列表通过单独的接口获取
};
setDetail(detailData);
} catch (e: any) {
Toast.show({ content: e?.message || "获取详情失败", position: "top" });
} finally {
setLoading(false);
}
};
const handleEdit = () => {
navigate(`/workspace/group-create/${id}/edit`);
};
if (loading) {
return (
<Layout
header={<NavCommon title="计划详情" backFn={() => navigate(-1)} />}
>
<div className={style.loadingContainer}>
<SpinLoading style={{ "--size": "48px" }} />
</div>
</Layout>
);
}
if (!detail) {
return (
<Layout
header={<NavCommon title="计划详情" backFn={() => navigate(-1)} />}
>
<div className={style.emptyContainer}>
<div className={style.emptyText}></div>
<Button color="primary" onClick={() => navigate("/workspace/group-create")}>
</Button>
</div>
</Layout>
);
}
const isRunning = detail.status === 1;
const planTypeText = detail.planType === 0 ? "全局计划" : "独立计划";
const executorDevice = detail.config?.deviceGroupsOptions?.[0];
const executorName = executorDevice?.nickname || executorDevice?.memo || executorDevice?.wechatId || "-";
const fixedWechatIds = detail.config?.wechatGroupsOptions || [];
const groupAdminId = detail.config?.groupAdminWechatId;
return (
<Layout
header={
<NavCommon
title="计划详情"
backFn={() => navigate(-1)}
right={
<Button
size="small"
color="primary"
onClick={handleEdit}
style={{ marginRight: "-8px" }}
>
<EditOutlined />
</Button>
}
/>
}
>
<div className={style.container}>
{/* 基本信息卡片 */}
<section className={style.infoCard}>
<div className={style.infoHeader}>
<div>
<h2 className={style.infoTitle}>{detail.name}</h2>
<p className={style.infoSubtitle}>{planTypeText}</p>
</div>
<span className={`${style.statusBadge} ${isRunning ? style.statusRunning : style.statusStopped}`}>
{isRunning && <span className={style.statusDot}></span>}
{isRunning ? "运行中" : "已停止"}
</span>
</div>
<div className={style.infoMeta}>
<div className={style.metaItem}>
<span className={style.metaLabel}></span>
<div className={style.metaValue}>
<ScheduleOutlined className={style.metaIcon} />
{detail.createTime || "-"}
</div>
</div>
<div className={style.metaItem}>
<span className={style.metaLabel}></span>
<div className={style.metaValue}>
<HistoryOutlined className={style.metaIcon} />
{detail.updateTime || "-"}
</div>
</div>
</div>
</section>
{/* 统计信息 */}
<section className={style.statsSection}>
<h3 className={style.sectionTitle}>
<span className={style.sectionTitleDot}></span>
</h3>
<div className={style.statsGrid}>
<div className={style.statCard}>
<div className={`${style.statIcon} ${style.statIconBlue}`}>
👥
</div>
<span className={style.statNumber}>{detail.createdGroupsCount || 0}</span>
<span className={style.statLabel}></span>
</div>
<div className={style.statCard}>
<div className={`${style.statIcon} ${style.statIconGreen}`}>
👥
</div>
<span className={style.statNumber}>{detail.totalMembersCount || 0}</span>
<span className={style.statLabel}></span>
</div>
<div className={style.statCard}>
<div className={`${style.statIcon} ${style.statIconPurple}`}>
📊
</div>
<span className={style.statNumber}>{detail.groupSizeMax || 38}</span>
<span className={style.statLabel}>/</span>
</div>
</div>
</section>
{/* 配置信息 */}
<section className={style.configCard}>
<h3 className={style.sectionTitle}>
<span className={style.sectionTitleDot}></span>
</h3>
<div className={style.configList}>
<div className={style.configItem}>
<span className={style.configLabel}></span>
<span className={style.configValue}></span>
</div>
<div className={style.configDivider}></div>
<div className={style.configItem}>
<span className={style.configLabel}></span>
<div className={style.configValue}>
<span className={style.deviceIcon}>📱</span>
{executorName}
</div>
</div>
<div className={style.configDivider}></div>
<div className={style.configItem}>
<span className={style.configLabel}></span>
<div className={style.wechatTags}>
{fixedWechatIds.length > 0 ? (
fixedWechatIds.map((wechat: any) => {
const isGroupAdmin = wechat.id === groupAdminId;
const displayText = wechat.nickname && wechat.wechatId
? `${wechat.nickname}${wechat.wechatId}`
: wechat.wechatId || wechat.nickname || "-";
return (
<span
key={wechat.id}
className={`${style.wechatTag} ${isGroupAdmin ? style.wechatTagAdmin : ""}`}
>
{displayText}
{isGroupAdmin && (
<span className={style.adminBadge}></span>
)}
</span>
);
})
) : (
<span className={style.configValue}>-</span>
)}
</div>
</div>
</div>
</section>
{/* 群列表 */}
<section className={style.groupsSection}>
<div className={style.groupsHeader}>
<h3 className={style.sectionTitle}>
<span className={style.sectionTitleDot}></span>
</h3>
<button
className={style.viewAllButton}
onClick={() => navigate(`/workspace/group-create/${id}/groups`)}
>
</button>
</div>
<div className={style.groupsList}>
{groups.length > 0 ? (
groups.map((group, index) => (
<div
key={String(group.id)}
className={style.groupCard}
onClick={() => navigate(`/workspace/group-create/${id}/groups/${String(group.id)}`)}
>
<div className={style.groupCardHeader}>
<div className={style.groupCardLeft}>
<div className={style.groupIconWrapper}>
{group.avatar || group.groupAvatar ? (
<img
className={style.groupIconImg}
src={group.avatar || group.groupAvatar}
alt={group.groupName || group.name || ""}
onError={(e) => {
// 加载失败时显示默认图标
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = target.parentElement?.querySelector(`.${style.groupIconFallback}`) as HTMLElement;
if (fallback) {
fallback.style.display = 'flex';
}
}}
/>
) : null}
<div
className={style.groupIconFallback}
style={{
display: group.avatar || group.groupAvatar ? 'none' : 'flex',
background: `linear-gradient(to bottom right, ${
['#60a5fa', '#818cf8', '#a78bfa', '#f472b6'][index % 4]
}, ${
['#4f46e5', '#6366f1', '#8b5cf6', '#ec4899'][index % 4]
})`,
}}
>
{(group.groupName || group.name || '').charAt(0) || '👥'}
</div>
</div>
<div>
<h4 className={style.groupName}>{group.groupName || group.name || detail.config?.groupNameTemplate || `群组 ${index + 1}`}</h4>
<div className={style.groupMeta}>
<span className={style.groupMemberCount}>
👤 {group.memberCountText || `${group.memberCount || 0}`}
</span>
<span className={style.groupDate}>{group.createTime || "-"}</span>
</div>
</div>
</div>
<span className={style.chevronIcon}></span>
</div>
{group.memberAvatars && group.memberAvatars.length > 0 && (
<div className={style.groupMembers}>
<div className={style.memberAvatars}>
{group.memberAvatars.slice(0, 6).map((member, memberIndex) => (
<img
key={member.wechatId || memberIndex}
className={style.memberAvatar}
src={member.avatar || "https://via.placeholder.com/24"}
alt={member.nickname || ""}
style={{ zIndex: 6 - memberIndex }}
onError={(e) => {
(e.target as HTMLImageElement).src = "https://via.placeholder.com/24";
}}
/>
))}
{group.memberAvatars.length > 6 && (
<span className={style.memberMore}>
+{group.memberAvatars.length - 6}
</span>
)}
</div>
<span className={style.viewDetailText}></span>
</div>
)}
</div>
))
) : (
<div className={style.emptyGroups}>
<div className={style.emptyGroupsText}></div>
</div>
)}
</div>
</section>
</div>
</Layout>
);
};
export default GroupCreateDetail;

View File

@@ -0,0 +1,74 @@
import request from "@/api/request";
// 创建自动建群任务
export const createGroupCreate = (params: any) =>
request("/v1/workbench/create", params, "POST");
// 更新自动建群任务
export const updateGroupCreate = (params: any) =>
request("/v1/workbench/update", params, "POST");
// 获取自动建群任务详情
export const getGroupCreateDetail = (id: string) =>
request("/v1/workbench/detail", { id }, "GET");
// 获取自动建群任务列表
export const getGroupCreateList = (params: any) =>
request("/v1/workbench/list", params, "GET");
// 删除自动建群任务
export const deleteGroupCreate = (id: string) =>
request("/v1/workbench/delete", { id }, "DELETE");
// 切换任务状态
export const toggleGroupCreateStatus = (data: { id: string | number; status: number }) =>
request("/v1/workbench/update-status", { ...data }, "POST");
// 获取群列表
export const getCreatedGroupsList = (params: {
workbenchId: string;
page?: number;
limit?: number;
keyword?: string;
}) => {
// 如果没有指定 limit默认使用 20
const finalParams = {
...params,
limit: params.limit ?? 20,
};
return request("/v1/workbench/created-groups-list", finalParams, "GET");
};
// 获取群详情
export const getCreatedGroupDetail = (params: {
workbenchId: string;
groupId: string;
}) => {
return request("/v1/workbench/created-group-detail", params, "GET");
};
// 同步群信息
export const syncGroupInfo = (params: {
workbenchId: string;
groupId: string;
}) => {
return request("/v1/workbench/sync-group-info", params, "POST");
};
// 修改群名称/群公告
export const modifyGroupInfo = (params: {
workbenchId: string;
groupId: string;
chatroomName?: string;
announce?: string;
}) => {
return request("/v1/workbench/modify-group-info", params, "POST");
};
// 退出群组
export const quitGroup = (params: {
workbenchId: string;
groupId: string;
}) => {
return request("/v1/workbench/quit-group", params, "POST");
};

View File

@@ -0,0 +1,700 @@
.container {
padding: 16px;
background: #f8fafc;
min-height: 100vh;
padding-bottom: 100px;
box-sizing: border-box;
width: 100%;
overflow-x: hidden; // 防止水平滚动
@media (max-width: 375px) {
padding: 12px; // 小屏幕时减小padding
}
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 16px;
margin-bottom: 16px;
box-sizing: border-box; // 确保padding不会导致超出
width: 100%;
overflow: hidden; // 防止内容溢出
@media (max-width: 375px) {
padding: 12px; // 小屏幕时减小padding
}
}
.label {
display: block;
font-size: 14px;
font-weight: 600;
color: #1e293b;
margin-bottom: 8px;
}
.labelRequired {
color: #ef4444;
margin-left: 2px;
}
.infoIcon {
color: #94a3b8;
font-size: 14px;
margin-left: 4px;
cursor: help;
}
.radioGroup {
display: flex;
gap: 24px;
}
.input {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 14px;
outline: none;
transition: all 0.2s;
box-sizing: border-box;
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #cbd5e1;
}
}
// 执行智能体选择区域
.executorSelector {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #f8fafc;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
}
}
.executorContent {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
overflow: hidden;
}
.executorAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.executorInfo {
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
flex: 1;
}
.executorName {
font-size: 14px;
font-weight: 500;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.executorId {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.executorExpand {
color: #64748b;
font-size: 20px;
flex-shrink: 0;
}
.statusDot {
position: absolute;
bottom: -2px;
right: -2px;
width: 12px;
height: 12px;
background: #10b981;
border: 2px solid #fff;
border-radius: 50%;
}
// 固定微信号
.wechatSelect {
position: relative;
margin-bottom: 12px;
}
.wechatSelectInput {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #f8fafc;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 12px;
&:hover {
border-color: #3b82f6;
}
&:active {
background: #f1f5f9;
}
&.disabled {
background: #f1f5f9;
color: #94a3b8;
cursor: not-allowed;
opacity: 0.6;
&:hover {
border-color: #e2e8f0;
}
}
}
.selectInputWrapper {
display: flex;
align-items: center;
justify-content: space-between;
}
.selectInputPlaceholder {
font-size: 14px;
color: #1e293b;
flex: 1;
.wechatSelectInput.disabled & {
color: #94a3b8;
}
}
.selectInputArrow {
color: #94a3b8;
font-size: 14px;
flex-shrink: 0;
margin-left: 8px;
}
.selectedList {
margin-top: 12px;
}
.selectedItem {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
margin-bottom: 8px;
}
.selectedItemContent {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.selectedItemAvatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #e2e8f0;
flex-shrink: 0;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.selectedItemInfo {
display: flex;
flex-direction: column;
flex: 1;
}
.selectedItemName {
font-size: 14px;
font-weight: 500;
color: #1e293b;
}
.selectedItemId {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.deleteButton {
color: #94a3b8;
cursor: pointer;
padding: 4px;
transition: color 0.2s;
&:hover {
color: #ef4444;
}
}
// 手动添加
.manualAdd {
border-top: 1px solid #f1f5f9;
padding-top: 12px;
margin-top: 12px;
}
.manualAddLabel {
font-size: 12px;
color: #64748b;
margin-bottom: 8px;
}
.manualAddInput {
display: flex;
gap: 8px;
height: 50px;
align-items: center;
}
.manualAddInputWrapper {
position: relative;
flex: 1;
height: 100%;
display: flex;
align-items: center;
}
.manualAddIcon {
position: absolute;
left: 12px;
color: #94a3b8;
font-size: 18px;
pointer-events: none;
z-index: 1;
}
.manualAddInputField {
width: 100%;
height: 100%;
padding-left: 40px;
padding-right: 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 14px;
outline: none;
transition: all 0.2s;
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: #cbd5e1;
}
}
.manualAddButton {
width: 60px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #3b82f6;
border: 1px solid #3b82f6;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
padding: 0;
line-height: 1.2;
&:hover {
background: #2563eb;
}
&:active {
transform: scale(0.98);
}
span {
line-height: 1;
}
.buttonText2 {
margin-top: 2px;
}
}
// 已添加的微信号(带编号)
.addedList {
margin-top: 12px;
}
.addedItem {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.addedItemContent {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.addedItemNumber {
width: 36px;
height: 36px;
border-radius: 50%;
background: #bfdbfe;
display: flex;
align-items: center;
justify-content: center;
color: #1e40af;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.addedItemInfo {
display: flex;
flex-direction: column;
flex: 1;
}
.addedItemName {
font-size: 14px;
font-weight: 500;
color: #1e293b;
}
.addedItemId {
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
.addedItemStatus {
font-size: 12px;
color: #3b82f6;
margin-top: 2px;
}
.addedCount {
font-size: 12px;
color: #64748b;
margin-top: 8px;
text-align: right;
}
// 群管理员
.groupAdminHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.groupAdminLabelWrapper {
display: flex;
align-items: center;
gap: 4px;
}
.switchWrapper {
position: relative;
display: inline-block;
}
.groupAdminHint {
font-size: 12px;
color: #64748b;
margin-top: 8px;
}
// 分组方式
.groupMethod {
display: flex;
flex-direction: column;
gap: 12px;
// 覆盖antd Radio的默认样式
:global(.ant-radio-group) {
display: flex;
flex-direction: column;
gap: 12px;
}
}
.groupMethodItem {
display: flex;
align-items: flex-start;
cursor: pointer;
}
.groupMethodRadio {
margin-top: 2px;
}
.groupMethodContent {
margin-left: 12px;
flex: 1;
}
.groupMethodTitle {
font-size: 14px;
font-weight: 500;
color: #1e293b;
margin-bottom: 4px;
}
.groupMethodDesc {
font-size: 12px;
color: #64748b;
line-height: 1.5;
}
// 群配置信息卡片
.groupConfigCard {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 12px;
padding: 16px;
margin-top: 16px;
}
.groupConfigHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.groupConfigIcon {
color: #3b82f6;
font-size: 18px;
}
.groupConfigTitle {
font-size: 14px;
font-weight: 600;
color: #3b82f6;
}
.groupConfigRow {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
align-items: flex-start;
}
}
.groupConfigLabel {
font-size: 14px;
color: #1e293b;
}
.groupConfigValue {
font-size: 14px;
font-weight: bold;
color: #3b82f6;
text-align: right;
}
// 错误提示
.errorTip {
background: #fff;
border: 1px solid #fca5a5;
border-radius: 12px;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 16px;
}
.errorIcon {
color: #ef4444;
font-size: 16px;
}
.errorText {
font-size: 14px;
color: #ef4444;
}
// 群人数配置
.groupSizeConfig {
display: flex;
flex-direction: column;
gap: 16px;
}
.groupSizeRow {
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap; // 小屏幕时允许换行
@media (max-width: 375px) {
flex-direction: column; // 小屏幕时垂直排列
gap: 16px;
}
}
.groupSizeItem {
flex: 1;
min-width: 0; // 防止flex item超出容器
@media (max-width: 375px) {
flex: none; // 小屏幕时取消flex占满宽度
width: 100%;
}
}
.groupSizeLabel {
font-size: 14px;
font-weight: normal;
color: #1e293b;
margin-bottom: 8px;
display: block;
word-wrap: break-word; // 允许长文本换行
overflow-wrap: break-word;
@media (max-width: 375px) {
font-size: 13px; // 小屏幕时稍微减小字体
}
}
// 执行时间
.timeRangeContainer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap; // 小屏幕时允许换行
@media (max-width: 375px) {
flex-direction: column; // 小屏幕时垂直排列
align-items: stretch;
gap: 12px;
}
}
.timeInputWrapper {
flex: 1;
position: relative;
min-width: 0; // 防止flex item超出容器
@media (max-width: 375px) {
flex: none; // 小屏幕时取消flex占满宽度
width: 100%;
}
}
.timeSeparator {
color: #64748b;
font-size: 14px;
flex-shrink: 0;
margin: 0 4px;
@media (max-width: 375px) {
display: none; // 小屏幕时隐藏分隔符
}
}
.timeInput {
width: 100%;
padding: 10px 12px 10px 36px; // 左边留出图标空间
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #f8fafc;
font-size: 14px;
outline: none;
transition: all 0.2s;
box-sizing: border-box; // 确保padding不会导致超出
&:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
}
.timeIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
font-size: 16px;
pointer-events: none;
z-index: 1;
}
.statusSwitchContainer {
display: flex;
align-items: center;
justify-content: space-between;
}

View File

@@ -0,0 +1,636 @@
import React, { useImperativeHandle, forwardRef, useState, useEffect } from "react";
import { Radio, Switch } from "antd";
import { Input } from "antd";
import { Toast, Avatar, Popup } from "antd-mobile";
import { ClockCircleOutlined, InfoCircleOutlined, DeleteOutlined, UserAddOutlined, ExclamationCircleOutlined } from "@ant-design/icons";
import FriendSelection from "@/components/FriendSelection";
import DeviceSelection from "@/components/DeviceSelection";
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { GroupCreateFormData } from "../types";
import style from "./BasicSettings.module.scss";
interface BasicSettingsProps {
formData: GroupCreateFormData;
onChange: (data: Partial<GroupCreateFormData>) => void;
}
export interface BasicSettingsRef {
validate: () => Promise<boolean>;
getValues: () => any;
}
const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
({ formData, onChange }, ref) => {
const [executorSelectionVisible, setExecutorSelectionVisible] = useState(false);
const [groupAdminSelectionVisible, setGroupAdminSelectionVisible] = useState(false);
const [fixedWechatIdsSelectionVisible, setFixedWechatIdsSelectionVisible] = useState(false);
const [manualWechatIdInput, setManualWechatIdInput] = useState("");
// 处理执行智能体选择(单选设备)
const handleExecutorSelect = (devices: DeviceSelectionItem[]) => {
if (devices.length > 0) {
const selectedDevice = devices[0];
// 自动设置群名称为执行智能体的名称(优先使用 nickname其次 memo最后 wechatId加上"的群"后缀
const executorName = selectedDevice.nickname || selectedDevice.memo || selectedDevice.wechatId || "";
onChange({
executor: selectedDevice,
executorId: selectedDevice.id,
groupNameTemplate: executorName ? `${executorName}的群` : "", // 设置为"XXX的群"格式
});
} else {
onChange({
executor: undefined,
executorId: undefined,
groupNameTemplate: "", // 清空群名称
});
}
setExecutorSelectionVisible(false);
};
// 处理固定微信号选择必须3个
const handleFixedWechatIdsSelect = (friends: FriendSelectionItem[]) => {
// 检查总数是否超过3个包括已添加的手动微信号
const currentManualCount = (formData.wechatGroupsOptions || []).filter(f => f.isManual).length;
const newSelectedCount = friends.length;
if (currentManualCount + newSelectedCount > 3) {
Toast.show({ content: "固定微信号最多只能选择3个", position: "top" });
return;
}
// 标记为选择的(非手动添加),确保所有从选择弹窗来的都标记为非手动
const selectedFriends = friends.map(f => ({ ...f, isManual: false }));
// 合并已添加的手动微信号和新的选择
const manualFriends = (formData.wechatGroupsOptions || []).filter(f => f.isManual === true);
onChange({
wechatGroups: [...manualFriends, ...selectedFriends].map(f => f.id),
wechatGroupsOptions: [...manualFriends, ...selectedFriends],
});
setFixedWechatIdsSelectionVisible(false);
};
// 打开固定微信号选择弹窗前检查是否已选择执行智能体
const handleOpenFixedWechatIdsSelection = () => {
if (!formData.executorId) {
Toast.show({ content: "请先选择执行智能体", position: "top" });
return;
}
setFixedWechatIdsSelectionVisible(true);
};
// 打开群管理员选择弹窗
const handleOpenGroupAdminSelection = () => {
if (selectedWechatIds.length === 0) {
Toast.show({
content: manualAddedWechatIds.length > 0
? "群管理员只能从已选择的微信号中选择,不能选择手动添加的微信号"
: "请先选择固定微信号",
position: "top"
});
return;
}
// 如果当前选择的群管理员是手动添加的,清空选择
if (formData.groupAdminWechatIdOption && formData.groupAdminWechatIdOption.isManual) {
onChange({
groupAdminWechatIdOption: undefined,
groupAdminWechatId: undefined,
});
}
setGroupAdminSelectionVisible(true);
};
// 处理群管理员选择(单选)
const handleGroupAdminSelect = (friends: FriendSelectionItem[]) => {
if (friends.length > 0) {
onChange({
groupAdminWechatIdOption: friends[0],
groupAdminWechatId: friends[0].id,
});
} else {
onChange({
groupAdminWechatIdOption: undefined,
groupAdminWechatId: undefined,
});
}
setGroupAdminSelectionVisible(false);
};
// 手动添加微信号
const handleAddManualWechatId = () => {
if (!manualWechatIdInput.trim()) {
Toast.show({ content: "请输入微信号", position: "top" });
return;
}
const existingIds = formData.wechatGroupsOptions.map(f => f.wechatId.toLowerCase());
if (existingIds.includes(manualWechatIdInput.trim().toLowerCase())) {
Toast.show({ content: "该微信号已添加", position: "top" });
return;
}
if (formData.wechatGroupsOptions.length >= 3) {
Toast.show({ content: "固定微信号最多只能添加3个", position: "top" });
return;
}
// 创建临时好友项,标记为手动添加
const newFriend: FriendSelectionItem = {
id: Date.now(), // 临时ID
wechatId: manualWechatIdInput.trim(),
nickname: manualWechatIdInput.trim(),
avatar: "",
isManual: true, // 标记为手动添加
};
onChange({
wechatGroups: [...formData.wechatGroups, newFriend.id],
wechatGroupsOptions: [...formData.wechatGroupsOptions, newFriend],
});
setManualWechatIdInput("");
};
// 移除固定微信号
const handleRemoveFixedWechatId = (id: number) => {
const removedFriend = formData.wechatGroupsOptions.find(f => f.id === id);
const newOptions = formData.wechatGroupsOptions.filter(f => f.id !== id);
const updateData: Partial<GroupCreateFormData> = {
wechatGroups: formData.wechatGroups.filter(fid => fid !== id),
wechatGroupsOptions: newOptions,
};
// 如果移除的是群管理员,也要清除群管理员设置
if (formData.groupAdminWechatId === id) {
updateData.groupAdminWechatId = undefined;
updateData.groupAdminWechatIdOption = undefined;
}
onChange(updateData);
};
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
// 验证必填字段
if (!formData.name?.trim()) {
Toast.show({ content: "请输入计划名称", position: "top" });
return false;
}
if (!formData.executorId) {
Toast.show({ content: "请选择执行智能体", position: "top" });
return false;
}
// 固定微信号不是必填的,移除验证
if (!formData.groupNameTemplate?.trim()) {
Toast.show({ content: "请输入群名称模板", position: "top" });
return false;
}
// 群名称模板长度验证2-100个字符
const groupNameTemplateLength = formData.groupNameTemplate.trim().length;
if (groupNameTemplateLength < 2 || groupNameTemplateLength > 100) {
Toast.show({ content: "群名称模板长度应在2-100个字符之间", position: "top" });
return false;
}
return true;
},
getValues: () => {
return formData;
},
}));
// 区分已选择的微信号(从下拉选择)和已添加的微信号(手动输入)
// 如果 isManual 未定义,默认为 false即选择的
const selectedWechatIds = (formData.wechatGroupsOptions || []).filter(f => !f.isManual);
const manualAddedWechatIds = (formData.wechatGroupsOptions || []).filter(f => f.isManual === true);
return (
<div className={style.container}>
{/* 计划类型和计划名称 */}
<div className={style.card}>
<div>
<label className={style.label}></label>
<Radio.Group
value={formData.planType}
onChange={e => onChange({ planType: e.target.value })}
className={style.radioGroup}
>
<Radio value={0}></Radio>
<Radio value={1}></Radio>
</Radio.Group>
</div>
<div style={{ marginTop: "16px" }}>
<label className={style.label}>
<span className={style.labelRequired}>*</span>
</label>
<input
type="text"
className={style.input}
value={formData.name}
onChange={e => onChange({ name: e.target.value })}
placeholder="请输入计划名称"
/>
</div>
</div>
{/* 执行智能体 */}
<div className={style.card}>
<label className={style.label}>
<span className={style.labelRequired}>*</span>
</label>
<div
className={style.executorSelector}
onClick={() => setExecutorSelectionVisible(true)}
>
{formData.executor ? (
<div className={style.executorContent}>
<div className={style.executorAvatar}>
{formData.executor.avatar ? (
<img src={formData.executor.avatar} alt={formData.executor.memo || formData.executor.wechatId} />
) : (
<span style={{ fontSize: "20px" }}>🤖</span>
)}
<div className={style.statusDot} style={{ background: formData.executor.status === "online" ? "#10b981" : "#94a3b8" }}></div>
</div>
<div className={style.executorInfo}>
<div className={style.executorName}>
{formData.executor.nickname || formData.executor.memo || formData.executor.wechatId}
</div>
<div className={style.executorId}>ID: {formData.executor.wechatId}</div>
</div>
</div>
) : (
<div className={style.executorContent}>
<div className={style.executorAvatar}>
<span style={{ fontSize: "20px", color: "#94a3b8" }}>🤖</span>
</div>
<div className={style.executorInfo}>
<div className={style.executorName} style={{ color: "#cbd5e1" }}>
</div>
</div>
</div>
)}
<span className={style.executorExpand}></span>
</div>
</div>
{/* 固定微信号 */}
<div className={style.card}>
<div style={{ marginBottom: "12px" }}>
<label className={style.label}>
<span className={style.labelRequired}>*</span>
<InfoCircleOutlined className={style.infoIcon} />
</label>
</div>
{/* 点击选择框 */}
<div
className={`${style.wechatSelectInput} ${!formData.executorId ? style.disabled : ''}`}
onClick={handleOpenFixedWechatIdsSelection}
>
<div className={style.selectInputWrapper}>
<span className={style.selectInputPlaceholder}>
{(selectedWechatIds.length + manualAddedWechatIds.length) > 0
? `已选择 ${selectedWechatIds.length + manualAddedWechatIds.length} 个微信号`
: '请选择微信号'}
</span>
<span className={style.selectInputArrow}></span>
</div>
</div>
{/* 已选择的微信号列表 */}
{selectedWechatIds.length > 0 && (
<div className={style.selectedList}>
<p className={style.manualAddLabel}>:</p>
{selectedWechatIds.map(friend => (
<div key={friend.id} className={style.selectedItem}>
<div className={style.selectedItemContent}>
<div className={style.selectedItemAvatar}>
{friend.avatar ? (
<img src={friend.avatar} alt={friend.nickname} />
) : (
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "14px", color: "#94a3b8" }}>
{friend.nickname?.charAt(0) || "?"}
</div>
)}
</div>
<div className={style.selectedItemInfo}>
<div className={style.selectedItemName}>{friend.nickname || friend.wechatId}</div>
<div className={style.selectedItemId}>ID: {friend.wechatId}</div>
</div>
</div>
<button
className={style.deleteButton}
onClick={() => handleRemoveFixedWechatId(friend.id)}
>
<DeleteOutlined style={{ fontSize: "18px" }} />
</button>
</div>
))}
</div>
)}
{/* 手动添加微信号 */}
<div className={style.manualAdd}>
<p className={style.manualAddLabel}>?</p>
<div className={style.manualAddInput}>
<div className={style.manualAddInputWrapper}>
<UserAddOutlined className={style.manualAddIcon} />
<input
type="text"
className={style.manualAddInputField}
value={manualWechatIdInput}
onChange={e => setManualWechatIdInput(e.target.value)}
placeholder="请输入微信号"
/>
</div>
<button className={style.manualAddButton} onClick={handleAddManualWechatId}>
<span></span>
<span className={style.buttonText2}></span>
</button>
</div>
</div>
{/* 已添加的微信号列表(手动输入的) */}
{manualAddedWechatIds.length > 0 && (
<div className={style.addedList}>
<p className={style.manualAddLabel}>:</p>
{manualAddedWechatIds.map((friend, index) => (
<div key={friend.id} className={style.addedItem}>
<div className={style.addedItemContent}>
<div className={style.addedItemNumber}>{index + 1}</div>
<div className={style.addedItemInfo}>
<div className={style.addedItemName}>{friend.nickname || friend.wechatId}</div>
<div className={style.addedItemId}>ID: {friend.wechatId}</div>
<div className={style.addedItemStatus}>{friend.wechatId} </div>
</div>
</div>
<button
className={style.deleteButton}
onClick={() => handleRemoveFixedWechatId(friend.id)}
>
<DeleteOutlined style={{ fontSize: "18px" }} />
</button>
</div>
))}
<p className={style.addedCount}> {(selectedWechatIds.length + manualAddedWechatIds.length)}/3 </p>
</div>
)}
</div>
{/* 群管理员 */}
<div className={style.card}>
<div className={style.groupAdminHeader}>
<div className={style.groupAdminLabelWrapper}>
<label className={style.label}></label>
<InfoCircleOutlined className={style.infoIcon} />
</div>
<Switch
checked={formData.groupAdminEnabled}
onChange={checked => onChange({ groupAdminEnabled: checked })}
/>
</div>
{formData.groupAdminEnabled && (
<div
className={`${style.wechatSelectInput} ${selectedWechatIds.length === 0 ? style.disabled : ''}`}
onClick={handleOpenGroupAdminSelection}
>
<div className={style.selectInputWrapper}>
<span className={style.selectInputPlaceholder}>
{formData.groupAdminWechatIdOption
? (formData.groupAdminWechatIdOption.nickname || formData.groupAdminWechatIdOption.wechatId)
: '请选择群管理员微信号'}
</span>
<span className={style.selectInputArrow}></span>
</div>
</div>
)}
<p className={style.groupAdminHint}></p>
</div>
{/* 群名称 */}
<div className={style.card}>
<label className={style.label}></label>
<input
type="text"
className={style.input}
value={formData.groupNameTemplate}
onChange={e => onChange({ groupNameTemplate: e.target.value })}
placeholder="请输入群名称"
/>
</div>
{/* 群人数配置 */}
<div className={style.card}>
<label className={style.label}></label>
<div className={style.groupSizeConfig}>
{/* 每日最大建群数 */}
<div>
<label className={style.groupSizeLabel}></label>
<input
type="number"
className={style.input}
value={formData.maxGroupsPerDay || ""}
onChange={e => onChange({ maxGroupsPerDay: Number(e.target.value) || 0 })}
placeholder="请输入数量"
/>
</div>
{/* 群组最小人数和最大人数 */}
<div className={style.groupSizeRow}>
<div className={style.groupSizeItem}>
<label className={style.groupSizeLabel}></label>
<input
type="number"
className={style.input}
value={formData.groupSizeMin || ""}
onChange={e => {
const value = Number(e.target.value) || 0;
onChange({ groupSizeMin: value < 3 ? 3 : value });
}}
placeholder="如: 3"
min={3}
/>
</div>
<div className={style.groupSizeItem}>
<label className={style.groupSizeLabel}></label>
<input
type="number"
className={style.input}
value={formData.groupSizeMax || ""}
onChange={e => {
const value = Number(e.target.value) || 0;
onChange({ groupSizeMax: value > 38 ? 38 : value });
}}
placeholder="如: 40"
max={38}
/>
</div>
</div>
</div>
</div>
{/* 执行时间 */}
<div className={style.card}>
<label className={style.label}></label>
<div className={style.timeRangeContainer}>
<div className={style.timeInputWrapper}>
<input
type="time"
className={style.timeInput}
value={formData.startTime || "09:00"}
onChange={e => onChange({ startTime: e.target.value || "09:00" })}
/>
<ClockCircleOutlined className={style.timeIcon} />
</div>
<span className={style.timeSeparator}>-</span>
<div className={style.timeInputWrapper}>
<input
type="time"
className={style.timeInput}
value={formData.endTime || "21:00"}
onChange={e => onChange({ endTime: e.target.value || "21:00" })}
/>
<ClockCircleOutlined className={style.timeIcon} />
</div>
</div>
</div>
{/* 是否启用 */}
<div className={style.card}>
<div className={style.statusSwitchContainer}>
<label className={style.label} style={{ marginBottom: 0 }}></label>
<Switch
checked={formData.status === 1}
onChange={(checked) => onChange({ status: checked ? 1 : 0 })}
/>
</div>
</div>
{/* 隐藏的选择组件 */}
<div style={{ display: "none" }}>
<DeviceSelection
selectedOptions={formData.executor ? [formData.executor] : []}
onSelect={handleExecutorSelect}
placeholder="选择执行智能体"
showInput={false}
showSelectedList={false}
singleSelect={true}
mode="dialog"
open={executorSelectionVisible}
onOpenChange={setExecutorSelectionVisible}
/>
<FriendSelection
visible={fixedWechatIdsSelectionVisible}
onVisibleChange={setFixedWechatIdsSelectionVisible}
selectedOptions={selectedWechatIds}
onSelect={handleFixedWechatIdsSelect}
placeholder="选择微信号"
showInput={false}
showSelectedList={false}
deviceIds={formData.executorId ? [formData.executorId] : []}
enableDeviceFilter={true}
/>
</div>
{/* 群管理员选择弹窗 - 只显示固定微信号列表 */}
<Popup
visible={groupAdminSelectionVisible}
onMaskClick={() => setGroupAdminSelectionVisible(false)}
position="bottom"
bodyStyle={{ height: "60vh" }}
>
<div style={{ padding: "16px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "16px" }}>
<h3 style={{ margin: 0, fontSize: "16px", fontWeight: 600 }}></h3>
<button
onClick={() => setGroupAdminSelectionVisible(false)}
style={{ background: "none", border: "none", fontSize: "16px", color: "#3b82f6", cursor: "pointer" }}
>
</button>
</div>
<div style={{ maxHeight: "50vh", overflowY: "auto" }}>
{selectedWechatIds.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px", color: "#94a3b8" }}>
{manualAddedWechatIds.length > 0
? "群管理员只能从已选择的微信号中选择,不能选择手动添加的微信号"
: "暂无固定微信号可选"}
</div>
) : (
selectedWechatIds.map(friend => {
const isSelected = formData.groupAdminWechatIdOption?.id === friend.id;
return (
<div
key={friend.id}
onClick={() => {
onChange({
groupAdminWechatIdOption: friend,
groupAdminWechatId: friend.id,
});
setGroupAdminSelectionVisible(false);
}}
style={{
padding: "12px",
marginBottom: "8px",
borderRadius: "8px",
border: `1px solid ${isSelected ? "#3b82f6" : "#e2e8f0"}`,
background: isSelected ? "#eff6ff" : "#fff",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.borderColor = "#3b82f6";
e.currentTarget.style.background = "#f8fafc";
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.borderColor = "#e2e8f0";
e.currentTarget.style.background = "#fff";
}
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{
width: "36px",
height: "36px",
borderRadius: "50%",
background: "#e2e8f0",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
overflow: "hidden"
}}>
{friend.avatar ? (
<img src={friend.avatar} alt={friend.nickname} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<span style={{ fontSize: "14px", color: "#94a3b8" }}>
{friend.nickname?.charAt(0) || friend.wechatId?.charAt(0) || "?"}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "14px", fontWeight: 500, color: "#1e293b", marginBottom: "2px" }}>
{friend.nickname || friend.wechatId}
</div>
<div style={{ fontSize: "12px", color: "#64748b" }}>
ID: {friend.wechatId}
</div>
</div>
{isSelected && (
<span style={{ color: "#3b82f6", fontSize: "16px" }}></span>
)}
</div>
</div>
);
})
)}
</div>
</div>
</Popup>
</div>
);
},
);
BasicSettings.displayName = "BasicSettings";
export default BasicSettings;

View File

@@ -0,0 +1,197 @@
.container {
padding: 16px;
background: #f8fafc;
min-height: 100vh;
padding-bottom: 100px;
}
.header {
margin-bottom: 16px;
}
.title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.deviceSelectorWrapper {
margin-top: 16px;
}
.deviceSelector {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #f8fafc;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
}
}
.selectedDevicesInfo {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
overflow: hidden;
}
.selectedCountText {
font-size: 14px;
font-weight: 500;
color: #1e293b;
}
.deviceNames {
font-size: 12px;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
flex: 1;
color: #cbd5e1;
font-size: 14px;
}
.expandIcon {
color: #94a3b8;
font-size: 14px;
flex-shrink: 0;
margin-left: 8px;
}
.selectedDevicesGrid {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.selectedDeviceCard {
position: relative;
background: #fff;
border-radius: 12px;
padding: 16px;
border: 2px solid #3b82f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
background: #eff6ff;
}
.deviceCardHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.deviceCardIcon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
display: flex;
align-items: center;
justify-content: center;
}
}
.deviceIconOnline {
background: #e0f2fe;
color: #3b82f6;
}
.deviceIconOffline {
background: #f3f4f6;
color: #94a3b8;
}
.deviceCardStatusBadge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.statusOnline {
background: #d1fae5;
color: #065f46;
}
.statusOffline {
background: #f3f4f6;
color: #374151;
}
.deviceCardName {
font-size: 16px;
font-weight: 700;
color: #1e293b;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deviceCardInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.deviceCardPhone {
font-size: 12px;
color: #64748b;
margin: 0;
}
.deviceCardDeleteButton {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
border-radius: 4px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
}
}
.deviceCardDeleteIcon {
color: #ef4444;
font-size: 14px;
}

View File

@@ -0,0 +1,123 @@
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
import { DeleteOutlined } from "@ant-design/icons";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import DeviceSelection from "@/components/DeviceSelection";
import style from "./DeviceSelectionStep.module.scss";
export interface DeviceSelectionStepRef {
validate: () => Promise<boolean>;
getValues: () => any;
}
interface DeviceSelectionStepProps {
deviceGroupsOptions?: DeviceSelectionItem[];
deviceGroups?: number[];
onSelect: (devices: DeviceSelectionItem[]) => void;
}
const DeviceSelectionStep = forwardRef<DeviceSelectionStepRef, DeviceSelectionStepProps>(
({ deviceGroupsOptions = [], deviceGroups = [], onSelect }, ref) => {
const [deviceSelectionVisible, setDeviceSelectionVisible] = useState(false);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
if (deviceGroups.length === 0) {
return false;
}
return true;
},
getValues: () => {
return { deviceGroupsOptions, deviceGroups };
},
}));
const handleDeviceSelect = (devices: DeviceSelectionItem[]) => {
onSelect(devices);
setDeviceSelectionVisible(false);
};
return (
<div className={style.container}>
<div className={style.header}>
<h2 className={style.title}></h2>
</div>
<div className={style.deviceSelectorWrapper}>
<div
className={style.deviceSelector}
onClick={() => setDeviceSelectionVisible(true)}
>
{deviceGroupsOptions.length > 0 ? (
<div className={style.selectedDevicesInfo}>
<span className={style.selectedCountText}>
{deviceGroupsOptions.length}
</span>
</div>
) : (
<div className={style.placeholder}>
<span></span>
</div>
)}
<span className={style.expandIcon}></span>
</div>
</div>
{/* 已选设备列表 */}
{deviceGroupsOptions.length > 0 && (
<div className={style.selectedDevicesGrid}>
{deviceGroupsOptions.map((device) => (
<div key={device.id} className={style.selectedDeviceCard}>
<div className={style.deviceCardHeader}>
<div className={`${style.deviceCardIcon} ${device.status === "online" ? style.deviceIconOnline : style.deviceIconOffline}`}>
{device.avatar ? (
<img src={device.avatar} alt={device.memo || device.wechatId} />
) : (
<span>📱</span>
)}
</div>
<span className={`${style.deviceCardStatusBadge} ${device.status === "online" ? style.statusOnline : style.statusOffline}`}>
{device.status === "online" ? "在线" : "离线"}
</span>
</div>
<h3 className={style.deviceCardName}>{device.memo || device.wechatId}</h3>
<div className={style.deviceCardInfo}>
<p className={style.deviceCardPhone}>{device.wechatId}</p>
</div>
<div
className={style.deviceCardDeleteButton}
onClick={(e) => {
e.stopPropagation();
const newDeviceGroupsOptions = deviceGroupsOptions.filter(d => d.id !== device.id);
onSelect(newDeviceGroupsOptions);
}}
>
<DeleteOutlined className={style.deviceCardDeleteIcon} />
</div>
</div>
))}
</div>
)}
{/* 设备选择弹窗 */}
<div style={{ display: "none" }}>
<DeviceSelection
selectedOptions={deviceGroupsOptions}
onSelect={handleDeviceSelect}
placeholder="选择设备"
showInput={false}
showSelectedList={false}
singleSelect={false}
mode="dialog"
open={deviceSelectionVisible}
onOpenChange={setDeviceSelectionVisible}
/>
</div>
</div>
);
}
);
DeviceSelectionStep.displayName = "DeviceSelectionStep";
export default DeviceSelectionStep;

View File

@@ -0,0 +1,233 @@
.container {
padding: 16px;
background: #f8fafc;
min-height: 100vh;
padding-bottom: 100px;
}
.header {
margin-bottom: 16px;
}
.headerTop {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.manageLink {
font-size: 14px;
color: #3b82f6;
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.linkIcon {
font-size: 14px;
}
.infoBox {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 12px;
padding: 16px;
margin-bottom: 24px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.infoIcon {
color: #3b82f6;
font-size: 20px;
margin-top: 2px;
flex-shrink: 0;
}
.infoText {
font-size: 14px;
color: #1e40af;
line-height: 1.5;
margin: 0;
}
.poolSelectorWrapper {
margin-bottom: 24px;
}
.poolSelector {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #3b82f6;
}
}
.selectedPoolsInfo {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
overflow: hidden;
}
.selectedCountText {
font-size: 14px;
font-weight: 500;
color: #1e293b;
}
.placeholder {
flex: 1;
color: #cbd5e1;
font-size: 14px;
}
.expandIcon {
color: #94a3b8;
font-size: 14px;
flex-shrink: 0;
margin-left: 8px;
}
.selectedPoolsList {
display: flex;
flex-direction: column;
gap: 16px;
}
.selectedPoolCard {
position: relative;
background: #fff;
border-radius: 16px;
padding: 20px;
border: 1px solid transparent;
box-shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
cursor: pointer;
&:hover {
border-color: #3b82f6;
box-shadow: 0 4px 20px -2px rgba(59, 130, 246, 0.15);
}
}
.poolCardContent {
padding-right: 32px;
}
.poolCardHeader {
margin-bottom: 8px;
}
.poolCardName {
font-size: 16px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.poolCardDescription {
font-size: 14px;
color: #64748b;
margin: 0 0 12px 0;
}
.poolCardStats {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.poolCardUsers {
display: flex;
align-items: center;
gap: 6px;
color: #3b82f6;
font-weight: 700;
font-size: 14px;
}
.usersIcon {
font-size: 18px;
}
.usersCount {
font-size: 14px;
}
.poolCardDivider {
width: 1px;
height: 12px;
background: #d1d5db;
}
.poolCardTime {
font-size: 12px;
color: #9ca3af;
}
.poolCardTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.poolTag {
padding: 4px 10px;
border-radius: 8px;
background: #eff6ff;
color: #3b82f6;
font-size: 12px;
font-weight: 500;
border: 1px solid #bfdbfe;
}
.poolCardDeleteButton {
position: absolute;
top: 16px;
right: 16px;
width: 24px;
height: 24px;
border-radius: 4px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(239, 68, 68, 0.2);
border-color: #ef4444;
}
}
.poolCardDeleteIcon {
color: #ef4444;
font-size: 14px;
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
import { DeleteOutlined } from "@ant-design/icons";
import { PoolSelectionItem } from "@/components/PoolSelection/data";
import PoolSelection from "@/components/PoolSelection";
import style from "./PoolSelectionStep.module.scss";
export interface PoolSelectionStepRef {
validate: () => Promise<boolean>;
getValues: () => any;
}
interface PoolSelectionStepProps {
selectedPools?: PoolSelectionItem[];
poolGroups?: string[];
onSelect: (pools: PoolSelectionItem[], poolGroups: string[]) => void;
}
const PoolSelectionStep = forwardRef<PoolSelectionStepRef, PoolSelectionStepProps>(
({ selectedPools = [], poolGroups = [], onSelect }, ref) => {
const [poolSelectionVisible, setPoolSelectionVisible] = useState(false);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
if (selectedPools.length === 0) {
return false;
}
return true;
},
getValues: () => {
return { selectedPools, poolGroups };
},
}));
const handlePoolSelect = (pools: PoolSelectionItem[]) => {
const poolGroupIds = pools.map(p => p.id);
onSelect(pools, poolGroupIds);
setPoolSelectionVisible(false);
};
return (
<div className={style.container}>
<div className={style.header}>
<div className={style.headerTop}>
<h2 className={style.title}></h2>
<a
className={style.manageLink}
href="#"
onClick={(e) => {
e.preventDefault();
// TODO: 导航到流量池管理页面
}}
>
<span className={style.linkIcon}></span>
</a>
</div>
</div>
<div className={style.infoBox}>
<span className={style.infoIcon}></span>
<p className={style.infoText}>
</p>
</div>
<div className={style.poolSelectorWrapper}>
<div
className={style.poolSelector}
onClick={() => setPoolSelectionVisible(true)}
>
{selectedPools.length > 0 ? (
<div className={style.selectedPoolsInfo}>
<span className={style.selectedCountText}>
{selectedPools.length}
</span>
</div>
) : (
<div className={style.placeholder}>
<span></span>
</div>
)}
<span className={style.expandIcon}></span>
</div>
</div>
{/* 已选流量池列表 */}
{selectedPools.length > 0 && (
<div className={style.selectedPoolsList}>
{selectedPools.map((pool) => (
<div key={pool.id} className={style.selectedPoolCard}>
<div className={style.poolCardContent}>
<div className={style.poolCardHeader}>
<h3 className={style.poolCardName}>{pool.name}</h3>
</div>
{pool.description && (
<p className={style.poolCardDescription}>{pool.description}</p>
)}
<div className={style.poolCardStats}>
<div className={style.poolCardUsers}>
<span className={style.usersIcon}>👥</span>
<span className={style.usersCount}>{pool.num || 0} </span>
</div>
{pool.createTime && (
<>
<div className={style.poolCardDivider}></div>
<span className={style.poolCardTime}>
{pool.createTime}
</span>
</>
)}
</div>
{pool.tags && pool.tags.length > 0 && (
<div className={style.poolCardTags}>
{pool.tags.map((tag: any, index: number) => (
<span key={index} className={style.poolTag}>
{typeof tag === 'string' ? tag : tag.name || tag.label}
</span>
))}
</div>
)}
</div>
<div
className={style.poolCardDeleteButton}
onClick={(e) => {
e.stopPropagation();
const newSelectedPools = selectedPools.filter(p => p.id !== pool.id);
const newPoolGroups = newSelectedPools.map(p => p.id);
onSelect(newSelectedPools, newPoolGroups);
}}
>
<DeleteOutlined className={style.poolCardDeleteIcon} />
</div>
</div>
))}
</div>
)}
{/* 流量池选择弹窗 */}
<PoolSelection
selectedOptions={selectedPools}
onSelect={handlePoolSelect}
placeholder="选择流量池"
showInput={false}
showSelectedList={false}
visible={poolSelectionVisible}
onVisibleChange={setPoolSelectionVisible}
/>
</div>
);
}
);
PoolSelectionStep.displayName = "PoolSelectionStep";
export default PoolSelectionStep;

View File

@@ -0,0 +1,291 @@
import React, { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Toast } from "antd-mobile";
import { Button } from "antd";
import Layout from "@/components/Layout/Layout";
import { createGroupCreate, updateGroupCreate, getGroupCreateDetail } from "./api";
import { GroupCreateFormData } from "./types";
import StepIndicator from "@/components/StepIndicator";
import BasicSettings, { BasicSettingsRef } from "./components/BasicSettings";
import DeviceSelectionStep, { DeviceSelectionStepRef } from "./components/DeviceSelectionStep";
import PoolSelectionStep, { PoolSelectionStepRef } from "./components/PoolSelectionStep";
import NavCommon from "@/components/NavCommon/index";
const steps = [
{ id: 1, title: "1", subtitle: "群设置" },
{ id: 2, title: "2", subtitle: "设备选择" },
{ id: 3, title: "3", subtitle: "流量池选择" },
];
const defaultForm: GroupCreateFormData = {
planType: 1, // 默认独立计划
name: "",
executorId: undefined,
executor: undefined,
deviceGroupsOptions: [],
deviceGroups: [],
wechatGroups: [],
wechatGroupsOptions: [],
groupAdminEnabled: false,
groupAdminWechatId: undefined,
groupAdminWechatIdOption: undefined,
groupNameTemplate: "",
maxGroupsPerDay: 20,
groupSizeMin: 3,
groupSizeMax: 38,
startTime: "09:00", // 默认开始时间
endTime: "21:00", // 默认结束时间
poolGroups: [],
poolGroupsOptions: [],
status: 1, // 默认启用
};
const GroupCreateForm: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams();
const isEdit = Boolean(id);
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const [dataLoaded, setDataLoaded] = useState(!isEdit);
const [formData, setFormData] = useState<GroupCreateFormData>(defaultForm);
// 创建子组件的ref
const basicSettingsRef = useRef<BasicSettingsRef>(null);
const deviceSelectionStepRef = useRef<DeviceSelectionStepRef>(null);
const poolSelectionStepRef = useRef<PoolSelectionStepRef>(null);
useEffect(() => {
if (!id) return;
// 获取详情并回填表单
getGroupCreateDetail(id)
.then(res => {
const config = res.config || {};
// 转换 deviceGroups 从字符串数组到数字数组
const deviceGroups = (config.deviceGroups || []).map((id: string | number) => Number(id));
// 转换 poolGroups 保持字符串数组
const poolGroups = (config.poolGroups || []).map((id: string | number) => String(id));
// 转换 wechatGroups 到数字数组
const wechatGroups = (config.wechatGroups || []).map((id: string | number) => Number(id));
// 查找群管理员选项(如果有)
const groupAdminWechatIdOption = config.groupAdminWechatId && config.wechatGroupsOptions
? config.wechatGroupsOptions.find((f: any) => f.id === config.groupAdminWechatId)
: undefined;
const updatedForm: GroupCreateFormData = {
...defaultForm,
id: String(res.id),
planType: config.planType ?? res.planType ?? 1,
name: res.name ?? "",
executorId: config.executorId,
executor: config.deviceGroupsOptions?.[0], // executor 使用第一个设备(如果需要)
deviceGroupsOptions: config.deviceGroupsOptions || [],
deviceGroups: deviceGroups,
wechatGroups: wechatGroups,
wechatGroupsOptions: config.wechatGroupsOptions || config.wechatFriendsOptions || [],
groupAdminEnabled: config.groupAdminEnabled === 1,
groupAdminWechatId: config.groupAdminWechatId || undefined,
groupAdminWechatIdOption: groupAdminWechatIdOption,
groupNameTemplate: config.groupNameTemplate || "",
maxGroupsPerDay: config.maxGroupsPerDay ?? 20,
groupSizeMin: config.groupSizeMin ?? 3,
groupSizeMax: config.groupSizeMax ?? 38,
startTime: config.startTime || "09:00",
endTime: config.endTime || "21:00",
poolGroups: poolGroups,
poolGroupsOptions: config.poolGroupsOptions || [],
status: res.status ?? 1,
};
setFormData(updatedForm);
setDataLoaded(true);
})
.catch(err => {
Toast.show({ content: err.message || "获取详情失败" });
setDataLoaded(true);
});
}, [id]);
const handleFormDataChange = (values: Partial<GroupCreateFormData>) => {
setFormData(prev => ({ ...prev, ...values }));
};
const handleNext = async () => {
if (currentStep === 1) {
// 验证第一步
const isValid = (await basicSettingsRef.current?.validate()) || false;
if (!isValid) {
return;
}
setCurrentStep(2);
// 切换到下一步时,滚动到顶部
setTimeout(() => {
const mainElement = document.querySelector('main');
if (mainElement) {
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
} else if (currentStep === 2) {
// 验证第二步
const isValid = (await deviceSelectionStepRef.current?.validate()) || false;
if (!isValid) {
Toast.show({ content: "请至少选择一个执行设备", position: "top" });
return;
}
setCurrentStep(3);
// 切换到下一步时,滚动到顶部
setTimeout(() => {
const mainElement = document.querySelector('main');
if (mainElement) {
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
} else if (currentStep === 3) {
// 验证第三步并保存
await handleSave();
}
};
const handleSave = async () => {
// 验证第三步
const isValid = (await poolSelectionStepRef.current?.validate()) || false;
if (!isValid) {
Toast.show({ content: "请至少选择一个流量池", position: "top" });
return;
}
setLoading(true);
try {
// 构建提交数据
const submitData = {
...formData,
type: 4, // 自动建群任务类型(保持与旧版一致)
};
if (isEdit) {
await updateGroupCreate(submitData);
Toast.show({ content: "编辑成功" });
} else {
await createGroupCreate(submitData);
Toast.show({ content: "创建成功" });
}
navigate("/workspace/group-create");
} catch (e: any) {
Toast.show({ content: e?.message || "提交失败" });
} finally {
setLoading(false);
}
};
const renderCurrentStep = () => {
// 编辑模式下,等待数据加载完成后再渲染
if (isEdit && !dataLoaded) {
return (
<div style={{ textAlign: "center", padding: "50px" }}>...</div>
);
}
switch (currentStep) {
case 1:
return (
<BasicSettings
ref={basicSettingsRef}
formData={formData}
onChange={handleFormDataChange}
/>
);
case 2:
return (
<DeviceSelectionStep
ref={deviceSelectionStepRef}
deviceGroupsOptions={formData.deviceGroupsOptions || []}
deviceGroups={formData.deviceGroups || []}
onSelect={(devices) => {
const deviceIds = devices.map(d => d.id);
handleFormDataChange({
deviceGroupsOptions: devices,
deviceGroups: deviceIds,
// 如果只有一个设备,也设置 executor 和 executorId 用于兼容
executor: devices.length === 1 ? devices[0] : undefined,
executorId: devices.length === 1 ? devices[0].id : undefined,
});
}}
/>
);
case 3:
return (
<PoolSelectionStep
ref={poolSelectionStepRef}
selectedPools={formData.poolGroupsOptions || []}
poolGroups={formData.poolGroups || []}
onSelect={(pools, poolGroupIds) => {
handleFormDataChange({
poolGroupsOptions: pools,
poolGroups: poolGroupIds,
});
}}
/>
);
default:
return null;
}
};
const renderFooter = () => {
return (
<div style={{ display: "flex", gap: "12px", padding: "16px" }}>
{currentStep > 1 && (
<Button
size="large"
onClick={() => {
setCurrentStep(currentStep - 1);
// 切换到上一步时,滚动到顶部
setTimeout(() => {
const mainElement = document.querySelector('main');
if (mainElement) {
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, 100);
}}
style={{ flex: 1 }}
>
</Button>
)}
<Button
size="large"
type="primary"
loading={loading}
onClick={handleNext}
style={{ flex: 1 }}
>
{currentStep === 3 ? "完成" : "下一步"}
</Button>
</div>
);
};
return (
<Layout
header={
<>
<NavCommon
title={isEdit ? "编辑自动建群" : "新建自动建群"}
backFn={() => navigate(-1)}
/>
<StepIndicator currentStep={currentStep} steps={steps} />
</>
}
footer={renderFooter()}
>
<div>{renderCurrentStep()}</div>
</Layout>
);
};
export default GroupCreateForm;

View File

@@ -0,0 +1,77 @@
import { FriendSelectionItem } from "@/components/FriendSelection/data";
import { DeviceSelectionItem } from "@/components/DeviceSelection/data";
import { PoolSelectionItem } from "@/components/PoolSelection/data";
// 自动建群表单数据类型定义(新版)
export interface GroupCreateFormData {
id?: string; // 任务ID
planType: number; // 计划类型0-全局计划1-独立计划
name: string; // 计划名称
executor?: DeviceSelectionItem; // 执行智能体(执行者)- 单个设备(保留用于兼容)
executorId?: number; // 执行智能体ID设备ID保留用于兼容
deviceGroupsOptions?: DeviceSelectionItem[]; // 选中的设备列表
deviceGroups?: number[]; // 选中的设备ID列表
wechatGroups: number[]; // 固定微信号ID列表必须3个
wechatGroupsOptions: FriendSelectionItem[]; // 固定微信号选项
groupAdminEnabled: boolean; // 群管理员开关
groupAdminWechatId?: number; // 群管理员微信号ID
groupAdminWechatIdOption?: FriendSelectionItem; // 群管理员微信号选项
groupNameTemplate: string; // 群名称模板
maxGroupsPerDay: number; // 每日最大建群数
groupSizeMin: number; // 群组最小人数
groupSizeMax: number; // 群组最大人数
startTime?: string; // 执行开始时间HH:mm默认 09:00
endTime?: string; // 执行结束时间HH:mm默认 21:00
poolGroups?: string[]; // 流量池ID列表
poolGroupsOptions?: PoolSelectionItem[]; // 流量池选项列表
status: number; // 是否启用 (1: 启用, 0: 禁用)
[key: string]: any;
}
// 步骤定义
export interface StepItem {
id: number;
title: string;
subtitle: string;
}
// 表单验证规则
export const formValidationRules = {
name: [
{ required: true, message: "请输入计划名称" },
{ min: 2, max: 50, message: "计划名称长度应在2-50个字符之间" },
],
executorId: [{ required: true, message: "请选择执行智能体" }],
wechatGroups: [
{ required: true, message: "请选择固定微信号" },
{
validator: (_: any, value: number[]) => {
if (!value || value.length !== 3) {
return Promise.reject(new Error("固定微信号必须选择3个"));
}
return Promise.resolve();
},
},
],
groupNameTemplate: [
{ required: true, message: "请输入群名称模板" },
{ min: 2, max: 100, message: "群名称模板长度应在2-100个字符之间" },
],
maxGroupsPerDay: [
{ required: true, message: "请输入每日最大建群数" },
{
type: "number",
min: 1,
max: 100,
message: "每日最大建群数应在1-100之间",
},
],
groupSizeMin: [
{ required: true, message: "请输入群组最小人数" },
{ type: "number", min: 1, max: 500, message: "群组最小人数应在1-500之间" },
],
groupSizeMax: [
{ required: true, message: "请输入群组最大人数" },
{ type: "number", min: 1, max: 500, message: "群组最大人数应在1-500之间" },
],
};

View File

@@ -0,0 +1,303 @@
.container {
padding: 16px;
padding-bottom: 100px;
background: #f8fafc;
min-height: 100vh;
}
.infoBox {
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 20px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.infoIcon {
color: #2563eb;
font-size: 20px;
margin-top: 2px;
flex-shrink: 0;
}
.infoText {
font-size: 13px;
color: #1e40af;
line-height: 1.5;
margin: 0;
font-weight: 500;
}
.section {
margin-bottom: 24px;
}
.sectionTitle {
font-size: 15px;
font-weight: 700;
margin-bottom: 12px;
padding-left: 4px;
display: flex;
align-items: center;
gap: 8px;
color: #1e293b;
}
.sectionDot {
width: 4px;
height: 14px;
background: #2563eb;
border-radius: 2px;
box-shadow: 0 0 8px rgba(37, 99, 235, 0.4);
}
.sectionTitleIndependent {
.sectionDot {
background: #fb923c;
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
}
}
.sectionDotIndependent {
background: #fb923c;
box-shadow: 0 0 8px rgba(251, 146, 60, 0.4);
}
.planList {
display: flex;
flex-direction: column;
gap: 16px;
}
.planCard {
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
transition: all 0.3s;
position: relative;
overflow: hidden;
&:hover {
box-shadow: 0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04);
}
&::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 128px;
height: 128px;
background: linear-gradient(to bottom right, rgba(239, 246, 255, 0.5), transparent);
border-radius: 0 0 0 80px;
opacity: 0;
transition: opacity 0.5s;
pointer-events: none;
}
&:hover::before {
opacity: 1;
}
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
position: relative;
z-index: 10;
}
.cardTitleSection {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
max-width: 70%;
}
.cardTitle {
font-size: 16px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
}
.statusRunning {
background: #dcfce7;
color: #166534;
border-color: rgba(22, 101, 52, 0.1);
}
.statusStopped {
background: #f1f5f9;
color: #64748b;
border-color: rgba(100, 116, 139, 0.1);
}
.cardActions {
display: flex;
align-items: center;
gap: 8px;
}
.statusSwitch {
--checked-color: #2563eb;
}
.moreButton {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: none;
background: transparent;
color: #64748b;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f8fafc;
}
&:active {
background: #e2e8f0;
}
}
.cardStats {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
padding: 0 4px;
position: relative;
z-index: 10;
}
.statItem {
flex: 1;
}
.statHeader {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
color: #64748b;
}
.statIcon {
font-size: 16px;
}
.statLabel {
font-size: 12px;
font-weight: 500;
margin: 0;
color: #64748b;
}
.statValue {
display: flex;
align-items: baseline;
gap: 8px;
}
.statNumber {
font-size: 24px;
font-weight: 700;
color: #1e293b;
font-family: "Noto Sans SC", sans-serif;
}
.statDivider {
width: 1px;
height: 40px;
background: #e2e8f0;
}
.cardDetails {
border-top: 1px solid #f1f5f9;
padding-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
z-index: 10;
}
.detailItem {
display: flex;
align-items: center;
font-size: 13px;
color: #64748b;
}
.detailIcon {
font-size: 16px;
margin-right: 8px;
color: #94a3b8;
}
.detailLabel {
flex-shrink: 0;
}
.detailValue {
margin-left: auto;
font-weight: 600;
color: #1e293b;
}
.detailTime {
margin-left: auto;
font-weight: 500;
font-family: monospace;
color: #64748b;
font-size: 11px;
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.emptyIcon {
font-size: 48px;
margin-bottom: 16px;
}
.emptyText {
font-size: 16px;
color: #64748b;
margin-bottom: 24px;
}
.emptyButton {
min-width: 140px;
}

View File

@@ -0,0 +1,344 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Toast, Switch, Dialog } from "antd-mobile";
import { Dropdown, Menu } from "antd";
import { MoreOutlined, EditOutlined, DeleteOutlined, PlusOutlined, EyeOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getGroupCreateList, toggleGroupCreateStatus, deleteGroupCreate } from "../form/api";
import style from "./index.module.scss";
interface GroupCreatePlan {
id: string;
name: string;
planType: number; // 0-全局计划, 1-独立计划
status: number; // 1-启用, 0-禁用
groupNameTemplate?: string;
groupSizeMax?: number;
groupSizeMin?: number;
updateTime?: string;
createTime?: string;
createdGroupsCount?: number; // 已建群数
totalMembersCount?: number; // 总人数
[key: string]: any;
}
const GroupCreateList: React.FC = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [plans, setPlans] = useState<GroupCreatePlan[]>([]);
const [menuLoadingId, setMenuLoadingId] = useState<string | null>(null);
// 获取列表数据
const fetchList = async () => {
setLoading(true);
try {
const res = await getGroupCreateList({ type: 4 });
const list = res?.list || res?.data?.list || res?.data || [];
const normalized: GroupCreatePlan[] = (list as any[]).map((item: any) => {
const stats = item.config?.stats || {};
return {
id: String(item.id),
name: item.name || "",
planType: item.config?.planType ?? item.planType ?? 1,
status: item.status === 1 ? 1 : 0,
groupNameTemplate: item.config?.groupNameTemplate || item.groupNameTemplate || item.groupName || "",
groupSizeMax: item.config?.groupSizeMax || item.groupSizeMax || 38,
groupSizeMin: item.config?.groupSizeMin || item.groupSizeMin || 3,
updateTime: item.updateTime || item.createTime || "",
createTime: item.createTime || "",
createdGroupsCount: stats.createdGroupsCount ?? item.createdGroupsCount ?? 0,
totalMembersCount: stats.totalMembersCount ?? item.totalMembersCount ?? 0,
};
});
setPlans(normalized);
} catch (e: any) {
Toast.show({ content: e?.message || "获取列表失败", position: "top" });
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchList();
}, []);
// 切换状态
const handleStatusToggle = async (plan: GroupCreatePlan) => {
try {
const newStatus = plan.status === 1 ? 0 : 1;
await toggleGroupCreateStatus({
id: plan.id,
status: newStatus,
});
Toast.show({ content: newStatus === 1 ? "已启用" : "已停止", position: "top" });
// 直接更新本地状态
setPlans(prev =>
prev.map(p => (p.id === plan.id ? { ...p, status: newStatus } : p))
);
} catch (e: any) {
Toast.show({ content: e?.message || "操作失败", position: "top" });
}
};
// 删除计划
const handleDelete = async (plan: GroupCreatePlan) => {
const result = await Dialog.confirm({
content: "确定要删除该计划吗?",
confirmText: "删除",
cancelText: "取消",
});
if (!result) return;
setMenuLoadingId(plan.id);
try {
await deleteGroupCreate(plan.id);
Toast.show({ content: "删除成功", position: "top" });
fetchList();
} catch (e: any) {
Toast.show({ content: e?.message || "删除失败", position: "top" });
} finally {
setMenuLoadingId(null);
}
};
// 菜单点击
const handleMenuClick = ({ key }: { key: string }, plan: GroupCreatePlan) => {
if (key === "detail") {
navigate(`/workspace/group-create/${plan.id}`);
} else if (key === "edit") {
navigate(`/workspace/group-create/${plan.id}/edit`);
} else if (key === "delete") {
handleDelete(plan);
}
};
// 创建新计划
const handleCreate = () => {
navigate("/workspace/group-create/new");
};
// 分隔全局计划和独立计划
const globalPlans = plans.filter(p => p.planType === 0);
const independentPlans = plans.filter(p => p.planType === 1);
return (
<Layout
header={
<NavCommon
title="自动建群"
right={
<div style={{ marginRight: "-16px" }}>
<Button
size="small"
color="primary"
onClick={handleCreate}
>
<PlusOutlined />
</Button>
</div>
}
/>
}
loading={loading}
>
<div className={style.container}>
{/* 全局计划提示 */}
{globalPlans.length > 0 && (
<div className={style.infoBox}>
<span className={style.infoIcon}></span>
<p className={style.infoText}>
</p>
</div>
)}
{/* 全局建群计划 */}
{globalPlans.length > 0 && (
<section className={style.section}>
<h2 className={style.sectionTitle}>
<div className={style.sectionDot}></div>
</h2>
{globalPlans.map(plan => (
<PlanCard
key={plan.id}
plan={plan}
onStatusToggle={handleStatusToggle}
onMenuClick={handleMenuClick}
menuLoading={menuLoadingId === plan.id}
/>
))}
</section>
)}
{/* 独立建群计划 */}
{independentPlans.length > 0 && (
<section className={style.section}>
<h2 className={`${style.sectionTitle} ${style.sectionTitleIndependent}`}>
<div className={`${style.sectionDot} ${style.sectionDotIndependent}`}></div>
</h2>
<div className={style.planList}>
{independentPlans.map(plan => (
<PlanCard
key={plan.id}
plan={plan}
onStatusToggle={handleStatusToggle}
onMenuClick={handleMenuClick}
menuLoading={menuLoadingId === plan.id}
/>
))}
</div>
</section>
)}
{/* 空状态 */}
{plans.length === 0 && !loading && (
<div className={style.emptyState}>
<div className={style.emptyIcon}>📋</div>
<div className={style.emptyText}></div>
<Button color="primary" onClick={handleCreate} className={style.emptyButton}>
</Button>
</div>
)}
</div>
</Layout>
);
};
// 计划卡片组件
interface PlanCardProps {
plan: GroupCreatePlan;
onStatusToggle: (plan: GroupCreatePlan) => void;
onMenuClick: (params: { key: string }, plan: GroupCreatePlan) => void;
menuLoading: boolean;
}
const PlanCard: React.FC<PlanCardProps> = ({
plan,
onStatusToggle,
onMenuClick,
menuLoading,
}) => {
const isRunning = plan.status === 1;
return (
<div className={style.planCard}>
{/* 卡片头部 */}
<div className={style.cardHeader}>
<div className={style.cardTitleSection}>
<h3 className={style.cardTitle}>{plan.name}</h3>
<span className={`${style.statusBadge} ${isRunning ? style.statusRunning : style.statusStopped}`}>
{isRunning ? "运行中" : "已停止"}
</span>
</div>
<div className={style.cardActions}>
<Switch
checked={isRunning}
onChange={() => onStatusToggle(plan)}
className={style.statusSwitch}
/>
<Dropdown
menu={{
items: [
{
key: "detail",
icon: <EyeOutlined />,
label: "计划详情",
disabled: menuLoading,
},
{
key: "edit",
icon: <EditOutlined />,
label: "编辑",
disabled: menuLoading,
},
{
key: "delete",
icon: <DeleteOutlined />,
label: "删除",
disabled: menuLoading,
danger: true,
},
],
onClick: (params) => onMenuClick(params, plan),
}}
trigger={["click"]}
placement="bottomRight"
disabled={menuLoading}
>
<button className={style.moreButton}>
<MoreOutlined />
</button>
</Dropdown>
</div>
</div>
{/* 统计数据 */}
<div className={style.cardStats}>
<div className={style.statItem}>
<div className={style.statHeader}>
<span className={style.statIcon}>👥</span>
<p className={style.statLabel}></p>
</div>
<div className={style.statValue}>
<span className={style.statNumber}>{plan.createdGroupsCount || 0}</span>
</div>
</div>
<div className={style.statDivider}></div>
<div className={style.statItem}>
<div className={style.statHeader}>
<span className={style.statIcon}>👥</span>
<p className={style.statLabel}></p>
</div>
<div className={style.statValue}>
<span className={style.statNumber}>{plan.totalMembersCount || 0}</span>
</div>
</div>
</div>
{/* 详细信息 */}
<div className={style.cardDetails}>
<div className={style.detailItem}>
<span className={style.detailIcon}>📝</span>
<span className={style.detailLabel}></span>
<span className={style.detailValue}>{plan.groupNameTemplate || "-"}</span>
</div>
<div className={style.detailItem}>
<span className={style.detailIcon}></span>
<span className={style.detailLabel}></span>
<span className={style.detailValue}>
{plan.groupSizeMax || 38}/
</span>
</div>
<div className={style.detailItem}>
<span className={style.detailIcon}>🕐</span>
<span className={style.detailLabel}></span>
<span className={style.detailTime}>
{plan.updateTime
? new Date(plan.updateTime).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).replace(/\//g, "-")
: plan.createTime
? new Date(plan.createTime).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).replace(/\//g, "-")
: "-"}
</span>
</div>
</div>
</div>
);
};
export default GroupCreateList;

View File

@@ -0,0 +1,92 @@
# 自动建群group-create新增字段说明
## 新增字段列表
相对于旧的 auto-group 模块新版本group-create新增了以下字段
### 1. planType (计划类型)
- **类型**: number
- **取值**: 0-全局计划, 1-独立计划
- **默认值**: 1 (独立计划)
- **说明**: 计划类型,用于区分全局计划和独立计划
### 2. executor / executorId (执行智能体)
- **类型**: FriendSelectionItem / number
- **说明**: 执行智能体(执行者),单个微信号选择
- **必填**: 是
- **替换**: 原 `devices` 字段(原为设备选择,现改为微信号选择)
### 3. fixedWechatIds / fixedWechatIdsOptions (固定微信号)
- **类型**: number[] / FriendSelectionItem[]
- **说明**: 固定微信号必须选择3个
- **必填**: 是
- **新增**: 完全新增的字段,旧版本没有对应字段
- **功能**: 支持搜索选择,也支持手动输入微信号添加
### 4. groupAdminEnabled (群管理员开关)
- **类型**: boolean
- **默认值**: false
- **说明**: 是否启用群管理员功能
- **新增**: 完全新增的字段
### 5. groupAdminWechatId / groupAdminWechatIdOption (群管理员微信号)
- **类型**: number / FriendSelectionItem
- **说明**: 群管理员微信号(当 groupAdminEnabled 为 true 时选择)
- **必填**: 否(仅在 groupAdminEnabled 为 true 时选择)
- **新增**: 完全新增的字段
- **替换**: 原 `admins` 字段(原为多个管理员,现改为单个可选的管理员)
### 6. groupName (群名称)
- **类型**: string
- **说明**: 群名称
- **必填**: 是
- **替换**: 原 `groupNameTemplate` (群名称模板)
- **变更**: 从模板改为直接输入群名称
### 7. executeType (执行类型)
- **类型**: number
- **取值**: 0-立即执行, 1-定时执行
- **默认值**: 1 (定时执行)
- **说明**: 执行类型
- **新增**: 完全新增的字段
### 8. executeDate (执行日期)
- **类型**: string (YYYY-MM-DD)
- **说明**: 执行日期,仅在 executeType === 1 (定时执行) 时必填
- **新增**: 完全新增的字段
### 9. executeTime (执行时间)
- **类型**: string (HH:mm)
- **说明**: 执行时间,仅在 executeType === 1 (定时执行) 时必填
- **新增**: 完全新增的字段
## 已删除的字段
以下字段在新版本中不再使用:
1. **devices / devicesOptions** - 被 executor / executorId 替换(从设备选择改为微信号选择)
2. **admins / adminsOptions** - 被 groupAdminWechatId / groupAdminWechatIdOption 替换(从多个管理员改为单个可选管理员)
3. **poolGroups / poolGroupsOptions** - 流量池选择,新版本不再需要
4. **startTime / endTime** - 允许建群的时间段,新版本改为 executeDate + executeTime
5. **groupNameTemplate** - 被 groupName 替换(从模板改为直接输入)
6. **groupDescription** - 群描述,新版本不再需要
## 保留的字段
以下字段在新版本中继续使用:
1. **id** - 任务ID
2. **name** - 计划名称(保持原有逻辑)
3. **maxGroupsPerDay** - 每日最大建群数(保持原有逻辑)
4. **groupSizeMin** - 群组最小人数(保持原有逻辑)
5. **groupSizeMax** - 群组最大人数(保持原有逻辑)
6. **status** - 是否启用(保持原有逻辑)
7. **type** - 任务类型固定为4与旧版保持一致
## 接口说明
接口路径保持与旧版一致:
- 创建: POST /v1/workbench/create
- 更新: POST /v1/workbench/update
- 详情: GET /v1/workbench/detail
- 列表: GET /v1/workbench/list

View File

@@ -10,5 +10,10 @@ export function getWechatStats() {
return request("/v1/dashboard/wechat-stats", {}, "GET");
}
// 获取常用功能列表
export function getCommonFunctions() {
return request("/v1/workbench/common-functions", {}, "GET");
}
// 你可以根据需要继续添加其他接口
// 例如:场景获客统计、今日数据统计等

View File

@@ -52,8 +52,10 @@
margin-bottom: 12px;
}
.icon {
font-size: 20px;
.iconImage {
width: 75%;
object-fit: contain;
border-radius: 8px;
}
.featureHeader {

View File

@@ -1,117 +1,137 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Card, Badge } from "antd-mobile";
import {
LikeOutlined,
SendOutlined,
TeamOutlined,
LinkOutlined,
ClockCircleOutlined,
ContactsOutlined,
BookOutlined,
ApartmentOutlined,
} from "@ant-design/icons";
import { Card, Badge, SpinLoading, Toast } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import styles from "./index.module.scss";
import NavCommon from "@/components/NavCommon";
import { getCommonFunctions } from "./api";
// 功能key到默认配置的映射用于降级当API没有返回icon时使用
const featureConfig: Record<string, {
bgColor: string;
path: string;
}> = {
"auto_like": {
bgColor: "#fff2f0",
path: "/workspace/auto-like",
},
"moments_sync": {
bgColor: "#f9f0ff",
path: "/workspace/moments-sync",
},
"group_push": {
bgColor: "#fff7e6",
path: "/workspace/group-push",
},
"auto_group": {
bgColor: "#f6ffed",
path: "/workspace/auto-group",
},
"group_create": {
bgColor: "#f6ffed",
path: "/workspace/group-create",
},
"traffic_distribution": {
bgColor: "#e6f7ff",
path: "/workspace/traffic-distribution",
},
"contact_import": {
bgColor: "#f9f0ff",
path: "/workspace/contact-import/list",
},
"ai_knowledge": {
bgColor: "#fff7e6",
path: "/workspace/ai-knowledge",
},
"distribution_management": {
bgColor: "#f9f0ff",
path: "/workspace/distribution-management",
},
};
interface CommonFunction {
id: number;
key: string;
name: string;
description?: string;
icon: React.ReactNode | null;
iconUrl?: string | null;
path: string;
bgColor?: string;
isNew?: boolean;
[key: string]: any;
}
const Workspace: React.FC = () => {
// 常用功能
const commonFeatures = [
{
id: "auto-like",
name: "自动点赞",
description: "智能自动点赞互动",
icon: (
<LikeOutlined className={styles.icon} style={{ color: "#ff4d4f" }} />
),
path: "/workspace/auto-like",
bgColor: "#fff2f0",
isNew: true,
},
{
id: "moments-sync",
name: "朋友圈同步",
description: "自动同步朋友圈内容",
icon: (
<ClockCircleOutlined
className={styles.icon}
style={{ color: "#722ed1" }}
/>
),
path: "/workspace/moments-sync",
bgColor: "#f9f0ff",
},
{
id: "group-push",
name: "群消息推送",
description: "智能群发助手",
icon: (
<SendOutlined className={styles.icon} style={{ color: "#fa8c16" }} />
),
path: "/workspace/group-push",
bgColor: "#fff7e6",
},
{
id: "auto-group",
name: "自动建群",
description: "智能拉好友建群",
icon: (
<TeamOutlined className={styles.icon} style={{ color: "#52c41a" }} />
),
path: "/workspace/auto-group",
bgColor: "#f6ffed",
},
{
id: "traffic-distribution",
name: "流量分发",
description: "管理流量分发和分配",
icon: (
<LinkOutlined className={styles.icon} style={{ color: "#1890ff" }} />
),
path: "/workspace/traffic-distribution",
bgColor: "#e6f7ff",
},
{
id: "contact-import",
name: "通讯录导入",
description: "批量导入通讯录联系人",
icon: (
<ContactsOutlined
className={styles.icon}
style={{ color: "#722ed1" }}
/>
),
path: "/workspace/contact-import/list",
bgColor: "#f9f0ff",
isNew: true,
},
{
id: "ai-knowledge",
name: "AI知识库",
description: "管理和配置内容",
icon: (
<BookOutlined className={styles.icon} style={{ color: "#fa8c16" }} />
),
path: "/workspace/ai-knowledge",
bgColor: "#fff7e6",
isNew: true,
},
{
id: "distribution-management",
name: "分销管理",
description: "管理分销客户和渠道",
icon: (
<ApartmentOutlined
className={styles.icon}
style={{ color: "#722ed1" }}
/>
),
path: "/workspace/distribution-management",
bgColor: "#f9f0ff",
isNew: true,
},
];
const [commonFeatures, setCommonFeatures] = useState<CommonFunction[]>([]);
const [loading, setLoading] = useState(true);
// 从API获取常用功能
useEffect(() => {
const fetchCommonFunctions = async () => {
try {
setLoading(true);
const res = await getCommonFunctions();
// 处理API返回的数据映射图标和样式
const features = (res?.list || res || []).map((item: any) => {
const config = featureConfig[item.key];
// icon是远程图片URL渲染为img标签
const iconElement = item.icon ? (
<img
src={item.icon}
alt={item.title || ""}
className={styles.iconImage}
onError={(e) => {
// 图片加载失败时,隐藏图片
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
) : null;
return {
id: item.id,
key: item.key,
name: item.title || item.name || "",
description: item.subtitle || item.description || item.desc || "",
icon: iconElement,
iconUrl: item.icon || null, // 保存原始URL
path: item.route || item.path || (config?.path) || `/workspace/${item.key?.replace(/_/g, '-')}`,
bgColor: item.iconColor || (config?.bgColor) || undefined, // iconColor可以为空
isNew: item.isNew || item.is_new || false,
};
});
setCommonFeatures(features);
} catch (e: any) {
Toast.show({
content: e?.message || "获取常用功能失败",
position: "top",
});
// 如果接口失败,使用默认数据(从配置映射表生成)
const defaultFeatures = Object.keys(featureConfig).map((key, index) => {
const config = featureConfig[key];
return {
id: index + 1,
key,
name: key.replace(/_/g, ' '),
description: "",
icon: null, // 默认没有图标
iconUrl: null,
path: config.path,
bgColor: config.bgColor,
isNew: false,
};
});
setCommonFeatures(defaultFeatures);
} finally {
setLoading(false);
}
};
fetchCommonFunctions();
}, []);
return (
<Layout
@@ -122,33 +142,45 @@ const Workspace: React.FC = () => {
{/* 常用功能 */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}></h2>
<div className={styles.featuresGrid}>
{commonFeatures.map(feature => (
<Link
to={feature.path}
key={feature.id}
className={styles.featureLink}
>
<Card className={styles.featureCard}>
<div
className={styles.featureIcon}
style={{ backgroundColor: feature.bgColor }}
{loading ? (
<div style={{ display: "flex", justifyContent: "center", padding: "40px 0" }}>
<SpinLoading style={{ "--size": "32px" }} />
</div>
) : (
<div className={styles.featuresGrid}>
{commonFeatures.length > 0 ? (
commonFeatures.map(feature => (
<Link
to={feature.path}
key={feature.key || feature.id}
className={styles.featureLink}
>
{feature.icon}
</div>
<div className={styles.featureHeader}>
<div className={styles.featureName}>{feature.name}</div>
{feature.isNew && (
<Badge content="New" className={styles.newBadge} />
)}
</div>
<div className={styles.featureDescription}>
{feature.description}
</div>
</Card>
</Link>
))}
</div>
<Card className={styles.featureCard}>
<div
className={styles.featureIcon}
style={{ backgroundColor: feature.bgColor || "transparent" }}
>
{feature.icon}
</div>
<div className={styles.featureHeader}>
<div className={styles.featureName}>{feature.name}</div>
{feature.isNew && (
<Badge content="New" className={styles.newBadge} />
)}
</div>
<div className={styles.featureDescription}>
{feature.description}
</div>
</Card>
</Link>
))
) : (
<div style={{ textAlign: "center", padding: "40px 0", color: "#999", width: "100%" }}>
</div>
)}
</div>
)}
</div>
{/* AI智能助手 */}

View File

@@ -52,13 +52,13 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
}
return {
id: pool.id || pool.poolId,
name: pool.name || pool.poolName || `流量池${pool.id}`,
description: pool.description || pool.desc || "",
id: pool.id || pool.poolId,
name: pool.name || pool.poolName || `流量池${pool.id}`,
description: pool.description || pool.desc || "",
userCount: pool.num || pool.userCount || pool.count || 0,
tags: pool.tags || [],
tags: pool.tags || [],
createdAt: createdAt,
deviceIds: pool.deviceIds || [],
deviceIds: pool.deviceIds || [],
};
});

View File

@@ -40,6 +40,12 @@ export const routeGroups = {
"/workspace/auto-like/:id/edit",
"/workspace/auto-group",
"/workspace/auto-group/:id",
"/workspace/group-create",
"/workspace/group-create/new",
"/workspace/group-create/:id",
"/workspace/group-create/:id/groups",
"/workspace/group-create/:id/groups/:groupId",
"/workspace/group-create/:id/edit",
"/workspace/group-push",
"/workspace/group-push/new",
"/workspace/group-push/:id",

View File

@@ -5,6 +5,11 @@ import RecordAutoLike from "@/pages/mobile/workspace/auto-like/record";
import AutoGroupList from "@/pages/mobile/workspace/auto-group/list";
import AutoGroupDetail from "@/pages/mobile/workspace/auto-group/detail";
import AutoGroupForm from "@/pages/mobile/workspace/auto-group/form";
import GroupCreateList from "@/pages/mobile/workspace/group-create/list";
import GroupCreateForm from "@/pages/mobile/workspace/group-create/form";
import GroupCreateDetail from "@/pages/mobile/workspace/group-create/detail";
import GroupListPage from "@/pages/mobile/workspace/group-create/detail/groups-list";
import GroupDetailPage from "@/pages/mobile/workspace/group-create/detail/group-detail";
import GroupPush from "@/pages/mobile/workspace/group-push/list";
import FormGroupPush from "@/pages/mobile/workspace/group-push/form";
import DetailGroupPush from "@/pages/mobile/workspace/group-push/detail";
@@ -53,7 +58,7 @@ const workspaceRoutes = [
element: <NewAutoLike />,
auth: true,
},
// 自动建群
// 自动建群(旧版)
{
path: "/workspace/auto-group",
element: <AutoGroupList />,
@@ -74,6 +79,37 @@ const workspaceRoutes = [
element: <AutoGroupForm />,
auth: true,
},
// 自动建群(新版)
{
path: "/workspace/group-create",
element: <GroupCreateList />,
auth: true,
},
{
path: "/workspace/group-create/new",
element: <GroupCreateForm />,
auth: true,
},
{
path: "/workspace/group-create/:id",
element: <GroupCreateDetail />,
auth: true,
},
{
path: "/workspace/group-create/:id/groups",
element: <GroupListPage />,
auth: true,
},
{
path: "/workspace/group-create/:id/groups/:groupId",
element: <GroupDetailPage />,
auth: true,
},
{
path: "/workspace/group-create/:id/edit",
element: <GroupCreateForm />,
auth: true,
},
// 群发推送
{
path: "/workspace/group-push",

View File

@@ -37,11 +37,12 @@ class DouBaoAI extends Controller
if (empty($params)){
$content = $this->request->param('content', '');
$model = $this->request->param('model', 'doubao-seed-1-8-251215');
if(empty($content)){
return json_encode(['code' => 500, 'msg' => '提示词缺失']);
}
$params = [
'model' => 'doubao-seed-1-8-251215',
'model' => $model,
'messages' => [
['role' => 'system', 'content' => '你现在是存客宝的AI助理你精通中国大陆的法律'],
['role' => 'user', 'content' => $content],

View File

@@ -1020,7 +1020,6 @@ class WebSocketController extends BaseController
// "wechatFriendIds" => $data['wechatFriendIds']
"wechatFriendIds" => [17453051,17453058]
];
$message = $this->sendMessage($params,false);
return json_encode(['code' => 200, 'msg' => '群聊创建成功', 'data' => $message]);
} catch (\Exception $e) {
@@ -1116,6 +1115,7 @@ class WebSocketController extends BaseController
];
}
//chatroomOperateType 4退群 6群公告 5群名称
$params = [
"chatroomOperateType" => !empty($data['chatroomName']) ? 6 : 5,
"cmdType" => "CmdChatroomOperate",
@@ -1125,7 +1125,7 @@ class WebSocketController extends BaseController
"wechatChatroomId" => $data['wechatChatroomId']
];
$message = $this->sendMessage($params,false);
return json_encode(['code' => 200, 'msg' => '群聊创建成功', 'data' => $message]);
return json_encode(['code' => 200, 'msg' => '修改群信息成功', 'data' => $message]);
} catch (\Exception $e) {
Log::error('修改群信息异常: ' . $e->getMessage());
return json_encode(['code' => 500, 'msg' => '修改群信息失败: ' . $e->getMessage()]);

View File

@@ -39,7 +39,7 @@ class WechatChatroomController extends BaseController
'groupId' => $data['groupId'] ?? '',
'wechatChatroomId' => $data['wechatChatroomId'] ?? '',
'memberKeyword' => $data['memberKeyword'] ?? '',
'pageIndex' => $data['pageIndex'] ?? 1,
'pageIndex' => $data['pageIndex'] ?? 0,
'pageSize' => $data['pageSize'] ?? 20
];

View File

@@ -21,6 +21,7 @@ Route::group('v1/', function () {
Route::group('wechatChatroom/', function () {
Route::get('list', 'app\chukebao\controller\WechatChatroomController@getList'); // 获取好友列表
Route::get('detail', 'app\chukebao\controller\WechatChatroomController@getDetail'); // 获取群详情
Route::get('members', 'app\chukebao\controller\WechatChatroomController@getMembers'); // 获取群成员列表
Route::post('aiAnnouncement', 'app\chukebao\controller\WechatChatroomController@aiAnnouncement'); // AI群公告
});

View File

@@ -28,6 +28,12 @@ class MomentsController extends BaseController
$labels = $this->request->param('labels', []); // 标签列表
$timingTime = $this->request->param('timingTime', date('Y-m-d H:i:s')); // 定时发布时间
$immediately = $this->request->param('immediately', false); // 是否立即发布
// 格式化时间字符串为统一格式
$timingTime = $this->normalizeTimingTime($timingTime);
if ($timingTime === false) {
return ResponseHelper::error('定时发布时间格式不正确');
}
// 参数验证
if (empty($text) && empty($picUrlList) && empty($videoUrl)) {
@@ -173,6 +179,12 @@ class MomentsController extends BaseController
$labels = $this->request->param('labels', []);
$timingTime = $this->request->param('timingTime', date('Y-m-d H:i:s'));
$immediately = $this->request->param('immediately', false);
// 格式化时间字符串为统一格式
$timingTime = $this->normalizeTimingTime($timingTime);
if ($timingTime === false) {
return ResponseHelper::error('定时发布时间格式不正确');
}
// 读取待编辑记录
/** @var KfMoments|null $moments */
@@ -427,4 +439,66 @@ class MomentsController extends BaseController
return ResponseHelper::error('删除失败:' . $e->getMessage());
}
}
/**
* 规范化时间字符串为 Y-m-d H:i:s 格式
* 支持多种时间格式:
* - "2026年1月5日15:43:00"
* - "2026-01-05 15:43:00"
* - "2026/01/05 15:43:00"
* - 时间戳
* @param string|int $timingTime 时间字符串或时间戳
* @return string|false 格式化后的时间字符串失败返回false
*/
private function normalizeTimingTime($timingTime)
{
if (empty($timingTime)) {
return date('Y-m-d H:i:s');
}
// 如果是时间戳
if (is_numeric($timingTime) && strlen($timingTime) == 10) {
return date('Y-m-d H:i:s', $timingTime);
}
// 如果是毫秒时间戳
if (is_numeric($timingTime) && strlen($timingTime) == 13) {
return date('Y-m-d H:i:s', intval($timingTime / 1000));
}
// 如果已经是标准格式,直接返回
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $timingTime)) {
return $timingTime;
}
// 处理中文日期格式2026年1月5日15:43:00 或 2026年01月05日15:43:00
if (preg_match('/^(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2}):(\d{1,2}):(\d{1,2})$/', $timingTime, $matches)) {
$year = $matches[1];
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$day = str_pad($matches[3], 2, '0', STR_PAD_LEFT);
$hour = str_pad($matches[4], 2, '0', STR_PAD_LEFT);
$minute = str_pad($matches[5], 2, '0', STR_PAD_LEFT);
$second = str_pad($matches[6], 2, '0', STR_PAD_LEFT);
return "{$year}-{$month}-{$day} {$hour}:{$minute}:{$second}";
}
// 处理中文日期格式无秒2026年1月5日15:43
if (preg_match('/^(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2}):(\d{1,2})$/', $timingTime, $matches)) {
$year = $matches[1];
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$day = str_pad($matches[3], 2, '0', STR_PAD_LEFT);
$hour = str_pad($matches[4], 2, '0', STR_PAD_LEFT);
$minute = str_pad($matches[5], 2, '0', STR_PAD_LEFT);
return "{$year}-{$month}-{$day} {$hour}:{$minute}:00";
}
// 尝试使用 strtotime 解析其他格式
$timestamp = strtotime($timingTime);
if ($timestamp !== false) {
return date('Y-m-d H:i:s', $timestamp);
}
// 如果所有方法都失败,返回 false
return false;
}
}

View File

@@ -151,6 +151,68 @@ class WechatChatroomController extends BaseController
return ResponseHelper::success($detail);
}
public function getMembers()
{
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$groupId = $this->request->param('groupId', '');
$keyword = $this->request->param('keyword', '');
$accountId = $this->getUserInfo('s2_accountId');
if (empty($accountId)) {
return ResponseHelper::error('请先登录');
}
// 验证群组ID必填
if (empty($groupId)) {
return ResponseHelper::error('群组ID不能为空');
}
// 验证群组是否属于当前账号
$chatroom = Db::table('s2_wechat_chatroom')
->where(['id' => $groupId, 'isDeleted' => 0])
->find();
if (!$chatroom) {
return ResponseHelper::error('群组不存在或无权限访问');
}
// 获取群组的chatroomId微信群聊ID
$chatroomId = $chatroom['chatroomId'] ?? $chatroom['id'];
// 如果chatroomId为空使用id作为chatroomId
if (empty($chatroomId)) {
$chatroomId = $chatroom['id'];
}
// 构建查询
$query = Db::table('s2_wechat_chatroom_member')
->where('chatroomId', $chatroomId);
// 关键字搜索:昵称、备注、别名
if ($keyword !== '' && $keyword !== null) {
$query->where(function ($q) use ($keyword) {
$like = '%' . $keyword . '%';
$q->whereLike('nickname', $like)
->whereOr('conRemark', 'like', $like)
->whereOr('alias', 'like', $like);
});
}
$query->order('id desc');
$total = $query->count();
$list = $query->page($page, $limit)->select();
// 处理时间格式
foreach ($list as $k => &$v) {
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s', $v['createTime']) : '';
$v['updateTime'] = !empty($v['updateTime']) ? date('Y-m-d H:i:s', $v['updateTime']) : '';
}
unset($v);
return ResponseHelper::success(['list' => $list, 'total' => $total]);
}
public function aiAnnouncement()
{
$userId = $this->getUserInfo('id');

View File

@@ -19,7 +19,7 @@ return [
'message:friendsList' => 'app\command\MessageFriendsListCommand', // 微信好友消息列表 √
'message:chatroomList' => 'app\command\MessageChatroomListCommand', // 微信群聊消息列表 √
'department:list' => 'app\command\DepartmentListCommand', // 部门列表 √
'content:sync' => 'app\command\SyncContentCommand', // 同步内容库
'content:sync' => 'app\command\SyncContentCommand', // 同步内容库 XXXXXXXX
'groupFriends:list' => 'app\command\GroupFriendsCommand', // 微信群好友列表
// 'allotFriends:run' => 'app\command\AllotFriendCommand', // 自动分配微信好友
// 'allotChatroom:run' => 'app\command\AllotChatroomCommand', // 自动分配微信群聊

View File

@@ -93,27 +93,33 @@ Route::group('v1/', function () {
// 工作台相关
Route::group('workbench', function () {
Route::post('create', 'app\cunkebao\controller\WorkbenchController@create'); // 创建工作台
Route::get('list', 'app\cunkebao\controller\WorkbenchController@getList'); // 获取工作台列表
Route::post('update-status', 'app\cunkebao\controller\WorkbenchController@updateStatus'); // 更新工作台状态
Route::delete('delete', 'app\cunkebao\controller\WorkbenchController@delete'); // 删除工作台
Route::post('copy', 'app\cunkebao\controller\WorkbenchController@copy'); // 拷贝工作台
Route::get('detail', 'app\cunkebao\controller\WorkbenchController@detail'); // 获取工作台详情
Route::post('update', 'app\cunkebao\controller\WorkbenchController@update'); // 更新工作台
Route::get('like-records', 'app\cunkebao\controller\WorkbenchController@getLikeRecords'); // 获取点赞记录列表
Route::get('moments-records', 'app\cunkebao\controller\WorkbenchController@getMomentsRecords'); // 获取朋友圈发布记录列表
Route::get('device-labels', 'app\cunkebao\controller\WorkbenchController@getDeviceLabels'); // 获取设备微信好友标签统计
Route::get('group-list', 'app\cunkebao\controller\WorkbenchController@getGroupList'); // 获取群列表
Route::get('account-list', 'app\cunkebao\controller\WorkbenchController@getAccountList'); // 获取账号列表
Route::get('transfer-friends', 'app\cunkebao\controller\WorkbenchController@getTrafficList'); // 获取账号列表
Route::get('import-contact', 'app\cunkebao\controller\WorkbenchController@getImportContact'); // 获取通讯录导入记录列表
Route::post('create', 'app\cunkebao\controller\workbench\WorkbenchController@create'); // 创建工作台
Route::get('list', 'app\cunkebao\controller\workbench\WorkbenchController@getList'); // 获取工作台列表
Route::post('update-status', 'app\cunkebao\controller\workbench\WorkbenchController@updateStatus'); // 更新工作台状态
Route::delete('delete', 'app\cunkebao\controller\workbench\WorkbenchController@delete'); // 删除工作台
Route::post('copy', 'app\cunkebao\controller\workbench\WorkbenchController@copy'); // 拷贝工作台
Route::get('detail', 'app\cunkebao\controller\workbench\WorkbenchController@detail'); // 获取工作台详情
Route::post('update', 'app\cunkebao\controller\workbench\WorkbenchController@update'); // 更新工作台
Route::get('like-records', 'app\cunkebao\controller\workbench\WorkbenchController@getLikeRecords'); // 获取点赞记录列表
Route::get('moments-records', 'app\cunkebao\controller\workbench\WorkbenchController@getMomentsRecords'); // 获取朋友圈发布记录列表
Route::get('device-labels', 'app\cunkebao\controller\workbench\WorkbenchController@getDeviceLabels'); // 获取设备微信好友标签统计
Route::get('group-list', 'app\cunkebao\controller\workbench\WorkbenchController@getGroupList'); // 获取群列表
Route::get('created-groups-list', 'app\cunkebao\controller\workbench\WorkbenchController@getCreatedGroupsList'); // 获取已创建的群列表(自动建群)
Route::get('created-group-detail', 'app\cunkebao\controller\workbench\WorkbenchController@getCreatedGroupDetail'); // 获取已创建群的详情(自动建群)
Route::post('sync-group-info', 'app\cunkebao\controller\workbench\WorkbenchController@syncGroupInfo'); // 同步群最新信息(包括群成员)
Route::post('modify-group-info', 'app\cunkebao\controller\workbench\WorkbenchController@modifyGroupInfo'); // 修改群名称、群公告
Route::post('quit-group', 'app\cunkebao\controller\workbench\WorkbenchController@quitGroup'); // 退群(自动建群)
Route::get('account-list', 'app\cunkebao\controller\workbench\WorkbenchController@getAccountList'); // 获取账号列表
Route::get('transfer-friends', 'app\cunkebao\controller\workbench\WorkbenchController@getTrafficList'); // 获取账号列表
Route::get('import-contact', 'app\cunkebao\controller\workbench\WorkbenchController@getImportContact'); // 获取通讯录导入记录列表
Route::get('getJdSocialMedia', 'app\cunkebao\controller\WorkbenchController@getJdSocialMedia'); // 获取京东联盟导购媒体
Route::get('getJdPromotionSite', 'app\cunkebao\controller\WorkbenchController@getJdPromotionSite'); // 获取京东联盟广告位
Route::get('changeLink', 'app\cunkebao\controller\WorkbenchController@changeLink'); // 获取京东联盟广告位
Route::get('getJdSocialMedia', 'app\cunkebao\controller\workbench\WorkbenchController@getJdSocialMedia'); // 获取京东联盟导购媒体
Route::get('getJdPromotionSite', 'app\cunkebao\controller\workbench\WorkbenchController@getJdPromotionSite'); // 获取京东联盟广告位
Route::get('changeLink', 'app\cunkebao\controller\workbench\WorkbenchController@changeLink'); // 获取京东联盟广告位
Route::get('group-push-stats', 'app\cunkebao\controller\WorkbenchController@getGroupPushStats'); // 获取群发统计数据
Route::get('group-push-history', 'app\cunkebao\controller\WorkbenchController@getGroupPushHistory'); // 获取推送历史记录列表
Route::get('group-push-stats', 'app\cunkebao\controller\workbench\WorkbenchController@getGroupPushStats'); // 获取群发统计数据
Route::get('group-push-history', 'app\cunkebao\controller\workbench\WorkbenchController@getGroupPushHistory'); // 获取推送历史记录列表
Route::get('common-functions', 'app\cunkebao\controller\workbench\CommonFunctionsController@getList'); // 获取常用功能列表
});
// 内容库相关

View File

@@ -95,7 +95,7 @@ class ContentLibraryController extends Controller
// 来源类型
'sourceType' => $sourceType, // 1=好友2=群3=好友和群
// 表单类型
'formType' => isset($param['formType']) ? intval($param['formType']) : 0, // 表单类型默认为0
'formType' => isset($param['formType']) ? intval($param['formType']) : 1, // 表单类型默认为0
// 基础信息
'status' => isset($param['status']) ? $param['status'] : 0, // 状态0=禁用1=启用
'userId' => $this->request->userInfo['id'],
@@ -131,7 +131,7 @@ class ContentLibraryController extends Controller
$limit = $this->request->param('limit', 10);
$keyword = $this->request->param('keyword', '');
$sourceType = $this->request->param('sourceType', ''); // 来源类型1=好友2=群
$formType = $this->request->param('formType', ''); // 表单类型筛选
$formType = $this->request->param('formType', 0); // 表单类型筛选
$companyId = $this->request->userInfo['companyId'];
$userId = $this->request->userInfo['id'];
$isAdmin = !empty($this->request->userInfo['isAdmin']);
@@ -182,18 +182,18 @@ class ContentLibraryController extends Controller
foreach ($list as $item) {
$libraryIds[] = $item['id'];
// 解析JSON字段
$item['sourceFriends'] = json_decode($item['sourceFriends'] ?: '[]', true);
$item['sourceGroups'] = json_decode($item['sourceGroups'] ?: '[]', true);
$item['keywordInclude'] = json_decode($item['keywordInclude'] ?: '[]', true);
$item['keywordExclude'] = json_decode($item['keywordExclude'] ?: '[]', true);
// 收集好友和群组ID
if (!empty($item['sourceFriends']) && $item['sourceType'] == 1) {
$friendIdsByLibrary[$item['id']] = $item['sourceFriends'];
}
if (!empty($item['sourceGroups']) && $item['sourceType'] == 2) {
$groupIdsByLibrary[$item['id']] = $item['sourceGroups'];
}
@@ -208,12 +208,12 @@ class ContentLibraryController extends Controller
->where('isDel', 0)
->group('libraryId')
->select();
foreach ($counts as $count) {
$itemCounts[$count['libraryId']] = $count['count'];
}
}
// 批量查询好友信息
$friendsInfoMap = [];
$allFriendIds = [];
@@ -222,7 +222,7 @@ class ContentLibraryController extends Controller
$allFriendIds = array_merge($allFriendIds, $friendIds);
}
}
if (!empty($allFriendIds)) {
$allFriendIds = array_unique($allFriendIds);
$friendsInfo = Db::name('wechat_friendship')->alias('wf')
@@ -230,12 +230,12 @@ class ContentLibraryController extends Controller
->join('wechat_account wa', 'wf.wechatId = wa.wechatId')
->whereIn('wf.id', $allFriendIds)
->select();
foreach ($friendsInfo as $friend) {
$friendsInfoMap[$friend['id']] = $friend;
}
}
// 批量查询群组信息
$groupsInfoMap = [];
$allGroupIds = [];
@@ -244,14 +244,14 @@ class ContentLibraryController extends Controller
$allGroupIds = array_merge($allGroupIds, $groupIds);
}
}
if (!empty($allGroupIds)) {
$allGroupIds = array_unique($allGroupIds);
$groupsInfo = Db::name('wechat_group')->alias('g')
->field('g.id, g.chatroomId, g.name, g.avatar, g.ownerWechatId')
->whereIn('g.id', $allGroupIds)
->select();
foreach ($groupsInfo as $group) {
$groupsInfoMap[$group['id']] = $group;
}
@@ -261,10 +261,10 @@ class ContentLibraryController extends Controller
foreach ($list as &$item) {
// 添加创建人名称
$item['creatorName'] = $item['user']['username'] ?? '';
// 添加内容项数量
$item['itemCount'] = $itemCounts[$item['id']] ?? 0;
// 处理好友信息
if (!empty($friendIdsByLibrary[$item['id']])) {
$selectedFriends = [];
@@ -275,7 +275,7 @@ class ContentLibraryController extends Controller
}
$item['selectedFriends'] = $selectedFriends;
}
// 处理群组信息
if (!empty($groupIdsByLibrary[$item['id']])) {
$selectedGroups = [];
@@ -286,7 +286,7 @@ class ContentLibraryController extends Controller
}
$item['selectedGroups'] = $selectedGroups;
}
unset($item['user']); // 移除关联数据
}
@@ -311,7 +311,7 @@ class ContentLibraryController extends Controller
$companyId = $this->request->userInfo['companyId'];
$userId = $this->request->userInfo['id'];
$isAdmin = !empty($this->request->userInfo['isAdmin']);
if (empty($id)) {
return json(['code' => 400, 'msg' => '参数错误']);
}
@@ -344,7 +344,7 @@ class ContentLibraryController extends Controller
$library['groupMembers'] = json_decode($library['groupMembers'] ?: [], true);
$library['catchType'] = json_decode($library['catchType'] ?: [], true);
$library['deviceGroups'] = json_decode($library['devices'] ?: [], true);
unset($library['sourceFriends'], $library['sourceGroups'],$library['devices']);
unset($library['sourceFriends'], $library['sourceGroups'], $library['devices']);
// 将时间戳转换为日期格式(精确到日)
if (!empty($library['timeStart'])) {
@@ -357,7 +357,8 @@ class ContentLibraryController extends Controller
// 初始化选项数组
$library['friendsGroupsOptions'] = [];
$library['wechatGroupsOptions'] = [];
$library['groupMembersOptions'] = [];
// 批量查询好友信息
if (!empty($library['friendsGroups'])) {
$friendIds = $library['friendsGroups'];
@@ -385,6 +386,61 @@ class ContentLibraryController extends Controller
}
}
// 批量查询群成员信息
if (!empty($library['groupMembers'])) {
// groupMembers格式: {"826825": ["413771", "413769"], "840818": ["496300", "496302"]}
// 键是群组ID值是成员ID数组
$allMemberIds = [];
$groupMembersMap = [];
if (is_array($library['groupMembers'])) {
foreach ($library['groupMembers'] as $groupId => $memberIds) {
if (is_array($memberIds) && !empty($memberIds)) {
$allMemberIds = array_merge($allMemberIds, $memberIds);
// 保存群组ID和成员ID的映射关系
$groupMembersMap[$groupId] = $memberIds;
}
}
}
if (!empty($allMemberIds)) {
// 去重
$allMemberIds = array_unique($allMemberIds);
// 查询群成员信息
$members = Db::table('s2_wechat_chatroom_member')
->field('id, chatroomId, wechatId, nickname, avatar, conRemark, alias, friendType, createTime, updateTime')
->whereIn('id', $allMemberIds)
->select();
// 将成员数据按ID建立索引
$membersById = [];
foreach ($members as $member) {
// 格式化时间字段
$member['createTime'] = !empty($member['createTime']) ? date('Y-m-d H:i:s', $member['createTime']) : '';
$member['updateTime'] = !empty($member['updateTime']) ? date('Y-m-d H:i:s', $member['updateTime']) : '';
$membersById[$member['id']] = $member;
}
// 按照群组ID分组返回
$groupMembersOptions = [];
foreach ($groupMembersMap as $groupId => $memberIds) {
$groupMembersOptions[$groupId] = [];
foreach ($memberIds as $memberId) {
if (isset($membersById[$memberId])) {
$groupMembersOptions[$groupId][] = $membersById[$memberId];
}
}
}
$library['groupMembersOptions'] = $groupMembersOptions;
} else {
$library['groupMembersOptions'] = [];
}
} else {
$library['groupMembersOptions'] = [];
}
//获取设备信息
if (!empty($library['deviceGroups'])) {
$deviceList = DeviceModel::alias('d')
@@ -410,7 +466,6 @@ class ContentLibraryController extends Controller
}
return json([
'code' => 200,
'msg' => '获取成功',
@@ -560,7 +615,7 @@ class ContentLibraryController extends Controller
['isDel', '=', 0]
];
if (!$isAdmin) {
$libraryWhere[] = ['userId', '=', $userId];
}
@@ -588,7 +643,7 @@ class ContentLibraryController extends Controller
// 查询数据
$list = ContentItem::where($where)
->field('id,libraryId,type,title,content,contentAi,contentType,resUrls,urls,friendId,wechatId,wechatChatroomId,createTime,createMomentTime,createMessageTime,coverImage,ossUrls')
->order('createMomentTime DESC,createTime DESC')
->order('createMomentTime DESC,createMessageTime DESC,createTime DESC')
->page($page, $limit)
->select();
@@ -596,7 +651,7 @@ class ContentLibraryController extends Controller
$friendIds = [];
$chatroomIds = [];
$wechatIds = [];
foreach ($list as $item) {
if ($item['type'] == 'moment' && !empty($item['friendId'])) {
$friendIds[] = $item['friendId'];
@@ -607,7 +662,7 @@ class ContentLibraryController extends Controller
}
}
}
// 批量查询好友信息
$friendInfoMap = [];
if (!empty($friendIds)) {
@@ -616,12 +671,12 @@ class ContentLibraryController extends Controller
->whereIn('id', $friendIds)
->field('id, nickname, avatar')
->select();
foreach ($friendInfos as $info) {
$friendInfoMap[$info['id']] = $info;
}
}
// 批量查询群成员信息
$memberInfoMap = [];
if (!empty($wechatIds)) {
@@ -630,7 +685,7 @@ class ContentLibraryController extends Controller
->whereIn('wechatId', $wechatIds)
->field('wechatId, nickname, avatar')
->select();
foreach ($memberInfos as $info) {
$memberInfoMap[$info['wechatId']] = $info;
}
@@ -641,14 +696,14 @@ class ContentLibraryController extends Controller
foreach ($list as &$item) {
// 使用AI内容如果有
$item['content'] = !empty($item['contentAi']) ? $item['contentAi'] : $item['content'];
// 处理JSON字段
$item['resUrls'] = json_decode($item['resUrls'] ?: [], true);
$item['urls'] = json_decode($item['urls'] ?: [], true);
$item['ossUrls'] = json_decode($item['ossUrls'] ?: [], true);
if(!empty($item['ossUrls']) && count($item['ossUrls']) > 0){
$item['resUrls'] = $item['ossUrls'];
if (!empty($item['ossUrls']) && count($item['ossUrls']) > 0) {
$item['resUrls'] = $item['ossUrls'];
}
@@ -666,7 +721,7 @@ class ContentLibraryController extends Controller
// 设置发送者信息
$item['senderNickname'] = '';
$item['senderAvatar'] = '';
// 从映射表获取发送者信息
if ($item['type'] == 'moment' && !empty($item['friendId'])) {
if (isset($friendInfoMap[$item['friendId']])) {
@@ -682,7 +737,7 @@ class ContentLibraryController extends Controller
}
}
unset($item['contentAi'],$item['ossUrls']);
unset($item['contentAi'], $item['ossUrls']);
}
return json([
@@ -793,8 +848,8 @@ class ContentLibraryController extends Controller
['l.companyId', '=', $this->request->userInfo['companyId']]
];
if(empty($this->request->userInfo['isAdmin'])){
$where[] = ['l.userId', '=', $this->request->userInfo['id']];
if (empty($this->request->userInfo['isAdmin'])) {
$where[] = ['l.userId', '=', $this->request->userInfo['id']];
}
// 查询内容项目是否存在并检查权限
@@ -804,7 +859,7 @@ class ContentLibraryController extends Controller
->find();
if(empty($item)) {
if (empty($item)) {
return json(['code' => 500, 'msg' => '内容项目不存在或无权限操作']);
}
@@ -831,7 +886,7 @@ class ContentLibraryController extends Controller
$id = $this->request->param('id', 0);
$userId = $this->request->userInfo['id'];
$isAdmin = !empty($this->request->userInfo['isAdmin']);
if (empty($id)) {
return json(['code' => 400, 'msg' => '参数错误']);
}
@@ -841,7 +896,7 @@ class ContentLibraryController extends Controller
['i.id', '=', $id],
['i.isDel', '=', 0]
];
// 非管理员只能查看自己的内容项
if (!$isAdmin) {
$where[] = ['l.userId', '=', $userId];
@@ -895,7 +950,7 @@ class ContentLibraryController extends Controller
// 初始化发送者和群组信息
$item['senderInfo'] = [];
$item['groupInfo'] = [];
// 批量获取关联信息
if ($item['type'] == 'moment' && !empty($item['friendId'])) {
// 获取朋友圈发送者信息
@@ -905,7 +960,7 @@ class ContentLibraryController extends Controller
->where('wf.id', $item['friendId'])
->field('wf.id, wf.wechatId, wa.nickname, wa.avatar')
->find();
if ($friendInfo) {
$item['senderInfo'] = $friendInfo;
}
@@ -915,20 +970,20 @@ class ContentLibraryController extends Controller
->where('id', $item['wechatChatroomId'])
->field('id, chatroomId, name, avatar, ownerWechatId')
->find();
if ($groupInfo) {
$item['groupInfo'] = $groupInfo;
// 如果有发送者信息,也获取发送者详情
if (!empty($item['wechatId'])) {
$senderInfo = Db::name('wechat_chatroom_member')
$senderInfo = Db::table('s2_wechat_chatroom_member')
->where([
'chatroomId' => $groupInfo['chatroomId'],
'wechatId' => $item['wechatId']
])
->field('wechatId, nickname, avatar')
->find();
if ($senderInfo) {
$item['senderInfo'] = $senderInfo;
}
@@ -1131,9 +1186,9 @@ class ContentLibraryController extends Controller
->select()->toArray();
if (empty($libraries)) {
return json_encode(['code' => 200, 'msg' => '没有可用的内容库配置'],256);
return json_encode(['code' => 200, 'msg' => '没有可用的内容库配置'], 256);
}
$successCount = 0;
$failCount = 0;
$results = [];
@@ -1156,13 +1211,13 @@ class ContentLibraryController extends Controller
foreach ($libraries as $library) {
try {
$processedLibraries++;
// 根据数据来源类型执行不同的采集逻辑
$collectResult = [
'status' => 'skipped',
'message' => '没有配置数据来源'
];
switch ($library['sourceType']) {
case 1: // 好友类型
if (!empty($library['sourceFriends'])) {
@@ -1213,7 +1268,7 @@ class ContentLibraryController extends Controller
'status' => 'error',
'message' => $e->getMessage()
];
// 记录错误日志
\think\facade\Log::error('内容库采集错误: ' . $e->getMessage() . ' [库ID: ' . $library['id'] . ']');
}
@@ -1230,7 +1285,7 @@ class ContentLibraryController extends Controller
'skipped' => $totalLibraries - $successCount - $failCount,
'results' => $results
]
],256);
], 256);
}
/**
@@ -1247,14 +1302,14 @@ class ContentLibraryController extends Controller
'message' => '没有指定要采集的好友'
];
}
try {
// 获取API配置
$toAccountId = '';
$username = Env::get('api.username2', '');
$password = Env::get('api.password2', '');
$needFetch = false;
// 检查是否需要主动获取朋友圈
if (!empty($username) && !empty($password)) {
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
@@ -1280,10 +1335,10 @@ class ContentLibraryController extends Controller
$totalMomentsCount = 0;
$processedFriends = 0;
$totalFriends = count($friends);
// 获取采集类型限制
$catchTypes = $library['catchType'] ?? [];
foreach ($friends as $friend) {
$processedFriends++;
@@ -1293,16 +1348,16 @@ class ContentLibraryController extends Controller
// 执行切换好友命令
$automaticAssign = new AutomaticAssign();
$automaticAssign->allotWechatFriend(['wechatFriendId' => $friend['id'], 'toAccountId' => $toAccountId], true);
// 存入缓存
$friendData = $friend;
$friendData['friendId'] = $friend['id'];
artificialAllotWechatFriend($friendData);
// 执行采集朋友圈命令
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
$webSocket->getMoments(['wechatFriendId' => $friend['id'], 'wechatAccountId' => $friend['wechatAccountId']]);
// 采集完毕切换回原账号
$automaticAssign->allotWechatFriend(['wechatFriendId' => $friend['id'], 'toAccountId' => $friend['accountId']], true);
} catch (\Exception $e) {
@@ -1319,17 +1374,17 @@ class ContentLibraryController extends Controller
])
->order('createTime', 'desc')
->group('snsId');
// 如果启用了时间限制
if ($library['timeEnabled'] && $library['timeStart'] > 0 && $library['timeEnd'] > 0) {
$query->whereBetween('createTime', [$library['timeStart'], $library['timeEnd']]);
}
// 如果指定了采集类型,进行过滤
/*if (!empty($catchTypes)) {
$query->whereIn('type', $catchTypes);
}*/
// 获取最近20条朋友圈
$moments = $query->page(1, 100)->select();
if (empty($moments)) {
@@ -1344,24 +1399,24 @@ class ContentLibraryController extends Controller
foreach ($moments as $moment) {
// 处理关键词过滤
$content = $moment['content'] ?? '';
// 应用关键词过滤
if (!$this->passKeywordFilter($content, $library['keywordInclude'], $library['keywordExclude'])) {
continue;
}
/* // 如果启用了AI处理
if (!empty($library['aiEnabled']) && !empty($content)) {
try {
$contentAi = $this->aiRewrite($library, $content);
if (!empty($contentAi)) {
$moment['contentAi'] = $contentAi;
}
} catch (\Exception $e) {
\think\facade\Log::error('AI处理失败: ' . $e->getMessage() . ' [朋友圈ID: ' . ($moment['id'] ?? 'unknown') . ']');
$moment['contentAi'] = '';
}
}*/
/* // 如果启用了AI处理
if (!empty($library['aiEnabled']) && !empty($content)) {
try {
$contentAi = $this->aiRewrite($library, $content);
if (!empty($contentAi)) {
$moment['contentAi'] = $contentAi;
}
} catch (\Exception $e) {
\think\facade\Log::error('AI处理失败: ' . $e->getMessage() . ' [朋友圈ID: ' . ($moment['id'] ?? 'unknown') . ']');
$moment['contentAi'] = '';
}
}*/
// 保存到内容库的content_item表
if ($this->saveMomentToContentItem($moment, $library['id'], $friend, $nickname)) {
@@ -1385,7 +1440,7 @@ class ContentLibraryController extends Controller
$totalMomentsCount += $friendMomentsCount;
}
// 每处理5个好友释放一次内存
if ($processedFriends % 5 == 0 && $processedFriends < $totalFriends) {
gc_collect_cycles();
@@ -1417,7 +1472,7 @@ class ContentLibraryController extends Controller
];
}
}
/**
* 应用关键词过滤规则
* @param string $content 内容文本
@@ -1431,7 +1486,7 @@ class ContentLibraryController extends Controller
if (empty($content)) {
return false;
}
// 检查是否包含必须关键词
$includeMatch = empty($includeKeywords);
if (!empty($includeKeywords)) {
@@ -1456,7 +1511,7 @@ class ContentLibraryController extends Controller
}
}
}
return true; // 通过所有过滤条件
}
@@ -1477,8 +1532,8 @@ class ContentLibraryController extends Controller
try {
// 查询群组信息
$groups = Db::name('wechat_group')->alias('g')
->field('g.id, g.chatroomId, g.name, g.ownerWechatId')
$groups = Db::table('s2_wechat_chatroom')->alias('g')
->field('g.id, g.chatroomId, g.nickname as name, g.wechatAccountWechatId as ownerWechatId')
->whereIn('g.id', $groupIds)
->where('g.deleteTime', 0)
->select();
@@ -1500,12 +1555,59 @@ class ContentLibraryController extends Controller
];
}
// groupMembers格式: {"826825": ["413771", "413769"], "840818": ["496300", "496302"]}
// 键是群组ID值是该群组的成员ID数组
// 需要按群组分组处理,确保每个群组只采集该群组配置的成员
// 建立群组ID到成员ID数组的映射
$groupIdToMemberIds = [];
if (is_array($groupMembers)) {
foreach ($groupMembers as $groupId => $memberIds) {
if (is_array($memberIds) && !empty($memberIds)) {
$groupIdToMemberIds[$groupId] = $memberIds;
}
}
}
if (empty($groupIdToMemberIds)) {
return [
'status' => 'failed',
'message' => '未找到有效的群成员ID'
];
}
// 为每个群组查询成员信息建立群组ID到成员wechatId数组的映射
$groupIdToMemberWechatIds = [];
foreach ($groupIdToMemberIds as $groupId => $memberIds) {
// 查询该群组的成员信息获取wechatId
$members = Db::table('s2_wechat_chatroom_member')
->field('id, wechatId')
->whereIn('id', $memberIds)
->select();
$wechatIds = [];
foreach ($members as $member) {
if (!empty($member['wechatId'])) {
$wechatIds[] = $member['wechatId'];
}
}
if (!empty($wechatIds)) {
$groupIdToMemberWechatIds[$groupId] = array_unique($wechatIds);
}
}
if (empty($groupIdToMemberWechatIds)) {
return [
'status' => 'failed',
'message' => '未找到有效的群成员微信ID'
];
}
// 从群组采集内容
$collectedData = [];
$totalMessagesCount = 0;
$chatroomIds = array_column($groups, 'id');
// 获取群消息 - 支持时间范围过滤
// 获取群消息 - 支持时间范围过滤(先不添加群成员过滤,后面按群组分别过滤)
$messageWhere = [
['wechatChatroomId', 'in', $chatroomIds],
['type', '=', 2]
@@ -1516,7 +1618,7 @@ class ContentLibraryController extends Controller
$messageWhere[] = ['createTime', 'between', [$library['timeStart'], $library['timeEnd']]];
}
// 查询群消息
// 查询群消息(先查询所有消息,后面按群组和成员过滤)
$groupMessages = Db::table('s2_wechat_message')
->where($messageWhere)
->order('createTime', 'desc')
@@ -1532,6 +1634,34 @@ class ContentLibraryController extends Controller
$groupedMessages = [];
foreach ($groupMessages as $message) {
$chatroomId = $message['wechatChatroomId'];
$senderWechatId = $message['senderWechatId'] ?? '';
// 找到对应的群组信息
$groupInfo = null;
foreach ($groups as $group) {
if ($group['id'] == $chatroomId) {
$groupInfo = $group;
break;
}
}
if (!$groupInfo) {
continue;
}
// 检查该消息的发送者是否在该群组的配置成员列表中
$groupId = $groupInfo['id'];
if (!isset($groupIdToMemberWechatIds[$groupId])) {
// 该群组没有配置成员,跳过
continue;
}
// 检查发送者是否在配置的成员列表中
if (!in_array($senderWechatId, $groupIdToMemberWechatIds[$groupId])) {
// 发送者不在该群组的配置成员列表中,跳过
continue;
}
if (!isset($groupedMessages[$chatroomId])) {
$groupedMessages[$chatroomId] = [
'count' => 0,
@@ -1579,27 +1709,14 @@ class ContentLibraryController extends Controller
continue;
}
// 找到对应的群组信息
$groupInfo = null;
foreach ($groups as $group) {
if ($group['id'] == $chatroomId) {
$groupInfo = $group;
break;
}
}
if (!$groupInfo) {
continue;
}
// 如果启用了AI处理
if (!empty($library['aiEnabled']) && !empty($content)) {
$contentAi = $this->aiRewrite($library, $content);
if (!empty($content)) {
$moment['contentAi'] = $contentAi;
if (!empty($contentAi)) {
$message['contentAi'] = $contentAi;
} else {
$moment['contentAi'] = '';
$message['contentAi'] = '';
}
}
@@ -1907,7 +2024,7 @@ class ContentLibraryController extends Controller
// 如果不存在,则创建新的内容项目
if(empty($exists)){
if (empty($exists)) {
$exists = new ContentItem();
}
@@ -1968,32 +2085,57 @@ class ContentLibraryController extends Controller
return true;
}
// 提取消息内容中的链接
$content = $message['content'] ?? '';
$links = [];
$pattern = '/https?:\/\/[-A-Za-z0-9+&@#\/%?=~_|!:,.;]+[-A-Za-z0-9+&@#\/%=~_|]/';
preg_match_all($pattern, $content, $matches);
if (!empty($matches[0])) {
$links = $matches[0];
}
// 提取可能的图片URL
$resUrls = [];
if (isset($message['imageUrl']) && !empty($message['imageUrl'])) {
$resUrls[] = $message['imageUrl'];
$links = [];
$contentType = 4;
$content = '';
switch ($message['msgType']) {
case 1: // 文字
$content = $message['content'];
$contentType = 4;
break;
case 3: //图片
$resUrls[] = $message['content'];
$contentType = 1;
break;
case 47: //动态图片
$resUrls[] = $message['content'];
$contentType = 1;
break;
case 34: //语言
return false;
case 43: //视频
$resUrls[] = $message['content'];
$contentType = 3;
break;
case 42: //名片
return false;
case 49: //文件 链接
$link = json_decode($message['content'], true);
switch ($link['type']) {
case 'link':
$links[] = [
'desc' => $link['desc'],
'image' => $link['thumbPath'],
'url' => $link['url'],
];
$contentType = 2;
break;
default:
return false;
}
break;
default:
return false;
}
// 判断内容类型 (0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文)
$contentType = $this->determineContentType($content, $resUrls, $links);
// 创建新的内容项目
$item = new ContentItem();
$item->libraryId = $libraryId;
$item->type = 'group_message'; // 群消息类型
$item->title = '来自 ' . ($group['name'] ?? '未知群组') . ' 的消息';
$item->contentData = json_encode($message, JSON_UNESCAPED_UNICODE);
$item->msgId = $message['msgId'] ?? ''; // 存储msgId便于后续查询
$item->msgId = $message['msgSvrId'] ?? ''; // 存储msgSvrId便于后续查询
$item->createTime = time();
$item->content = $content;
$item->contentType = $contentType; // 设置内容类型
@@ -2011,12 +2153,17 @@ class ContentLibraryController extends Controller
if (!empty($resUrls[0])) {
$item->coverImage = $resUrls[0];
}
} else {
$item->resUrls = json_encode([], JSON_UNESCAPED_UNICODE);
}
// 处理链接
if (!empty($links)) {
$item->urls = json_encode($links, JSON_UNESCAPED_UNICODE);
} else {
$item->urls = json_encode([], JSON_UNESCAPED_UNICODE);
}
$item->ossUrls = json_encode([], JSON_UNESCAPED_UNICODE);
// 设置商品信息(需根据消息内容解析)
$this->extractProductInfo($item, $content);
@@ -2438,7 +2585,7 @@ class ContentLibraryController extends Controller
['companyId', '=', $companyId],
['isDel', '=', 0]
];
if (!$isAdmin) {
$libraryWhere[] = ['userId', '=', $userId];
}
@@ -2468,13 +2615,13 @@ class ContentLibraryController extends Controller
// 下载远程文件到临时目录
$tmpFile = tempnam(sys_get_temp_dir(), 'excel_import_') . '.' . $urlExt;
$fileContent = @file_get_contents($fileUrl);
if ($fileContent === false) {
return json(['code' => 400, 'msg' => '下载远程文件失败请检查URL是否可访问']);
}
file_put_contents($tmpFile, $fileContent);
} elseif ($file) {
// 处理上传的文件
$ext = strtolower($file->getExtension());
@@ -2499,7 +2646,7 @@ class ContentLibraryController extends Controller
// 加载Excel文件
$excel = PHPExcel_IOFactory::load($tmpFile);
$sheet = $excel->getActiveSheet();
// 获取所有图片
$images = [];
try {
@@ -2508,14 +2655,14 @@ class ContentLibraryController extends Controller
if ($drawing instanceof \PHPExcel_Worksheet_Drawing) {
$coordinates = $drawing->getCoordinates();
$imagePath = $drawing->getPath();
// 如果是嵌入的图片zip://格式),提取到临时文件
if (strpos($imagePath, 'zip://') === 0) {
$zipEntry = str_replace('zip://', '', $imagePath);
$zipEntry = explode('#', $zipEntry);
$zipFile = $zipEntry[0];
$imageEntry = isset($zipEntry[1]) ? $zipEntry[1] : '';
if (!empty($imageEntry)) {
$zip = new \ZipArchive();
if ($zip->open($zipFile) === true) {
@@ -2536,11 +2683,11 @@ class ContentLibraryController extends Controller
// 处理内存中的图片
$coordinates = $drawing->getCoordinates();
$imageResource = $drawing->getImageResource();
if ($imageResource) {
$tempImageFile = tempnam(sys_get_temp_dir(), 'excel_img_') . '.png';
$imageType = $drawing->getMimeType();
switch ($imageType) {
case 'image/png':
imagepng($imageResource, $tempImageFile);
@@ -2555,7 +2702,7 @@ class ContentLibraryController extends Controller
default:
imagepng($imageResource, $tempImageFile);
}
$images[$coordinates] = $tempImageFile;
}
}
@@ -2582,7 +2729,7 @@ class ContentLibraryController extends Controller
try {
foreach ($data as $rowIndex => $row) {
$rowNum = $rowIndex + 3; // Excel行号从3开始因为前两行是标题和说明
// 跳过空行
if (empty(array_filter($row))) {
continue;
@@ -2597,7 +2744,7 @@ class ContentLibraryController extends Controller
$content = isset($row[3]) ? trim($row[3]) : '';
$selfReply = isset($row[4]) ? trim($row[4]) : '';
$displayForm = isset($row[5]) ? trim($row[5]) : '';
// 如果没有朋友圈文案,跳过
if (empty($content)) {
continue;
@@ -2608,11 +2755,11 @@ class ContentLibraryController extends Controller
for ($colIndex = 6; $colIndex <= 14; $colIndex++) {
$columnLetter = $this->columnLetter($colIndex);
$cellCoordinate = $columnLetter . $rowNum;
// 检查是否有图片
if (isset($images[$cellCoordinate])) {
$imagePath = $images[$cellCoordinate];
// 上传图片到OSS
$imageExt = 'jpg';
if (file_exists($imagePath)) {
@@ -2624,10 +2771,10 @@ class ContentLibraryController extends Controller
}
}
}
$objectName = AliyunOSS::generateObjectName('excel_img_' . $rowNum . '_' . ($colIndex - 5) . '.' . $imageExt);
$uploadResult = AliyunOSS::uploadFile($imagePath, $objectName);
if ($uploadResult['success']) {
$imageUrls[] = $uploadResult['url'];
}
@@ -2643,7 +2790,7 @@ class ContentLibraryController extends Controller
$year = $matches[1];
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$day = str_pad($matches[3], 2, '0', STR_PAD_LEFT);
// 解析时间
$hour = 0;
$minute = 0;
@@ -2651,11 +2798,11 @@ class ContentLibraryController extends Controller
$hour = intval($timeMatches[1]);
$minute = intval($timeMatches[2]);
}
$createMomentTime = strtotime("{$year}-{$month}-{$day} {$hour}:{$minute}:00");
}
}
if ($createMomentTime == 0) {
$createMomentTime = time();
}
@@ -2678,12 +2825,12 @@ class ContentLibraryController extends Controller
$item->urls = json_encode([], JSON_UNESCAPED_UNICODE);
$item->createMomentTime = $createMomentTime;
$item->createTime = time();
// 设置封面图片
if (!empty($imageUrls[0])) {
$item->coverImage = $imageUrls[0];
}
// 保存其他信息到contentData
$contentData = [
'date' => $date,
@@ -2693,7 +2840,7 @@ class ContentLibraryController extends Controller
'selfReply' => $selfReply
];
$item->contentData = json_encode($contentData, JSON_UNESCAPED_UNICODE);
$item->save();
$successCount++;
@@ -2730,7 +2877,7 @@ class ContentLibraryController extends Controller
} catch (\Exception $e) {
Db::rollback();
// 清理临时文件
foreach ($images as $imagePath) {
if (file_exists($imagePath) && strpos($imagePath, sys_get_temp_dir()) === 0) {
@@ -2740,7 +2887,7 @@ class ContentLibraryController extends Controller
if (file_exists($tmpFile) && strpos($tmpFile, sys_get_temp_dir()) === 0) {
@unlink($tmpFile);
}
return json(['code' => 500, 'msg' => '导入失败:' . $e->getMessage()]);
}

View File

@@ -308,7 +308,7 @@ class StatsController extends Controller
->field("FROM_UNIXTIME(addTime, '%m-%d') AS d, COUNT(*) AS c")
->where(['task_id' => $taskId])
->where('addTime', 'between', [$startTimestamp, $endTimestamp])
->whereIn('status', [1, 2, 4])
->whereIn('status', [1, 2, 4, 5])
->group('d')
->select();

View File

@@ -84,7 +84,7 @@ class StoreAccountController extends BaseController
'phone' => $phone,
'passwordMd5' => md5($password),
'passwordLocal' => localEncrypt($password),
'avatar' => 'https://img.icons8.com/color/512/circled-user-male-skin-type-7.png',
'avatar' => '',
'isAdmin' => 0,
'companyId' => $companyId,
'typeId' => 2, // 门店端固定为2

View File

@@ -39,10 +39,10 @@ class GetChatroomListV1Controller extends BaseController
$where = [];
if ($this->getUserInfo('isAdmin') == 1) {
$where[] = ['g.deleteTime', '=', 0];
$where[] = ['gg.isDeleted', '=', 0];
$where[] = ['g.ownerWechatId', 'in', $wechatIds];
} else {
$where[] = ['g.deleteTime', '=', 0];
$where[] = ['gg.isDeleted', '=', 0];
$where[] = ['g.ownerWechatId', 'in', $wechatIds];
//$where[] = ['g.userId', '=', $this->getUserInfo('id')];
}
@@ -55,6 +55,7 @@ class GetChatroomListV1Controller extends BaseController
->field(['g.id', 'g.chatroomId', 'g.name', 'g.avatar','g.ownerWechatId', 'g.identifier', 'g.createTime',
'wa.nickname as ownerNickname','wa.avatar as ownerAvatar','wa.alias as ownerAlias'])
->join('wechat_account wa', 'g.ownerWechatId = wa.wechatId', 'LEFT')
->join(['s2_wechat_chatroom' => 'gg'], 'g.id = gg.id', 'LEFT')
->where($where);
$total = $data->count();

View File

@@ -167,10 +167,9 @@ class GetDeviceListV1Controller extends BaseController
if ($this->getUserInfo('isAdmin') == UserModel::ADMIN_STP) {
$where = $this->makeWhere();
$result = $this->getDeviceList($where);
}
else {
$where = $this->makeWhere( $this->makeDeviceIdsWhere() );
}else {
//$where = $this->makeWhere( $this->makeDeviceIdsWhere() );
$where = $this->makeWhere();
$result = $this->getDeviceList($where);
}

View File

@@ -35,6 +35,7 @@ class ChannelController extends BaseController
$createType = $this->request->param('createType', DistributionChannel::CREATE_TYPE_MANUAL); // 默认为手动创建
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
// 参数验证
if (empty($name)) {
@@ -87,6 +88,7 @@ class ChannelController extends BaseController
// 准备插入数据
$data = [
'companyId' => $companyId,
'userId' => $userId,
'name' => $name,
'code' => $code,
'phone' => $phone ?: '',
@@ -122,6 +124,7 @@ class ChannelController extends BaseController
'phone' => $channel['phone'] ?: '',
'wechatId' => $channel['wechatId'] ?: '',
'companyId' => (int)$companyId, // 返回companyId方便小程序自动跳转
'userId' => (int)($channel['userId'] ?? 0),
'createType' => $channel['createType'],
'status' => $channel['status'],
'totalCustomers' => (int)$channel['totalCustomers'],
@@ -175,6 +178,11 @@ class ChannelController extends BaseController
$where[] = ['companyId', '=', $companyId];
$where[] = ['deleteTime', '=', 0];
// 如果不是管理员,只能查看自己创建的数据
if (!$this->getUserInfo('isAdmin')) {
$where[] = ['userId', '=', $this->getUserInfo('id')];
}
// 状态筛选
if ($status !== 'all') {
$where[] = ['status', '=', $status];
@@ -208,6 +216,8 @@ class ChannelController extends BaseController
'code' => $item['code'] ?? '',
'phone' => !empty($item['phone']) ? $item['phone'] : null,
'wechatId' => !empty($item['wechatId']) ? $item['wechatId'] : null,
'companyId' => (int)($item['companyId'] ?? 0),
'userId' => (int)($item['userId'] ?? 0),
'createType' => $item['createType'] ?? 'manual',
'status' => $item['status'] ?? 'enabled',
'totalCustomers' => (int)($item['totalCustomers'] ?? 0),
@@ -394,6 +404,8 @@ class ChannelController extends BaseController
'code' => $updatedChannel['code'],
'phone' => !empty($updatedChannel['phone']) ? $updatedChannel['phone'] : null,
'wechatId' => !empty($updatedChannel['wechatId']) ? $updatedChannel['wechatId'] : null,
'companyId' => (int)($updatedChannel['companyId'] ?? 0),
'userId' => (int)($updatedChannel['userId'] ?? 0),
'createType' => $updatedChannel['createType'],
'status' => $updatedChannel['status'],
'totalCustomers' => (int)$updatedChannel['totalCustomers'],
@@ -606,6 +618,11 @@ class ChannelController extends BaseController
['deleteTime', '=', 0]
];
// 如果不是管理员,只能查看自己创建的数据
if (!$this->getUserInfo('isAdmin')) {
$baseWhere[] = ['userId', '=', $this->getUserInfo('id')];
}
// 1. 总渠道数
$totalChannels = Db::name('distribution_channel')
->where($baseWhere)
@@ -667,6 +684,11 @@ class ChannelController extends BaseController
['companyId', '=', $companyId]
];
// 如果不是管理员,只能查看自己创建的提现申请
if (!$this->getUserInfo('isAdmin')) {
$baseWhere[] = ['userId', '=', $this->getUserInfo('id')];
}
// 1. 总支出所有已打款的提现申请金额总和状态为paid
$totalExpenditure = Db::name('distribution_withdrawal')
->where($baseWhere)
@@ -731,6 +753,11 @@ class ChannelController extends BaseController
$where[] = ['companyId', '=', $companyId];
$where[] = ['deleteTime', '=', 0];
// 如果不是管理员,只能查看自己创建的数据
if (!$this->getUserInfo('isAdmin')) {
$where[] = ['userId', '=', $this->getUserInfo('id')];
}
// 关键词搜索(模糊匹配 name、code
if (!empty($keyword)) {
$keyword = trim($keyword);
@@ -753,12 +780,20 @@ class ChannelController extends BaseController
$channelIds = array_column($channels, 'id');
$withdrawalStats = [];
if (!empty($channelIds)) {
// 按渠道ID和状态分组统计提现金额
$stats = Db::name('distribution_withdrawal')
->where([
// 构建提现查询条件
$withdrawalWhere = [
['companyId', '=', $companyId],
['channelId', 'in', $channelIds]
])
];
// 如果不是管理员,只能查看自己创建的提现申请
if (!$this->getUserInfo('isAdmin')) {
$withdrawalWhere[] = ['userId', '=', $this->getUserInfo('id')];
}
// 按渠道ID和状态分组统计提现金额
$stats = Db::name('distribution_withdrawal')
->where($withdrawalWhere)
->field([
'channelId',
'status',
@@ -772,16 +807,22 @@ class ChannelController extends BaseController
$cid = $stat['channelId'];
if (!isset($withdrawalStats[$cid])) {
$withdrawalStats[$cid] = [
'totalRevenue' => 0, // 所有状态的总金额
'totalRevenue' => 0, // 总收益(不包括驳回的)
'withdrawn' => 0, // 已打款paid
'pendingReview' => 0 // 待审核pending
];
}
$amount = intval($stat['totalAmount'] ?? 0);
$status = $stat['status'];
// totalRevenue 不包括驳回rejected状态的金额
if ($status !== DistributionWithdrawal::STATUS_REJECTED) {
$withdrawalStats[$cid]['totalRevenue'] += $amount;
if ($stat['status'] === DistributionWithdrawal::STATUS_PAID) {
}
if ($status === DistributionWithdrawal::STATUS_PAID) {
$withdrawalStats[$cid]['withdrawn'] += $amount;
} elseif ($stat['status'] === DistributionWithdrawal::STATUS_PENDING) {
} elseif ($status === DistributionWithdrawal::STATUS_PENDING) {
$withdrawalStats[$cid]['pendingReview'] += $amount;
}
}
@@ -844,6 +885,7 @@ class ChannelController extends BaseController
$type = $this->request->param('type', 'h5'); // h5 或 miniprogram
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
// 参数验证
if (!in_array($type, ['h5', 'miniprogram'])) {
@@ -855,10 +897,11 @@ class ChannelController extends BaseController
]);
}
// 生成临时token包含公司ID有效期24小时
// 生成临时token包含公司ID和用户ID有效期24小时
// 用户扫码后需要自己填写所有信息
$tokenData = [
'companyId' => $companyId,
'userId' => $userId,
'expireTime' => time() + 86400 // 24小时后过期
];
$token = base64_encode(json_encode($tokenData));
@@ -1218,6 +1261,7 @@ class ChannelController extends BaseController
}
$companyId = $tokenData['companyId'];
$userId = isset($tokenData['userId']) ? $tokenData['userId'] : 0; // 兼容旧token如果没有userId则默认为0
// GET请求返回token验证成功信息前端可以显示表单
if ($this->request->isGet()) {
@@ -1310,9 +1354,10 @@ class ChannelController extends BaseController
// 生成渠道编码
$code = DistributionChannel::generateChannelCode();
// 准备插入数据
// 准备插入数据从token中获取userId记录是哪个用户生成的二维码
$data = [
'companyId' => $companyId,
'userId' => $userId, // 从token中获取userId记录生成二维码的用户
'name' => $name,
'code' => $code,
'phone' => $phone ?: '',
@@ -1353,6 +1398,7 @@ class ChannelController extends BaseController
'phone' => $channel['phone'] ?: '',
'wechatId' => $channel['wechatId'] ?: '',
'companyId' => (int)$companyId, // 返回companyId方便小程序自动跳转
'userId' => (int)($channel['userId'] ?? 0),
'createType' => $channel['createType'],
'status' => $channel['status'],
'totalCustomers' => (int)$channel['totalCustomers'],

View File

@@ -235,7 +235,7 @@ class ChannelUserController extends Controller
// 2. 财务统计
// 当前可提现金额
$withdrawableAmount = $channel['withdrawableAmount'] ?? 0;
$withdrawableAmount = round(($channel['withdrawableAmount'] ?? 0) / 100, 2); // 分转元
// 已提现金额(已打款的提现申请)
$withdrawnAmount = Db::name('distribution_withdrawal')
@@ -245,7 +245,7 @@ class ChannelUserController extends Controller
['status', '=', DistributionWithdrawal::STATUS_PAID]
])
->sum('amount');
$withdrawnAmount = $withdrawnAmount ?? 0;
$withdrawnAmount = round(($withdrawnAmount ?? 0) / 100, 2); // 分转元
// 待审核金额(待审核的提现申请)
$pendingReviewAmount = Db::name('distribution_withdrawal')
@@ -255,7 +255,7 @@ class ChannelUserController extends Controller
['status', '=', DistributionWithdrawal::STATUS_PENDING]
])
->sum('amount');
$pendingReviewAmount = $pendingReviewAmount ?? 0;
$pendingReviewAmount = round(($pendingReviewAmount ?? 0) / 100, 2); // 分转元
// 总收益(所有收益记录的总和)
$totalRevenue = Db::name('distribution_revenue_record')
@@ -264,7 +264,7 @@ class ChannelUserController extends Controller
['channelId', '=', $channelId]
])
->sum('amount');
$totalRevenue = $totalRevenue ?? 0;
$totalRevenue = round(($totalRevenue ?? 0) / 100, 2); // 分转元
$financialStats = [
'withdrawableAmount' => $withdrawableAmount, // 当前可提现金额

View File

@@ -43,6 +43,11 @@ class WithdrawalController extends BaseController
$where = [];
$where[] = ['w.companyId', '=', $companyId];
// 如果不是管理员,只能查看自己创建的提现申请
if (!$this->getUserInfo('isAdmin')) {
$where[] = ['w.userId', '=', $this->getUserInfo('id')];
}
// 状态筛选
if ($status !== 'all') {
$where[] = ['w.status', '=', $status];
@@ -89,6 +94,7 @@ class WithdrawalController extends BaseController
$list = $query->field([
'w.id',
'w.channelId',
'w.userId',
'w.amount',
'w.status',
'w.payType',
@@ -123,6 +129,7 @@ class WithdrawalController extends BaseController
'channelId' => (string)$item['channelId'],
'channelName' => $item['channelName'] ?? '',
'channelCode' => $item['channelCode'] ?? '',
'userId' => (int)($item['userId'] ?? 0),
'amount' => round($item['amount'] / 100, 2), // 分转元保留2位小数
'status' => $item['status'] ?? DistributionWithdrawal::STATUS_PENDING,
'payType' => !empty($item['payType']) ? $item['payType'] : null, // 支付类型
@@ -220,6 +227,8 @@ class WithdrawalController extends BaseController
// 统一使用渠道ID变量后续逻辑仍然基于 channelId
$channelId = $channel['id'];
// 从渠道获取创建者的userId而不是当前登录用户的userId
$userId = intval($channel['userId'] ?? 0);
// 检查渠道状态
if ($channel['status'] !== 'enabled') {
@@ -271,6 +280,7 @@ class WithdrawalController extends BaseController
$withdrawalData = [
'companyId' => $companyId,
'channelId' => $channelId,
'userId' => $userId,
'amount' => $amountInFen, // 存储为分
'status' => DistributionWithdrawal::STATUS_PENDING,
'applyTime' => time(),
@@ -300,6 +310,7 @@ class WithdrawalController extends BaseController
->field([
'w.id',
'w.channelId',
'w.userId',
'w.amount',
'w.status',
'w.payType',
@@ -315,6 +326,7 @@ class WithdrawalController extends BaseController
'channelId' => (string)$withdrawal['channelId'],
'channelName' => $withdrawal['channelName'] ?? '',
'channelCode' => $withdrawal['channelCode'] ?? '',
'userId' => (int)($withdrawal['userId'] ?? 0),
'amount' => round($withdrawal['amount'] / 100, 2), // 分转元保留2位小数
'status' => $withdrawal['status'],
'payType' => !empty($withdrawal['payType']) ? $withdrawal['payType'] : null, // 支付类型wechat、alipay、bankcard创建时为null
@@ -603,17 +615,26 @@ class WithdrawalController extends BaseController
]);
}
// 构建查询条件
$where = [
['w.id', '=', $id],
['w.companyId', '=', $companyId]
];
// 如果不是管理员,只能查看自己创建的提现申请
if (!$this->getUserInfo('isAdmin')) {
$where[] = ['w.userId', '=', $this->getUserInfo('id')];
}
// 查询申请详情(关联渠道表)
$withdrawal = Db::name('distribution_withdrawal')
->alias('w')
->join('distribution_channel c', 'w.channelId = c.id AND c.deleteTime = 0', 'left')
->where([
['w.id', '=', $id],
['w.companyId', '=', $companyId]
])
->where($where)
->field([
'w.id',
'w.channelId',
'w.userId',
'w.amount',
'w.status',
'w.payType',
@@ -641,6 +662,7 @@ class WithdrawalController extends BaseController
'channelId' => (string)$withdrawal['channelId'],
'channelName' => $withdrawal['channelName'] ?? '',
'channelCode' => $withdrawal['channelCode'] ?? '',
'userId' => (int)($withdrawal['userId'] ?? 0),
'amount' => round($withdrawal['amount'] / 100, 2), // 分转元保留2位小数
'status' => $withdrawal['status'],
'payType' => !empty($withdrawal['payType']) ? $withdrawal['payType'] : null, // 支付类型wechat、alipay、bankcard

View File

@@ -34,7 +34,6 @@ class GetPlanSceneListV1Controller extends BaseController
$where[] = ['scenarioTags', 'like', '%' . $params['tag'] . '%'];
}
// 查询数据
$query = PlansSceneModel::where($where);
@@ -193,10 +192,19 @@ class GetPlanSceneListV1Controller extends BaseController
return [];
}
$where = [
['companyId', '=', $companyId],
['deleteTime', '=', 0],
['sceneId', 'in', $sceneIds],
];
if(!$this->getUserInfo('isAdmin')){
$where[] = ['userId', '=', $this->getUserInfo('id')];
}
$rows = Db::name('customer_acquisition_task')
->whereIn('sceneId', $sceneIds)
->where('companyId', $companyId)
->where('deleteTime', 0)
->where($where)
->field('sceneId, COUNT(*) as total')
->group('sceneId')
->select();

View File

@@ -30,7 +30,7 @@ class PlanSceneV1Controller extends BaseController
'companyId' => $this->getUserInfo('companyId'),
];
if($this->getUserInfo('isAdmin')){
if(!$this->getUserInfo('isAdmin')){
$where['userId'] = $this->getUserInfo('id');
}
@@ -176,7 +176,7 @@ class PlanSceneV1Controller extends BaseController
}
// 如果是管理员,需要验证用户权限
if ($this->getUserInfo('isAdmin')) {
if (!$this->getUserInfo('isAdmin')) {
$userPlan = Db::name('customer_acquisition_task')
->where([
'id' => $planId,
@@ -405,8 +405,8 @@ class PlanSceneV1Controller extends BaseController
->field([
'task_id as taskId',
'COUNT(1) as acquiredCount',
"SUM(CASE WHEN status IN (1,2,3,4) THEN 1 ELSE 0 END) as addedCount",
"SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) as passCount",
"SUM(CASE WHEN status IN (1,2,3,4,5) THEN 1 ELSE 0 END) as addedCount",
"SUM(CASE WHEN status IN (4,5) THEN 1 ELSE 0 END) as passCount",
'MAX(updateTime) as lastUpdated'
])
->group('task_id')
@@ -511,7 +511,7 @@ class PlanSceneV1Controller extends BaseController
$query = Db::name('task_customer')->where(['task_id' => $task['id']]);
if ($type == 2){
$query = $query->where('status',4);
$query = $query->whereIn('status',[4,5]);
}
if (!empty($keyword)) {

View File

@@ -179,7 +179,7 @@ class GetWechatController extends BaseController
'activityLevel' => $this->getActivityLevel($wechatId),
'accountWeight' => $this->getAccountWeight($wechatId),
'statistics' => $this->getStatistics($wechatId),
'restrictions' => $this->getRestrict($wechatId),
// 'restrictions' => $this->getRestrict($wechatId),
];

View File

@@ -0,0 +1,50 @@
<?php
namespace app\cunkebao\controller\workbench;
use app\cunkebao\controller\BaseController;
use library\ResponseHelper;
use think\Db;
/**
* 常用功能控制器
*/
class CommonFunctionsController extends BaseController
{
/**
* 获取常用功能列表
* @return \think\response\Json
*/
public function getList()
{
try {
$companyId = $this->getUserInfo('companyId');
// 从数据库查询常用功能列表
$functions = Db::name('workbench_function')
->where('status', 1)
->order('sort ASC, id ASC')
->select();
// 处理数据判断是否显示New标签创建时间近1个月
$oneMonthAgo = time() - 30 * 24 * 60 * 60; // 30天前的时间戳
foreach ($functions as &$function) {
// 判断是否显示New标签创建时间在近1个月内
$function['isNew'] = ($function['createTime'] >= $oneMonthAgo) ? true : false;
$function['labels'] = json_decode($function['labels'],true);
}
unset($function);
return ResponseHelper::success([
'list' => $functions
]);
} catch (\Exception $e) {
return ResponseHelper::error('获取常用功能列表失败:' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace app\cunkebao\controller\workbench;
use think\Controller;
use think\Db;
/**
* 工作台 - 自动点赞相关功能
*/
class WorkbenchAutoLikeController extends Controller
{
/**
* 获取点赞记录列表
* @return \think\response\Json
*/
public function getLikeRecords()
{
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$workbenchId = $this->request->param('workbenchId', 0);
$where = [
['wali.workbenchId', '=', $workbenchId]
];
// 查询点赞记录
$list = Db::name('workbench_auto_like_item')->alias('wali')
->join(['s2_wechat_moments' => 'wm'], 'wali.snsId = wm.snsId')
->field([
'wali.id',
'wali.workbenchId',
'wali.momentsId',
'wali.snsId',
'wali.wechatAccountId',
'wali.wechatFriendId',
'wali.createTime as likeTime',
'wm.content',
'wm.resUrls',
'wm.createTime as momentTime',
'wm.userName',
])
->where($where)
->order('wali.createTime', 'desc')
->group('wali.id')
->page($page, $limit)
->select();
// 处理数据
foreach ($list as &$item) {
//处理用户信息
$friend = Db::table('s2_wechat_friend')
->where(['id' => $item['wechatFriendId']])
->field('nickName,avatar')
->find();
if (!empty($friend)) {
$item['friendName'] = $friend['nickName'];
$item['friendAvatar'] = $friend['avatar'];
} else {
$item['friendName'] = '';
$item['friendAvatar'] = '';
}
//处理客服
$friend = Db::table('s2_wechat_account')
->where(['id' => $item['wechatAccountId']])
->field('nickName,avatar')
->find();
if (!empty($friend)) {
$item['operatorName'] = $friend['nickName'];
$item['operatorAvatar'] = $friend['avatar'];
} else {
$item['operatorName'] = '';
$item['operatorAvatar'] = '';
}
// 处理时间格式
$item['likeTime'] = date('Y-m-d H:i:s', $item['likeTime']);
$item['momentTime'] = !empty($item['momentTime']) ? date('Y-m-d H:i:s', $item['momentTime']) : '';
// 处理资源链接
if (!empty($item['resUrls'])) {
$item['resUrls'] = json_decode($item['resUrls'], true);
} else {
$item['resUrls'] = [];
}
}
// 获取总记录数
$total = Db::name('workbench_auto_like_item')->alias('wali')
->where($where)
->count();
return json([
'code' => 200,
'msg' => '获取成功',
'data' => [
'list' => $list,
'total' => $total,
'page' => $page,
'limit' => $limit
]
]);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
<?php
namespace app\cunkebao\controller\workbench;
use think\Controller;
use think\Db;
use think\facade\Env;
/**
* 工作台 - 辅助功能
*/
class WorkbenchHelperController extends Controller
{
/**
* 获取所有微信好友标签及数量统计
* @return \think\response\Json
*/
public function getDeviceLabels()
{
$deviceIds = $this->request->param('deviceIds', '');
$companyId = $this->request->userInfo['companyId'];
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$keyword = $this->request->param('keyword', '');
$where = [
['wc.companyId', '=', $companyId],
];
if (!empty($deviceIds)) {
$deviceIds = explode(',', $deviceIds);
$where[] = ['dwl.deviceId', 'in', $deviceIds];
}
$wechatAccounts = Db::name('wechat_customer')->alias('wc')
->join('device_wechat_login dwl', 'dwl.wechatId = wc.wechatId AND dwl.companyId = wc.companyId AND dwl.alive = 1')
->join(['s2_wechat_account' => 'wa'], 'wa.wechatId = wc.wechatId')
->where($where)
->field('wa.id,wa.wechatId,wa.nickName,wa.labels')
->select();
$labels = [];
$wechatIds = [];
foreach ($wechatAccounts as $account) {
$labelArr = json_decode($account['labels'], true);
if (is_array($labelArr)) {
foreach ($labelArr as $label) {
if ($label !== '' && $label !== null) {
$labels[] = $label;
}
}
}
$wechatIds[] = $account['wechatId'];
}
// 去重(只保留一个)
$labels = array_values(array_unique($labels));
$wechatIds = array_unique($wechatIds);
// 搜索过滤
if (!empty($keyword)) {
$labels = array_filter($labels, function ($label) use ($keyword) {
return mb_stripos($label, $keyword) !== false;
});
$labels = array_values($labels); // 重新索引数组
}
// 分页处理
$labels2 = array_slice($labels, ($page - 1) * $limit, $limit);
// 统计数量
$newLabel = [];
foreach ($labels2 as $label) {
$friendCount = Db::table('s2_wechat_friend')
->whereIn('ownerWechatId', $wechatIds)
->where('labels', 'like', '%"' . $label . '"%')
->count();
$newLabel[] = [
'label' => $label,
'count' => $friendCount
];
}
// 返回结果
return json([
'code' => 200,
'msg' => '获取成功',
'data' => [
'list' => $newLabel,
'total' => count($labels),
]
]);
}
/**
* 获取群列表
* @return \think\response\Json
*/
public function getGroupList()
{
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$keyword = $this->request->param('keyword', '');
$where = [
['wg.deleteTime', '=', 0],
['wg.companyId', '=', $this->request->userInfo['companyId']],
];
if (!empty($keyword)) {
$where[] = ['wg.name', 'like', '%' . $keyword . '%'];
}
$query = Db::name('wechat_group')->alias('wg')
->join('wechat_account wa', 'wa.wechatId = wg.ownerWechatId')
->where($where);
$total = $query->count();
$list = $query->order('wg.id', 'desc')
->field('wg.id,wg.name as groupName,wg.ownerWechatId,wa.nickName,wg.createTime,wa.avatar,wa.alias,wg.avatar as groupAvatar')
->page($page, $limit)
->select();
// 优化:格式化时间,头像兜底
$defaultGroupAvatar = '';
$defaultAvatar = '';
foreach ($list as &$item) {
$item['createTime'] = $item['createTime'] ? date('Y-m-d H:i:s', $item['createTime']) : '';
$item['groupAvatar'] = $item['groupAvatar'] ?: $defaultGroupAvatar;
$item['avatar'] = $item['avatar'] ?: $defaultAvatar;
}
return json(['code' => 200, 'msg' => '获取成功', 'data' => ['total' => $total, 'list' => $list]]);
}
/**
* 获取流量池列表
* @return \think\response\Json
*/
public function getTrafficPoolList()
{
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$keyword = $this->request->param('keyword', '');
$companyId = $this->request->userInfo['companyId'];
$baseQuery = Db::name('traffic_source_package')->alias('tsp')
->where('tsp.isDel', 0)
->whereIn('tsp.companyId', [$companyId, 0]);
if (!empty($keyword)) {
$baseQuery->whereLike('tsp.name', '%' . $keyword . '%');
}
$total = (clone $baseQuery)->count();
$list = $baseQuery
->leftJoin('traffic_source_package_item tspi', 'tspi.packageId = tsp.id and tspi.isDel = 0')
->field('tsp.id,tsp.name,tsp.description,tsp.pic,tsp.companyId,COUNT(tspi.id) as itemCount,max(tspi.createTime) as latestImportTime')
->group('tsp.id')
->order('tsp.id', 'desc')
->page($page, $limit)
->select();
foreach ($list as &$item) {
$item['latestImportTime'] = !empty($item['latestImportTime']) ? date('Y-m-d H:i:s', $item['latestImportTime']) : '';
}
unset($item);
return json(['code' => 200, 'msg' => '获取成功', 'data' => ['total' => $total, 'list' => $list]]);
}
/**
* 获取账号列表
* @return \think\response\Json
*/
public function getAccountList()
{
$companyId = $this->request->userInfo['companyId'];
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$query = Db::table('s2_company_account')
->alias('a')
->where(['a.departmentId' => $companyId, 'a.status' => 0])
->whereNotLike('a.userName', '%_offline%')
->whereNotLike('a.userName', '%_delete%');
$total = $query->count();
$list = $query->field('a.id,a.userName,a.realName,a.nickname,a.memo')
->page($page, $limit)
->select();
return json(['code' => 200, 'msg' => '获取成功', 'data' => ['total' => $total, 'list' => $list]]);
}
/**
* 获取京东联盟导购媒体
* @return \think\response\Json
*/
public function getJdSocialMedia()
{
$data = Db::name('jd_social_media')->order('id DESC')->select();
return json(['code' => 200, 'msg' => '获取成功', 'data' => $data]);
}
/**
* 获取京东联盟广告位
* @return \think\response\Json
*/
public function getJdPromotionSite()
{
$id = $this->request->param('id', '');
if (empty($id)) {
return json(['code' => 500, 'msg' => '参数缺失']);
}
$data = Db::name('jd_promotion_site')->where('jdSocialMediaId', $id)->order('id DESC')->select();
return json(['code' => 200, 'msg' => '获取成功', 'data' => $data]);
}
/**
* 京东转链-京推推
* @param string $content
* @param string $positionid
* @return string
*/
public function changeLink($content = '', $positionid = '')
{
$unionId = Env::get('jd.unionId', '');
$jttAppId = Env::get('jd.jttAppId', '');
$appKey = Env::get('jd.appKey', '');
$apiUrl = Env::get('jd.apiUrl', '');
$content = !empty($content) ? $content : $this->request->param('content', '');
$positionid = !empty($positionid) ? $positionid : $this->request->param('positionid', '');
if (empty($content)) {
return json_encode(['code' => 500, 'msg' => '转链的内容为空']);
}
// 验证是否包含链接
if (!$this->containsLink($content)) {
return json_encode(['code' => 500, 'msg' => '内容中未检测到有效链接']);
}
if (empty($unionId) || empty($jttAppId) || empty($appKey) || empty($apiUrl)) {
return json_encode(['code' => 500, 'msg' => '参数缺失']);
}
$params = [
'unionid' => $unionId,
'content' => $content,
'appid' => $jttAppId,
'appkey' => $appKey,
'v' => 'v2'
];
if (!empty($positionid)) {
$params['positionid'] = $positionid;
}
$res = requestCurl($apiUrl, $params, 'GET', [], 'json');
$res = json_decode($res, true);
if (empty($res)) {
return json_encode(['code' => 500, 'msg' => '未知错误']);
}
$result = $res['result'];
if ($res['return'] == 0) {
return json_encode(['code' => 200, 'data' => $result['chain_content'], 'msg' => $result['msg']]);
} else {
return json_encode(['code' => 500, 'msg' => $result['msg']]);
}
}
/**
* 验证内容是否包含链接
* @param string $content 要检测的内容
* @return bool
*/
private function containsLink($content)
{
// 定义各种链接的正则表达式模式
$patterns = [
// HTTP/HTTPS链接
'/https?:\/\/[^\s]+/i',
// 京东商品链接
'/item\.jd\.com\/\d+/i',
// 京东短链接
'/u\.jd\.com\/[a-zA-Z0-9]+/i',
// 淘宝商品链接
'/item\.taobao\.com\/item\.htm\?id=\d+/i',
// 天猫商品链接
'/detail\.tmall\.com\/item\.htm\?id=\d+/i',
// 淘宝短链接
'/m\.tb\.cn\/[a-zA-Z0-9]+/i',
// 拼多多链接
'/mobile\.yangkeduo\.com\/goods\.html\?goods_id=\d+/i',
// 苏宁易购链接
'/product\.suning\.com\/\d+\/\d+\.html/i',
// 通用域名模式(包含常见电商域名)
'/(?:jd|taobao|tmall|yangkeduo|suning|amazon|dangdang)\.com[^\s]*/i',
// 通用短链接模式
'/[a-zA-Z0-9-]+\.[a-zA-Z]{2,}\/[a-zA-Z0-9\-._~:\/?#\[\]@!$&\'()*+,;=]+/i'
];
// 遍历所有模式进行匹配
foreach ($patterns as $pattern) {
if (preg_match($pattern, $content)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace app\cunkebao\controller\workbench;
use think\Controller;
use think\Db;
/**
* 工作台 - 联系人导入相关功能
*/
class WorkbenchImportContactController extends Controller
{
/**
* 获取通讯录导入记录列表
* @return \think\response\Json
*/
public function getImportContact()
{
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$workbenchId = $this->request->param('workbenchId', 0);
$where = [
['wici.workbenchId', '=', $workbenchId]
];
// 查询发布记录
$list = Db::name('workbench_import_contact_item')->alias('wici')
->join('traffic_pool tp', 'tp.id = wici.poolId', 'left')
->join('traffic_source tc', 'tc.identifier = tp.identifier', 'left')
->join('wechat_account wa', 'wa.wechatId = tp.wechatId', 'left')
->field([
'wici.id',
'wici.workbenchId',
'wici.createTime',
'tp.identifier',
'tp.mobile',
'tp.wechatId',
'tc.name',
'wa.nickName',
'wa.avatar',
'wa.alias',
])
->where($where)
->order('tc.name DESC,wici.createTime DESC')
->group('tp.identifier')
->page($page, $limit)
->select();
foreach ($list as &$item) {
$item['createTime'] = date('Y-m-d H:i:s', $item['createTime']);
}
// 获取总记录数
$total = Db::name('workbench_import_contact_item')->alias('wici')
->where($where)
->count();
return json([
'code' => 200,
'msg' => '获取成功',
'data' => [
'list' => $list,
'total' => $total,
]
]);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace app\cunkebao\controller\workbench;
use think\Controller;
use think\Db;
/**
* 工作台 - 朋友圈同步相关功能
*/
class WorkbenchMomentsController extends Controller
{
/**
* 获取朋友圈发布记录列表
* @return \think\response\Json
*/
public function getMomentsRecords()
{
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$workbenchId = $this->request->param('workbenchId', 0);
$where = [
['wmsi.workbenchId', '=', $workbenchId]
];
// 查询发布记录
$list = Db::name('workbench_moments_sync_item')->alias('wmsi')
->join('content_item ci', 'ci.id = wmsi.contentId', 'left')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wmsi.wechatAccountId', 'left')
->field([
'wmsi.id',
'wmsi.workbenchId',
'wmsi.createTime as publishTime',
'ci.contentType',
'ci.content',
'ci.resUrls',
'ci.urls',
'wa.nickName as operatorName',
'wa.avatar as operatorAvatar'
])
->where($where)
->order('wmsi.createTime', 'desc')
->page($page, $limit)
->select();
foreach ($list as &$item) {
$item['resUrls'] = json_decode($item['resUrls'], true);
$item['urls'] = json_decode($item['urls'], true);
}
// 获取总记录数
$total = Db::name('workbench_moments_sync_item')->alias('wmsi')
->where($where)
->count();
return json([
'code' => 200,
'msg' => '获取成功',
'data' => [
'list' => $list,
'total' => $total,
'page' => $page,
'limit' => $limit
]
]);
}
/**
* 获取朋友圈发布统计
* @return \think\response\Json
*/
public function getMomentsStats()
{
$workbenchId = $this->request->param('workbenchId', 0);
if (empty($workbenchId)) {
return json(['code' => 400, 'msg' => '参数错误']);
}
// 获取今日数据
$todayStart = strtotime(date('Y-m-d') . ' 00:00:00');
$todayEnd = strtotime(date('Y-m-d') . ' 23:59:59');
$todayStats = Db::name('workbench_moments_sync_item')
->where([
['workbenchId', '=', $workbenchId],
['createTime', 'between', [$todayStart, $todayEnd]]
])
->field([
'COUNT(*) as total',
'SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as success',
'SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as failed'
])
->find();
// 获取总数据
$totalStats = Db::name('workbench_moments_sync_item')
->where('workbenchId', $workbenchId)
->field([
'COUNT(*) as total',
'SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as success',
'SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as failed'
])
->find();
return json([
'code' => 200,
'msg' => '获取成功',
'data' => [
'today' => [
'total' => intval($todayStats['total']),
'success' => intval($todayStats['success']),
'failed' => intval($todayStats['failed'])
],
'total' => [
'total' => intval($totalStats['total']),
'success' => intval($totalStats['success']),
'failed' => intval($totalStats['failed'])
]
]
]);
}
}

View File

@@ -0,0 +1,309 @@
<?php
namespace app\cunkebao\controller\workbench;
use think\Controller;
use think\Db;
/**
* 工作台 - 流量分发相关功能
*/
class WorkbenchTrafficController extends Controller
{
/**
* 获取流量分发记录列表
* @return \think\response\Json
*/
public function getTrafficDistributionRecords()
{
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$workbenchId = $this->request->param('workbenchId', 0);
$where = [
['wtdi.workbenchId', '=', $workbenchId]
];
// 查询分发记录
$list = Db::name('workbench_traffic_distribution_item')->alias('wtdi')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wtdi.wechatAccountId', 'left')
->join(['s2_wechat_friend' => 'wf'], 'wf.id = wtdi.wechatFriendId', 'left')
->field([
'wtdi.id',
'wtdi.workbenchId',
'wtdi.wechatAccountId',
'wtdi.wechatFriendId',
'wtdi.createTime as distributeTime',
'wtdi.status',
'wtdi.errorMsg',
'wa.nickName as operatorName',
'wa.avatar as operatorAvatar',
'wf.nickName as friendName',
'wf.avatar as friendAvatar',
'wf.gender',
'wf.province',
'wf.city'
])
->where($where)
->order('wtdi.createTime', 'desc')
->page($page, $limit)
->select();
// 处理数据
foreach ($list as &$item) {
// 处理时间格式
$item['distributeTime'] = date('Y-m-d H:i:s', $item['distributeTime']);
// 处理性别
$genderMap = [
0 => '未知',
1 => '男',
2 => '女'
];
$item['genderText'] = $genderMap[$item['gender']] ?? '未知';
// 处理状态文字
$statusMap = [
0 => '待分发',
1 => '分发成功',
2 => '分发失败'
];
$item['statusText'] = $statusMap[$item['status']] ?? '未知状态';
}
// 获取总记录数
$total = Db::name('workbench_traffic_distribution_item')->alias('wtdi')
->where($where)
->count();
return json([
'code' => 200,
'msg' => '获取成功',
'data' => [
'list' => $list,
'total' => $total,
'page' => $page,
'limit' => $limit
]
]);
}
/**
* 获取流量分发统计
* @return \think\response\Json
*/
public function getTrafficDistributionStats()
{
$workbenchId = $this->request->param('workbenchId', 0);
if (empty($workbenchId)) {
return json(['code' => 400, 'msg' => '参数错误']);
}
// 获取今日数据
$todayStart = strtotime(date('Y-m-d') . ' 00:00:00');
$todayEnd = strtotime(date('Y-m-d') . ' 23:59:59');
$todayStats = Db::name('workbench_traffic_distribution_item')
->where([
['workbenchId', '=', $workbenchId],
['createTime', 'between', [$todayStart, $todayEnd]]
])
->field([
'COUNT(*) as total',
'SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as success',
'SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as failed'
])
->find();
// 获取总数据
$totalStats = Db::name('workbench_traffic_distribution_item')
->where('workbenchId', $workbenchId)
->field([
'COUNT(*) as total',
'SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as success',
'SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as failed'
])
->find();
return json([
'code' => 200,
'msg' => '获取成功',
'data' => [
'today' => [
'total' => intval($todayStats['total']),
'success' => intval($todayStats['success']),
'failed' => intval($todayStats['failed'])
],
'total' => [
'total' => intval($totalStats['total']),
'success' => intval($totalStats['success']),
'failed' => intval($totalStats['failed'])
]
]
]);
}
/**
* 获取流量分发详情
* @return \think\response\Json
*/
public function getTrafficDistributionDetail()
{
$id = $this->request->param('id', 0);
if (empty($id)) {
return json(['code' => 400, 'msg' => '参数错误']);
}
$detail = Db::name('workbench_traffic_distribution_item')->alias('wtdi')
->join(['s2_wechat_account' => 'wa'], 'wa.id = wtdi.wechatAccountId', 'left')
->join(['s2_wechat_friend' => 'wf'], 'wf.id = wtdi.wechatFriendId', 'left')
->field([
'wtdi.id',
'wtdi.workbenchId',
'wtdi.wechatAccountId',
'wtdi.wechatFriendId',
'wtdi.createTime as distributeTime',
'wtdi.status',
'wtdi.errorMsg',
'wa.nickName as operatorName',
'wa.avatar as operatorAvatar',
'wf.nickName as friendName',
'wf.avatar as friendAvatar',
'wf.gender',
'wf.province',
'wf.city',
'wf.signature',
'wf.remark'
])
->where('wtdi.id', $id)
->find();
if (empty($detail)) {
return json(['code' => 404, 'msg' => '记录不存在']);
}
// 处理数据
$detail['distributeTime'] = date('Y-m-d H:i:s', $detail['distributeTime']);
// 处理性别
$genderMap = [
0 => '未知',
1 => '男',
2 => '女'
];
$detail['genderText'] = $genderMap[$detail['gender']] ?? '未知';
// 处理状态文字
$statusMap = [
0 => '待分发',
1 => '分发成功',
2 => '分发失败'
];
$detail['statusText'] = $statusMap[$detail['status']] ?? '未知状态';
return json([
'code' => 200,
'msg' => '获取成功',
'data' => $detail
]);
}
/**
* 创建流量分发计划
* @return \think\response\Json
*/
public function createTrafficPlan()
{
$param = $this->request->post();
Db::startTrans();
try {
// 1. 创建主表
$planId = Db::name('ck_workbench')->insertGetId([
'name' => $param['name'],
'type' => 5, // TYPE_TRAFFIC_DISTRIBUTION
'status' => 1,
'autoStart' => $param['autoStart'] ?? 0,
'userId' => $this->request->userInfo['id'],
'companyId' => $this->request->userInfo['companyId'],
'createTime' => time(),
'updateTime' => time()
]);
// 2. 创建扩展表
Db::name('ck_workbench_traffic_config')->insert([
'workbenchId' => $planId,
'distributeType' => $param['distributeType'],
'maxPerDay' => $param['maxPerDay'],
'timeType' => $param['timeType'],
'startTime' => $param['startTime'],
'endTime' => $param['endTime'],
'targets' => json_encode($param['targets'], JSON_UNESCAPED_UNICODE),
'pools' => json_encode($param['poolGroups'], JSON_UNESCAPED_UNICODE),
'createTime' => time(),
'updateTime' => time()
]);
Db::commit();
return json(['code' => 200, 'msg' => '创建成功']);
} catch (\Exception $e) {
Db::rollback();
return json(['code' => 500, 'msg' => '创建失败:' . $e->getMessage()]);
}
}
/**
* 获取流量列表
* @return \think\response\Json
*/
public function getTrafficList()
{
$companyId = $this->request->userInfo['companyId'];
$page = $this->request->param('page', 1);
$limit = $this->request->param('limit', 10);
$keyword = $this->request->param('keyword', '');
$workbenchId = $this->request->param('workbenchId', '');
$isRecycle = $this->request->param('isRecycle', '');
if (empty($workbenchId)) {
return json(['code' => 400, 'msg' => '参数错误']);
}
$workbench = Db::name('workbench')->where(['id' => $workbenchId, 'isDel' => 0, 'companyId' => $companyId, 'type' => 5])->find();
if (empty($workbench)) {
return json(['code' => 400, 'msg' => '该任务不存在或已删除']);
}
$query = Db::name('workbench_traffic_config_item')->alias('wtc')
->join(['s2_wechat_friend' => 'wf'], 'wtc.wechatFriendId = wf.id')
->join('users u', 'wtc.wechatAccountId = u.s2_accountId', 'left')
->field([
'wtc.id', 'wtc.isRecycle', 'wtc.isRecycle', 'wtc.createTime','wtc.recycleTime',
'wf.wechatId', 'wf.alias', 'wf.nickname', 'wf.avatar', 'wf.gender', 'wf.phone',
'u.account', 'u.username'
])
->where(['wtc.workbenchId' => $workbenchId])
->order('wtc.id DESC');
if (!empty($keyword)) {
$query->where('wf.wechatId|wf.alias|wf.nickname|wf.phone|u.account|u.username', 'like', '%' . $keyword . '%');
}
if ($isRecycle != '' || $isRecycle != null) {
$query->where('isRecycle',$isRecycle);
}
$total = $query->count();
$list = $query->page($page, $limit)->select();
foreach ($list as &$item) {
$item['createTime'] = date('Y-m-d H:i:s', $item['createTime']);
$item['recycleTime'] = date('Y-m-d H:i:s', $item['recycleTime']);
}
unset($item);
$data = [
'total' => $total,
'list' => $list,
];
return json(['code' => 200, 'msg' => '获取成功', 'data' => $data]);
}
}

View File

@@ -84,7 +84,7 @@ class MessageFriendsListJob
$data = $response['data'];
// 判断是否有下一页
if (!empty($data) && count($data['results']) > 0) {
if (!empty($data) && count($data) > 0) {
// 有下一页,将下一页任务添加到队列
$nextPageIndex = $pageIndex + 1;
$this->addNextPageToQueue($nextPageIndex, $pageSize);

View File

@@ -1,34 +1,66 @@
<?php
namespace app\job;
use app\chukebao\model\Reply;
use app\chukebao\model\ReplyGroup;
use think\Db;
use think\queue\Job;
class SyncContentJob
{
public function fire(Job $job, $data)
{
try {
// TODO: 在这里实现具体的同步逻辑
// 1. 获取需要同步的数据
// 2. 处理数据
// 3. 更新到目标位置
// 如果任务执行成功,删除任务
$job->delete();
// 记录日志
\think\Log::info('内容库同步成功:' . json_encode($data));
} catch (\Exception $e) {
// 如果任务执行失败,记录日志
\think\Log::error('内容库同步失败:' . $e->getMessage());
// 如果任务失败次数小于3次重新放入队列
if ($job->attempts() < 3) {
$job->release(60); // 延迟60秒后重试
} else {
$job->delete();
$ddd= Db::table('s2_wechat_friend')->where('ownerWechatId','wxid_h7nsh7vxseyn29')->select();
foreach ($ddd as $v) {
$d = Db::table('ck_task_customer')->where('task_id','167')->where('phone',$v['wechatId'])->find();
if (!empty($d) && !in_array($d['status'],[4,5])) {
Db::table('ck_task_customer')->where('id',$d['id'])->update(['status'=>5]);
}
}
exit_data(111);
return true;
/*$ddd= '[{"id":21909,"groupName":"私域运营招聘","sortIndex":240426546,"parentId":0,"replyType":1,"children":[],"replys":null},{"id":8074,"groupName":"存客宝新客户介绍","sortIndex":230,"parentId":0,"replyType":1,"children":[{"id":8081,"groupName":"客户系统上线准备","sortIndex":1,"parentId":8074,"replyType":1,"children":[],"replys":null}],"replys":null},{"id":10441,"groupName":"BOSS直聘","sortIndex":229,"parentId":0,"replyType":1,"children":[],"replys":null},{"id":15111,"groupName":"点了码新客户了解","sortIndex":228,"parentId":0,"replyType":1,"children":[],"replys":null},{"id":12732,"groupName":"测试","sortIndex":219,"parentId":0,"replyType":1,"children":[],"replys":null},{"id":8814,"groupName":"封单话术","sortIndex":176,"parentId":0,"replyType":1,"children":[],"replys":null},{"id":8216,"groupName":"私域合伙人","sortIndex":172,"parentId":0,"replyType":1,"children":[],"replys":null},{"id":6476,"groupName":"新客户了解","sortIndex":119,"parentId":0,"replyType":1,"children":[],"replys":null}]';
$ddd=json_decode($ddd,1);*/
$ddd = ReplyGroup::where('companyId',2778)->where('companyId','<>',21898)->where('isDel',0)->select()->toArray();
$authorization = 'OE7kh6Dsw_0SqqH1FTAPCB2ewCQDhx7VvPw6PrsE_p9tcRKbtlFsZau8kjk2NQ829Yah90KhTh0C_35ek569uRQgM_gC0NtKzfRPDDoqMIUE5mI6AO_hm0dm-xDJqhAFYkXHCdXnJYzQZxWS5dleJCIwtQxgRuIzIbr-_G_5C-7DeLEOSt2vi1oGPleLt00QGQ1WYVYqoHYrbPGMghMQpWIbgk5qNcUCeANlLJ_s7QFC3QzArU95_YiK0HlhU81hZqr8kI_5lmdrRBoR-yNIlyhySLRCmEZYGzOxCiUHL3uFHYZA1VnLBAVbryNj5DElZjMgwA';
// 设置请求头
$headerData = ['client:system'];
$header = setHeader($headerData, $authorization, 'json');
foreach ($ddd as $key => $value) {
$data = [];
// 发送请求获取公司账号列表
$result = requestCurl('https://s2.siyuguanli.com:9991/api/Reply/listReply?groupId='.$value['id'], '', 'GET', $header,'json');
$response = handleApiResponse($result);
foreach ($response as $k => $v) {
$data[] = [
'groupId' => $v['groupId'],
'userId' => $value['userId'],
'title' => $v['title'],
'msgType' => $v['msgType'],
'content' => $v['content'],
'createTime' => strtotime($v['createTime']),
'lastUpdateTime' => strtotime($v['lastUpdateTime']),
'sortIndex' => 50
];
}
$Reply = new Reply();
$Reply->insertAll($data);
}
exit_data(11111);
}
}

View File

@@ -77,7 +77,8 @@ class WorkbenchGroupCreateJob
{
try {
// 1. 查询启用了建群功能的数据
$workbenches = Workbench::where(['status' => 1, 'type' => 4, 'isDel' => 0,'id' => 315])->order('id desc')->select();
$workbenches = Workbench::where(['status' => 0, 'type' => 4, 'isDel' => 0,'id' => 354])->order('id desc')->select();
foreach ($workbenches as $workbench) {
// 获取工作台配置
$config = WorkbenchGroupCreate::where('workbenchId', $workbench->id)->find();
@@ -86,22 +87,49 @@ class WorkbenchGroupCreateJob
}
// 解析配置
$config['poolGroups'] = json_decode($config['poolGroups'], true);
$config['devices'] = json_decode($config['devices'], true);
$config['poolGroups'] = json_decode($config['poolGroups'] ?? '[]', true) ?: [];
$config['devices'] = json_decode($config['devices'] ?? '[]', true) ?: [];
$config['wechatGroups'] = json_decode($config['wechatGroups'] ?? '[]', true) ?: [];
$config['admins'] = json_decode($config['admins'] ?? '[]', true) ?: [];
if (empty($config['poolGroups']) || empty($config['devices'])) {
// 检查时间限制
if (!$this->isWithinTimeRange($config)) {
continue;
}
// 检查每日建群数量限制
if (!$this->checkDailyLimit($workbench->id, $config)) {
continue;
}
// 检查是否有正在创建中的群,如果有则跳过(避免重复创建)
$creatingCount = Db::name('workbench_group_create_item')
->where('workbenchId', $workbench->id)
->where('status', self::STATUS_CREATING)
->where('groupId', '<>', null) // 有groupId的记录
->group('groupId')
->count();
if ($creatingCount > 0) {
Log::info("工作台ID: {$workbench->id} 有正在创建中的群({$creatingCount}个),跳过本次执行");
continue;
}
if (empty($config['devices'])) {
continue;
}
// 获取群主成员(从设备中获取)
$groupMember = [];
$wechatId = Db::name('device_wechat_login')
->whereIn('deviceId',$config['devices'])
$wechatIds = Db::name('device_wechat_login')
->whereIn('deviceId', $config['devices'])
->where('alive', DeviceWechatLoginModel::ALIVE_WECHAT_ACTIVE)
->order('id desc')
->value('wechatId');
if (empty($wechatId)) {
->column('wechatId');
if (empty($wechatIds)) {
continue;
}
$groupMember[] = $wechatId;
$groupMember = array_unique($wechatIds);
// 获取群主好友ID映射所有群主的好友
$groupMemberWechatId = [];
$groupMemberId = [];
@@ -110,6 +138,7 @@ class WorkbenchGroupCreateJob
$friends = Db::table('s2_wechat_friend')
->where('ownerWechatId', $ownerWechatId)
->whereIn('wechatId', $groupMember)
->where('isDeleted', 0)
->field('id,wechatId')
->select();
@@ -120,44 +149,78 @@ class WorkbenchGroupCreateJob
}
}
}
if (empty($groupMemberWechatId)) {
// 如果配置了wechatGroups从指定的群组中获取成员
if (!empty($config['wechatGroups'])) {
$this->addGroupMembersFromWechatGroups($config['wechatGroups'], $groupMember, $groupMemberId, $groupMemberWechatId);
}
if (empty($groupMemberId)) {
continue;
}
// 获取流量池用户
$poolItem = Db::name('traffic_source_package_item')
->whereIn('packageId', $config['poolGroups'])
->group('identifier')
->column('identifier');
if (empty($poolItem)) {
// 获取流量池用户(如果配置了流量池)
$poolItem = [];
if (!empty($config['poolGroups'])) {
$poolItem = Db::name('traffic_source_package_item')
->whereIn('packageId', $config['poolGroups'])
->where('isDel', 0)
->group('identifier')
->column('identifier');
}
// 如果既没有流量池也没有指定群组,跳过
if (empty($poolItem) && empty($config['wechatGroups'])) {
continue;
}
// 获取已入群的用户(排除已成功入群的)111
$groupUser = Db::name('workbench_group_create_item')
->where('workbenchId', $workbench->id)
->where('status', 'in', [self::STATUS_SUCCESS, self::STATUS_ADMIN_FRIEND_ADDED, self::STATUS_CREATING])
->whereIn('wechatId', $poolItem)
->group('wechatId')
->column('wechatId');
// 待入群的用户
$joinUser = array_diff($poolItem, $groupUser);
if (empty($joinUser)) {
// 获取已入群的用户(排除已成功入群的)
$groupUser = [];
if (!empty($poolItem)) {
$groupUser = Db::name('workbench_group_create_item')
->where('workbenchId', $workbench->id)
->where('status', 'in', [self::STATUS_SUCCESS, self::STATUS_ADMIN_FRIEND_ADDED, self::STATUS_CREATING])
->whereIn('wechatId', $poolItem)
->group('wechatId')
->column('wechatId');
}
// 待入群的用户(从流量池中筛选)
$joinUser = !empty($poolItem) ? array_diff($poolItem, $groupUser) : [];
// 如果流量池用户已用完或没有配置流量池但配置了wechatGroups至少创建一次使用群主成员
if (empty($joinUser) && !empty($config['wechatGroups'])) {
// 如果没有流量池用户创建一个空批次让processBatchUsers处理只有群主成员的情况
$joinUser = []; // 空数组,但会继续执行
}
// 如果既没有流量池用户也没有配置wechatGroups跳过
if (empty($joinUser) && empty($config['wechatGroups'])) {
continue;
}
// 计算随机群人数(不包含管理员,只减去群主成员数)
$groupRandNum = mt_rand($config['groupSizeMin'], $config['groupSizeMax']) - count($groupMember);
// 群主成员数 = 群主好友ID数量
$minGroupSize = max(2, $config['groupSizeMin']); // 至少2人才能建群
$maxGroupSize = max($minGroupSize, $config['groupSizeMax']);
$groupRandNum = mt_rand($minGroupSize, $maxGroupSize) - count($groupMemberId);
if ($groupRandNum <= 0) {
$groupRandNum = 1; // 至少需要1个成员
}
// 分批处理待入群用户
$addGroupUser = [];
$totalRows = count($joinUser);
for ($i = 0; $i < $totalRows; $i += $groupRandNum) {
$batchRows = array_slice($joinUser, $i, $groupRandNum);
if (!empty($batchRows)) {
$addGroupUser[] = $batchRows;
if (!empty($joinUser)) {
$totalRows = count($joinUser);
for ($i = 0; $i < $totalRows; $i += $groupRandNum) {
$batchRows = array_slice($joinUser, $i, $groupRandNum);
if (!empty($batchRows)) {
$addGroupUser[] = $batchRows;
}
}
} else {
// 如果没有流量池用户但配置了wechatGroups创建一个空批次
$addGroupUser[] = [];
}
// 初始化WebSocket
$toAccountId = '';
@@ -229,28 +292,59 @@ class WorkbenchGroupCreateJob
$ownerFriendIdsByAccount = [];
$wechatIds = [];
// 获取群主的好友关系(从流量池中筛选)
$ownerFriends = Db::table('s2_wechat_friend')->alias('f')
->join(['s2_wechat_account' => 'a'], 'f.wechatAccountId=a.id')
->whereIn('f.wechatId', $batchUsers)
->whereIn('a.wechatId', $groupOwnerWechatIds)
->where('f.isDeleted', 0)
->field('f.id,f.wechatId,a.id as wechatAccountId')
->select();
if (empty($ownerFriends)) {
Log::warning("未找到群主的好友跳过。工作台ID: {$workbench->id}");
return;
}
// 按微信账号分组群主好友
foreach ($ownerFriends as $friend) {
$wechatAccountId = $friend['wechatAccountId'];
if (!isset($ownerFriendIdsByAccount[$wechatAccountId])) {
$ownerFriendIdsByAccount[$wechatAccountId] = [];
// 如果batchUsers为空说明没有流量池用户但可能配置了wechatGroups
// 这种情况下,使用群主成员作为基础,按账号分组
if (empty($batchUsers)) {
// 按账号分组群主成员
foreach ($groupMemberId as $memberId) {
$member = Db::table('s2_wechat_friend')->where('id', $memberId)->find();
if ($member) {
$accountWechatId = $member['ownerWechatId'];
$account = Db::table('s2_wechat_account')
->where('wechatId', $accountWechatId)
->field('id')
->find();
if ($account) {
$wechatAccountId = $account['id'];
if (!isset($ownerFriendIdsByAccount[$wechatAccountId])) {
$ownerFriendIdsByAccount[$wechatAccountId] = [];
}
$ownerFriendIdsByAccount[$wechatAccountId][] = $memberId;
$wechatIds[$memberId] = $groupMemberWechatId[$memberId] ?? '';
}
}
}
$ownerFriendIdsByAccount[$wechatAccountId][] = $friend['id'];
$wechatIds[$friend['id']] = $friend['wechatId'];
} else {
// 获取群主的好友关系(从流量池中筛选)
$ownerFriends = Db::table('s2_wechat_friend')->alias('f')
->join(['s2_wechat_account' => 'a'], 'f.wechatAccountId=a.id')
->whereIn('f.wechatId', $batchUsers)
->whereIn('a.wechatId', $groupOwnerWechatIds)
->where('f.isDeleted', 0)
->field('f.id,f.wechatId,a.id as wechatAccountId')
->select();
if (empty($ownerFriends)) {
Log::warning("未找到群主的好友跳过。工作台ID: {$workbench->id}");
return;
}
// 按微信账号分组群主好友
foreach ($ownerFriends as $friend) {
$wechatAccountId = $friend['wechatAccountId'];
if (!isset($ownerFriendIdsByAccount[$wechatAccountId])) {
$ownerFriendIdsByAccount[$wechatAccountId] = [];
}
$ownerFriendIdsByAccount[$wechatAccountId][] = $friend['id'];
$wechatIds[$friend['id']] = $friend['wechatId'];
}
}
// 如果没有找到任何好友,跳过
if (empty($ownerFriendIdsByAccount)) {
Log::warning("未找到任何群主好友或成员跳过。工作台ID: {$workbench->id}");
return;
}
// 4. 遍历每个微信账号,创建群
@@ -279,21 +373,47 @@ class WorkbenchGroupCreateJob
}
// 4.3 限制群主好友数量(按随机群人数)
$limitedOwnerFriendIds = array_slice($ownerFriendIds, 0, $groupRandNum);
// 如果ownerFriendIds只包含群主成员没有流量池用户则不需要限制
$limitedOwnerFriendIds = $ownerFriendIds;
if (count($ownerFriendIds) > $groupRandNum) {
$limitedOwnerFriendIds = array_slice($ownerFriendIds, 0, $groupRandNum);
}
// 4.4 创建群:管理员 + 群主成员 + 群主好友(从流量池筛选)
$createFriendIds = array_merge($currentAdminFriendIds, $currentGroupMemberIds, $limitedOwnerFriendIds);
// 合并时去重,避免重复添加群主成员
$createFriendIds = array_merge($currentAdminFriendIds, $currentGroupMemberIds);
foreach ($limitedOwnerFriendIds as $friendId) {
if (!in_array($friendId, $createFriendIds)) {
$createFriendIds[] = $friendId;
}
}
// 微信建群至少需要2个人
if (count($createFriendIds) < 2) {
Log::warning("建群好友数量不足跳过。工作台ID: {$workbench->id}, 微信账号ID: {$wechatAccountId}");
Log::warning("建群好友数量不足至少需要2人跳过。工作台ID: {$workbench->id}, 微信账号ID: {$wechatAccountId}, 当前人数: " . count($createFriendIds));
continue;
}
// 4.5 生成群名称
// 4.5 检查当前账号是否有正在创建中的群,如果有则跳过
$creatingGroupCount = Db::name('workbench_group_create_item')
->where('workbenchId', $workbench->id)
->where('wechatAccountId', $wechatAccountId)
->where('status', self::STATUS_CREATING)
->where('groupId', '<>', null)
->group('groupId')
->count();
if ($creatingGroupCount > 0) {
Log::info("工作台ID: {$workbench->id}, 微信账号ID: {$wechatAccountId} 有正在创建中的群({$creatingGroupCount}个),跳过本次创建");
continue;
}
// 4.6 生成群名称
$existingGroupCount = Db::name('workbench_group_create_item')
->where('workbenchId', $workbench->id)
->where('wechatAccountId', $wechatAccountId)
->where('status', self::STATUS_SUCCESS)
->where('groupId', '<>', null) // 排除groupId为NULL的记录
->group('groupId')
->count();
@@ -301,7 +421,7 @@ class WorkbenchGroupCreateJob
? $config['groupNameTemplate'] . ($existingGroupCount + 1) . '群'
: $config['groupNameTemplate'];
// 4.6 调用建群接口
// 4.7 调用建群接口
$createTime = time();
$createResult = $webSocket->CmdChatroomCreate([
'chatroomName' => $chatroomName,
@@ -311,18 +431,65 @@ class WorkbenchGroupCreateJob
$createResultData = json_decode($createResult, true);
// 4.7 解析建群结果获取群ID
$chatroomId = 0;
// 4.8 解析建群结果获取群ID
// chatroomId: varchar(64) - 微信的群聊ID字符串
// groupId: int(10) - 数据库中的群组ID整数
$chatroomId = null; // 微信群聊ID字符串
$groupId = 0; // 数据库群组ID整数
$tempGroupId = null; // 临时群标识,用于轮询查询
if (!empty($createResultData) && isset($createResultData['code']) && $createResultData['code'] == 200) {
// 尝试从返回数据中获取群ID根据实际API返回格式调整
if (isset($createResultData['data']['chatroomId'])) {
$chatroomId = $createResultData['data']['chatroomId'];
// API返回的是chatroomId(字符串)
$chatroomId = (string)$createResultData['data']['chatroomId'];
// 通过chatroomId查询数据库获取groupId
$group = Db::name('wechat_group')
->where('chatroomId', $chatroomId)
->where('deleteTime', 0)
->find();
if ($group) {
$groupId = intval($group['id']);
}
} elseif (isset($createResultData['data']['id'])) {
$chatroomId = $createResultData['data']['id'];
// API返回的是数据库ID整数
$groupId = intval($createResultData['data']['id']);
// 通过groupId查询chatroomId
$group = Db::name('wechat_group')
->where('id', $groupId)
->where('deleteTime', 0)
->find();
if ($group && !empty($group['chatroomId'])) {
$chatroomId = (string)$group['chatroomId'];
}
}
// 如果有临时标识,保存用于轮询
if (isset($createResultData['data']['tempId'])) {
$tempGroupId = $createResultData['data']['tempId'];
}
}
// 4.8 记录创建请求
// 4.9 如果建群接口没有立即返回群ID进行同步轮询检查
if ($groupId == 0) {
// 获取账号的微信ID群主微信ID
$accountWechatId = Db::table('s2_wechat_account')
->where('id', $wechatAccountId)
->value('wechatId');
if (!empty($accountWechatId)) {
$pollResult = $this->pollGroupCreation($chatroomName, $accountWechatId, $wechatAccountId, $tempGroupId);
if ($pollResult && is_array($pollResult)) {
$groupId = intval($pollResult['groupId'] ?? 0);
$chatroomId = !empty($pollResult['chatroomId']) ? (string)$pollResult['chatroomId'] : null;
} elseif ($pollResult > 0) {
// 兼容旧返回值只返回groupId
$groupId =0;
$chatroomId = null;
}
}
}
// 4.10 记录创建请求
$installData = [];
foreach ($createFriendIds as $friendId) {
$memberType = in_array($friendId, $currentAdminFriendIds)
@@ -333,20 +500,21 @@ class WorkbenchGroupCreateJob
'workbenchId' => $workbench->id,
'friendId' => $friendId,
'wechatId' => $wechatIds[$friendId] ?? ($groupMemberWechatId[$friendId] ?? ''),
'groupId' => $chatroomId,
'groupId' => $groupId > 0 ? $groupId : null, // int类型
'wechatAccountId' => $wechatAccountId,
'status' => $chatroomId > 0 ? self::STATUS_SUCCESS : self::STATUS_CREATING,
'status' => $groupId > 0 ? self::STATUS_SUCCESS : self::STATUS_FAILED,
'memberType' => $memberType,
'retryCount' => 0,
'chatroomId' => $chatroomId > 0 ? $chatroomId : null,
'chatroomId' => $chatroomId, // varchar类型
'createTime' => $createTime,
];
}
Db::name('workbench_group_create_item')->insertAll($installData);
// 5. 如果群创建成功,拉管理员的好友进群
if ($chatroomId > 0 && !empty($currentAdminFriendIds)) {
$this->inviteAdminFriends($workbench, $config, $batchUsers, $currentAdminFriendIds, $chatroomId, $wechatAccountId, $wechatIds, $createTime, $webSocket);
// 注意拉人接口需要chatroomId字符串而不是groupId整数
if (!empty($chatroomId) && !empty($currentAdminFriendIds)) {
$this->inviteAdminFriends($workbench, $config, $batchUsers, $currentAdminFriendIds, $chatroomId, $groupId, $wechatAccountId, $wechatIds, $createTime, $webSocket);
}
}
}
@@ -357,13 +525,14 @@ class WorkbenchGroupCreateJob
* @param array $config 配置
* @param array $batchUsers 批次用户流量池微信ID数组
* @param array $adminFriendIds 管理员好友ID数组
* @param int $chatroomId 群ID
* @param string $chatroomId 群ID字符串用于API调用
* @param int $groupId 数据库群组ID整数
* @param int $wechatAccountId 微信账号ID
* @param array $wechatIds 好友ID到微信ID的映射
* @param int $createTime 创建时间
* @param WebSocketController $webSocket WebSocket实例
*/
protected function inviteAdminFriends($workbench, $config, $batchUsers, $adminFriendIds, $chatroomId, $wechatAccountId, $wechatIds, $createTime, $webSocket)
protected function inviteAdminFriends($workbench, $config, $batchUsers, $adminFriendIds, $chatroomId, $groupId, $wechatAccountId, $wechatIds, $createTime, $webSocket)
{
// 获取管理员的微信ID列表
$adminWechatIds = [];
@@ -399,7 +568,7 @@ class WorkbenchGroupCreateJob
$wechatIds[$friend['id']] = $friend['wechatId'];
}
// 调用拉人接口
// 调用拉人接口使用chatroomId字符串
$inviteResult = $webSocket->CmdChatroomInvite([
'wechatChatroomId' => $chatroomId,
'wechatFriendIds' => $adminFriendIdsToInvite
@@ -415,12 +584,12 @@ class WorkbenchGroupCreateJob
'workbenchId' => $workbench->id,
'friendId' => $friendId,
'wechatId' => $wechatIds[$friendId] ?? '',
'groupId' => $chatroomId,
'groupId' => $groupId > 0 ? $groupId : null, // int类型
'wechatAccountId' => $wechatAccountId,
'status' => $inviteSuccess ? self::STATUS_ADMIN_FRIEND_ADDED : self::STATUS_FAILED,
'memberType' => self::MEMBER_TYPE_ADMIN_FRIEND,
'retryCount' => 0,
'chatroomId' => $chatroomId,
'chatroomId' => $chatroomId, // varchar类型
'createTime' => $createTime,
];
}
@@ -429,40 +598,209 @@ class WorkbenchGroupCreateJob
if ($inviteSuccess) {
// 去除成功日志,减少日志空间消耗
} else {
Log::warning("管理员好友拉入失败。工作台ID: {$workbench->id}, 群ID: {$chatroomId}");
Log::warning("管理员好友拉入失败。工作台ID: {$workbench->id}, 群组ID: {$groupId}, 群聊ID: {$chatroomId}");
}
}
/**
* 获取设备列表
* @param Workbench $workbench 工作台
* @param WorkbenchGroupPush $config 配置
* @return array|bool
* 轮询检查群是否创建成功
* @param string $chatroomName 群名称
* @param string $ownerWechatId 群主微信ID
* @param int $wechatAccountId 微信账号ID
* @param string|null $tempGroupId 临时群标识(如果有)
* @return array|int 返回数组包含groupId和chatroomId或只返回groupId兼容旧代码如果未找到返回0
*/
protected function isCreate($workbench, $config, $groupIds = [])
protected function pollGroupCreation($chatroomName, $ownerWechatId, $wechatAccountId, $tempGroupId = null)
{
// 检查发送间隔新逻辑根据startTime、endTime、maxPerDay动态计算
$maxAttempts = 10; // 最多查询10次
$interval = 5; // 每次间隔5秒
// 获取账号IDaccountId和微信账号的微信IDwechatAccountWechatId用于查询s2_wechat_chatroom表
$accountInfo = Db::table('s2_wechat_account')
->where('id', $wechatAccountId)
->field('id,wechatId')
->find();
$accountId = $accountInfo['id'] ?? null;
$wechatAccountWechatId = $accountInfo['wechatId'] ?? null;
if (empty($accountId) && empty($wechatAccountWechatId)) {
Log::warning("无法获取账号ID和微信账号ID跳过轮询。微信账号ID: {$wechatAccountId}");
return 0;
}
// 获取授权信息(用于调用同步接口)
$username = Env::get('api.username2', '');
$password = Env::get('api.password2', '');
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
// 等待5秒第一次立即查询后续等待
if ($attempt > 1) {
sleep($interval);
}
// 1. 先调用接口同步最新的群组信息
try {
$chatroomController = new \app\api\controller\WechatChatroomController();
// 构建同步参数
$syncData = [
'wechatAccountKeyword' => $ownerWechatId, // 通过群主微信ID筛选
'isDeleted' => false,
'pageIndex' => 0,
'pageSize' => 5 // 获取足够多的数据
];
// 调用getlist方法同步数据内部调用isInner=true
$chatroomController->getlist($syncData, true, 0);
} catch (\Exception $e) {
Log::warning("同步群组信息失败: " . $e->getMessage());
// 即使同步失败,也继续查询本地数据
}
// 2. 查询本地表 s2_wechat_chatroom
// 计算5分钟前的时间戳
$fiveMinutesAgo = time() - 300; // 5分钟 = 300秒
$now = time();
// 查询群聊通过群名称、账号ID或微信账号ID和创建时间查询
// 如果accountId不为空优先使用accountId查询如果accountId为空则使用wechatAccountWechatId查询
$chatroom = Db::table('s2_wechat_chatroom')
->where('nickname', $chatroomName)
->where('isDeleted', 0)
->where('createTime', '>=', $fiveMinutesAgo) // 创建时间在5分钟内
->where('createTime', '<=', $now)
->where('wechatAccountWechatId', $wechatAccountWechatId)
->order('createTime', 'desc')
->find();
// 如果找到了群聊返回群ID和chatroomId
if ($chatroom && !empty($chatroom['id'])) {
$chatroomId = !empty($chatroom['chatroomId']) ? (string)$chatroom['chatroomId'] : null;
// 如果有chatroomId尝试查询wechat_group表获取groupId
$groupId = $chatroom['id'];
Log::info("轮询检查群创建成功。群名称: {$chatroomName}, 群聊ID: {$chatroom['id']}, chatroomId: {$chatroomId}, 群组ID: {$groupId}, 尝试次数: {$attempt}");
return [
'groupId' => $groupId > 0 ? $groupId : intval($chatroom['id']), // 如果没有groupId使用chatroom的id
'chatroomId' => $chatroomId ?: (string)$chatroom['id']
];
}
Log::debug("轮询检查群创建中。群名称: {$chatroomName}, 尝试次数: {$attempt}/{$maxAttempts}");
}
// 10次查询后仍未找到返回0表示失败
Log::warning("轮询检查群创建失败,已查询{$maxAttempts}次仍未找到群组。群名称: {$chatroomName}, 群主微信ID: {$ownerWechatId}, 账号ID: {$accountId}");
return 0;
}
/**
* 检查是否在时间范围内
* @param array $config 配置
* @return bool
*/
protected function isWithinTimeRange($config)
{
if (empty($config['startTime']) || empty($config['endTime'])) {
return true; // 如果没有配置时间,则允许执行
}
$today = date('Y-m-d');
$startTimestamp = strtotime($today . ' ' . $config['startTime'] . ':00');
$endTimestamp = strtotime($today . ' ' . $config['endTime'] . ':00');
// 如果时间不符,则跳过
if ($startTimestamp > time() || $endTimestamp < time() || empty($groupIds)) {
$currentTime = time();
// 如果开始时间大于当前时间,还未到执行时间
if ($startTimestamp > $currentTime) {
return false;
}
// 查询今日建群数量
$count = Db::name('wechat_group')
->whereIn('id', $groupIds)
->whereTime('createTime', 'between', [$startTimestamp, $endTimestamp])
->count();
if ($count >= $config['maxGroupsPerDay']) {
// 如果结束时间小于当前时间,已过执行时间
if ($endTimestamp < $currentTime) {
return false;
}
return true;
}
/**
* 检查每日建群数量限制
* @param int $workbenchId 工作台ID
* @param array $config 配置
* @return bool
*/
protected function checkDailyLimit($workbenchId, $config)
{
if (empty($config['maxGroupsPerDay']) || $config['maxGroupsPerDay'] <= 0) {
return true; // 如果没有配置限制,则允许执行
}
$today = date('Y-m-d');
$startTimestamp = strtotime($today . ' 00:00:00');
$endTimestamp = strtotime($today . ' 23:59:59');
// 查询今日已创建的群数量(状态为成功)
$todayCount = Db::name('workbench_group_create_item')
->where('workbenchId', $workbenchId)
->where('status', self::STATUS_SUCCESS)
->where('groupId', '<>', null) // 排除groupId为NULL的记录
->where('createTime', 'between', [$startTimestamp, $endTimestamp])
->group('groupId')
->count();
return $todayCount < $config['maxGroupsPerDay'];
}
/**
* 从指定的微信群组中获取成员
* @param array $wechatGroups 群组ID数组可能是好友ID或群组ID
* @param array $groupMember 群主成员微信ID数组引用传递
* @param array $groupMemberId 群主成员好友ID数组引用传递
* @param array $groupMemberWechatId 群主成员微信ID映射引用传递
*/
protected function addGroupMembersFromWechatGroups($wechatGroups, &$groupMember, &$groupMemberId, &$groupMemberWechatId)
{
foreach ($wechatGroups as $groupId) {
if (is_numeric($groupId)) {
// 数字ID可能是好友ID查询好友信息
$friend = Db::table('s2_wechat_friend')
->where('id', $groupId)
->where('isDeleted', 0)
->field('id,wechatId,ownerWechatId')
->find();
if ($friend) {
// 添加到群主成员
if (!in_array($friend['ownerWechatId'], $groupMember)) {
$groupMember[] = $friend['ownerWechatId'];
}
if (!isset($groupMemberWechatId[$friend['id']])) {
$groupMemberWechatId[$friend['id']] = $friend['wechatId'];
$groupMemberId[] = $friend['id'];
}
} else {
// 如果不是好友ID可能是群组ID查询群组信息
$group = Db::name('wechat_group')
->where('id', $groupId)
->where('deleteTime', 0)
->field('ownerWechatId')
->find();
if ($group && !in_array($group['ownerWechatId'], $groupMember)) {
$groupMember[] = $group['ownerWechatId'];
}
}
} else {
// 字符串ID手动创建的群组可能是wechatId
if (!in_array($groupId, $groupMember)) {
$groupMember[] = $groupId;
}
}
}
}
/**
* 记录任务开始
@@ -510,4 +848,5 @@ class WorkbenchGroupCreateJob
return false;
}
}

View File

@@ -216,7 +216,7 @@ class Adapter implements WeChatServiceInterface
// 根据健康分判断24h内加的好友数量限制
$healthScoreService = new WechatAccountHealthScoreService();
$healthScoreInfo = $healthScoreService->getHealthScore($accountId);
// 如果健康分记录不存在,先计算一次
if (empty($healthScoreInfo)) {
try {
@@ -228,10 +228,10 @@ class Adapter implements WeChatServiceInterface
$maxAddFriendPerDay = 5;
}
}
// 获取每日最大加人次数(基于健康分)
$maxAddFriendPerDay = $healthScoreInfo['maxAddFriendPerDay'] ?? 5;
// 如果健康分为0或很低不允许添加好友
if ($maxAddFriendPerDay <= 0) {
Log::info("账号健康分过低,不允许添加好友 (accountId: {$accountId}, wechatId: {$wechatId}, healthScore: " . ($healthScoreInfo['healthScore'] ?? 0) . ")");
@@ -367,6 +367,10 @@ class Adapter implements WeChatServiceInterface
if (!empty($wechatId)) {
$isFriend = $this->checkIfIsWeChatFriendByPhone($wechatId, $task['phone']);
if ($isFriend) {
// 更新状态为5已通过未发消息
Db::name('task_customer')
->where('id', $task['id'])
->update(['status' => 5,'passTime' => time(), 'updateTime' => time()]);
$passedWeChatId = $wechatId;
break;
}