Merge branch 'develop' into feature/yongpxu-dev
This commit is contained in:
@@ -142,7 +142,11 @@ export default function SelectionPopup({
|
||||
// 复制一份selectedOptions到临时变量
|
||||
setTempSelectedOptions([...selectedOptions]);
|
||||
fetchGroups(1, "");
|
||||
} else {
|
||||
// 弹窗关闭时重置状态
|
||||
setTempSelectedOptions([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible]);
|
||||
|
||||
// 搜索防抖(只在弹窗打开且搜索词变化时执行)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
438
Cunkebao/src/components/GroupSelectionWithMembers/index.tsx
Normal file
438
Cunkebao/src/components/GroupSelectionWithMembers/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -60,6 +60,7 @@ export const defFormData: FormData = {
|
||||
enabled: true,
|
||||
remarkFormat: "",
|
||||
addFriendInterval: 1,
|
||||
tips: "请注意消息,稍后加你微信",
|
||||
posters: [],
|
||||
device: [],
|
||||
customTags: [],
|
||||
|
||||
@@ -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 || "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("获客", "")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -221,7 +221,7 @@ const AddChannelModal: React.FC<AddChannelModalProps> = ({
|
||||
setQrCodeData(null);
|
||||
setScanning(false);
|
||||
const timer = setTimeout(() => {
|
||||
handleGenerateQRCode();
|
||||
handleGenerateQRCode();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
74
Cunkebao/src/pages/mobile/workspace/group-create/form/api.ts
Normal file
74
Cunkebao/src/pages/mobile/workspace/group-create/form/api.ts
Normal 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");
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
291
Cunkebao/src/pages/mobile/workspace/group-create/form/index.tsx
Normal file
291
Cunkebao/src/pages/mobile/workspace/group-create/form/index.tsx
Normal 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;
|
||||
@@ -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之间" },
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
344
Cunkebao/src/pages/mobile/workspace/group-create/list/index.tsx
Normal file
344
Cunkebao/src/pages/mobile/workspace/group-create/list/index.tsx
Normal 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;
|
||||
92
Cunkebao/src/pages/mobile/workspace/group-create/新增字段说明.md
Normal file
92
Cunkebao/src/pages/mobile/workspace/group-create/新增字段说明.md
Normal 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
|
||||
@@ -10,5 +10,10 @@ export function getWechatStats() {
|
||||
return request("/v1/dashboard/wechat-stats", {}, "GET");
|
||||
}
|
||||
|
||||
// 获取常用功能列表
|
||||
export function getCommonFunctions() {
|
||||
return request("/v1/workbench/common-functions", {}, "GET");
|
||||
}
|
||||
|
||||
// 你可以根据需要继续添加其他接口
|
||||
// 例如:场景获客统计、今日数据统计等
|
||||
|
||||
@@ -52,8 +52,10 @@
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
.iconImage {
|
||||
width: 75%;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.featureHeader {
|
||||
|
||||
@@ -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智能助手 */}
|
||||
|
||||
@@ -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 || [],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -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群公告
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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', // 自动分配微信群聊
|
||||
|
||||
@@ -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'); // 获取常用功能列表
|
||||
});
|
||||
|
||||
// 内容库相关
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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, // 当前可提现金额
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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秒
|
||||
|
||||
// 获取账号ID(accountId)和微信账号的微信ID(wechatAccountWechatId),用于查询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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user