自动建群提交 + 触客宝发布朋友圈优化

This commit is contained in:
wong
2026-01-04 17:03:51 +08:00
parent 08d2a811b7
commit b4c813311b
39 changed files with 16264 additions and 504 deletions

View File

@@ -219,6 +219,48 @@
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;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons";
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";
@@ -82,6 +82,9 @@ const GroupSelectionWithMembers: React.FC<GroupSelectionWithMembersProps> = ({
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[]) => {
@@ -110,24 +113,66 @@ const GroupSelectionWithMembers: React.FC<GroupSelectionWithMembersProps> = ({
const handleOpenMemberSelection = async (groupId: string) => {
if (readonly) return;
setMemberSelectionVisible({ visible: true, groupId });
setMemberSearchKeyword(""); // 重置搜索关键词
// 如果还没有加载过该群的成员列表,则加载
if (!allMembers[groupId]) {
// 如果还没有加载过该群的成员列表,则加载所有成员(不使用搜索关键词)
if (!allMembers[groupId] && !fullMembersCache[groupId]) {
setLoadingMembers(true);
try {
const members = await getGroupMembers(groupId);
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] }));
}
};
// 选择成员
@@ -328,6 +373,36 @@ const GroupSelectionWithMembers: React.FC<GroupSelectionWithMembersProps> = ({
</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}>

View File

@@ -99,13 +99,18 @@ export default function ContentForm() {
: []);
setSelectedDevices(deviceOptions || []);
setSelectedFriends(data.sourceFriends || []);
setSelectedGroups(data.selectedGroups || []);
// 使用 wechatGroupsOptions 作为群列表数据
setSelectedGroupsOptions(data.wechatGroupsOptions || data.selectedGroupsOptions || []);
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[] = (data.wechatGroupsOptions || data.selectedGroupsOptions || []).map(
const groupsWithMembers: GroupWithMembers[] = groupsOptions.map(
(group: any) => {
const groupIdStr = String(group.id);
const members = groupMembersMap[groupIdStr] || [];

View File

@@ -75,7 +75,7 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
useEffect(() => {
if (!visible || !ruleId) return;
setLoading(true);
setLoading(true);
const params: { startTime?: string; endTime?: string } = {};
if (startTime) {
params.startTime = formatDate(startTime);
@@ -85,26 +85,26 @@ const PoolListModal: React.FC<PoolListModalProps> = ({
}
getFriendRequestTaskStats(ruleId.toString(), params)
.then(res => {
console.log(res);
setXData(res.dateArray);
setYData([
res.allNumArray,
res.errorNumArray,
res.passNumArray,
res.passRateArray,
res.successNumArray,
res.successRateArray,
]);
setStatistics(res.totalStats);
.then(res => {
console.log(res);
setXData(res.dateArray);
setYData([
res.allNumArray,
res.errorNumArray,
res.passNumArray,
res.passRateArray,
res.successNumArray,
res.successRateArray,
]);
setStatistics(res.totalStats);
})
.catch(error => {
console.error("获取统计数据失败:", error);
message.error("获取统计数据失败");
})
.finally(() => {
setLoading(false);
});
})
.finally(() => {
setLoading(false);
});
}, [visible, ruleId, startTime, endTime, formatDate]);
const title = ruleName ? `${ruleName} - 累计统计数据` : "累计统计数据";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -692,3 +692,9 @@
pointer-events: none;
z-index: 1;
}
.statusSwitchContainer {
display: flex;
align-items: center;
justify-content: space-between;
}

View File

@@ -37,12 +37,13 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
onChange({
executor: selectedDevice,
executorId: selectedDevice.id,
groupName: executorName ? `${executorName}的群` : "", // 设置为"XXX的群"格式
groupNameTemplate: executorName ? `${executorName}的群` : "", // 设置为"XXX的群"格式
});
} else {
onChange({
executor: undefined,
executorId: undefined,
groupNameTemplate: "", // 清空群名称
});
}
setExecutorSelectionVisible(false);
@@ -51,7 +52,7 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
// 处理固定微信号选择必须3个
const handleFixedWechatIdsSelect = (friends: FriendSelectionItem[]) => {
// 检查总数是否超过3个包括已添加的手动微信号
const currentManualCount = (formData.fixedWechatIdsOptions || []).filter(f => f.isManual).length;
const currentManualCount = (formData.wechatGroupsOptions || []).filter(f => f.isManual).length;
const newSelectedCount = friends.length;
if (currentManualCount + newSelectedCount > 3) {
Toast.show({ content: "固定微信号最多只能选择3个", position: "top" });
@@ -60,10 +61,10 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
// 标记为选择的(非手动添加),确保所有从选择弹窗来的都标记为非手动
const selectedFriends = friends.map(f => ({ ...f, isManual: false }));
// 合并已添加的手动微信号和新的选择
const manualFriends = (formData.fixedWechatIdsOptions || []).filter(f => f.isManual === true);
const manualFriends = (formData.wechatGroupsOptions || []).filter(f => f.isManual === true);
onChange({
fixedWechatIds: [...manualFriends, ...selectedFriends].map(f => f.id),
fixedWechatIdsOptions: [...manualFriends, ...selectedFriends],
wechatGroups: [...manualFriends, ...selectedFriends].map(f => f.id),
wechatGroupsOptions: [...manualFriends, ...selectedFriends],
});
setFixedWechatIdsSelectionVisible(false);
};
@@ -77,6 +78,27 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
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) {
@@ -99,12 +121,12 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
Toast.show({ content: "请输入微信号", position: "top" });
return;
}
const existingIds = formData.fixedWechatIdsOptions.map(f => f.wechatId.toLowerCase());
const existingIds = formData.wechatGroupsOptions.map(f => f.wechatId.toLowerCase());
if (existingIds.includes(manualWechatIdInput.trim().toLowerCase())) {
Toast.show({ content: "该微信号已添加", position: "top" });
return;
}
if (formData.fixedWechatIdsOptions.length >= 3) {
if (formData.wechatGroupsOptions.length >= 3) {
Toast.show({ content: "固定微信号最多只能添加3个", position: "top" });
return;
}
@@ -117,18 +139,26 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
isManual: true, // 标记为手动添加
};
onChange({
fixedWechatIds: [...formData.fixedWechatIds, newFriend.id],
fixedWechatIdsOptions: [...formData.fixedWechatIdsOptions, newFriend],
wechatGroups: [...formData.wechatGroups, newFriend.id],
wechatGroupsOptions: [...formData.wechatGroupsOptions, newFriend],
});
setManualWechatIdInput("");
};
// 移除固定微信号
const handleRemoveFixedWechatId = (id: number) => {
onChange({
fixedWechatIds: formData.fixedWechatIds.filter(fid => fid !== id),
fixedWechatIdsOptions: formData.fixedWechatIdsOptions.filter(f => f.id !== id),
});
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);
};
// 暴露方法给父组件
@@ -144,14 +174,14 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
return false;
}
// 固定微信号不是必填的,移除验证
if (!formData.groupName?.trim()) {
Toast.show({ content: "请输入群名称", position: "top" });
if (!formData.groupNameTemplate?.trim()) {
Toast.show({ content: "请输入群名称模板", position: "top" });
return false;
}
// 群名称长度验证2-100个字符
const groupNameLength = formData.groupName.trim().length;
if (groupNameLength < 2 || groupNameLength > 100) {
Toast.show({ content: "群名称长度应在2-100个字符之间", position: "top" });
// 群名称模板长度验证2-100个字符
const groupNameTemplateLength = formData.groupNameTemplate.trim().length;
if (groupNameTemplateLength < 2 || groupNameTemplateLength > 100) {
Toast.show({ content: "群名称模板长度应在2-100个字符之间", position: "top" });
return false;
}
return true;
@@ -163,8 +193,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
// 区分已选择的微信号(从下拉选择)和已添加的微信号(手动输入)
// 如果 isManual 未定义,默认为 false即选择的
const selectedWechatIds = (formData.fixedWechatIdsOptions || []).filter(f => !f.isManual);
const manualAddedWechatIds = (formData.fixedWechatIdsOptions || []).filter(f => f.isManual === true);
const selectedWechatIds = (formData.wechatGroupsOptions || []).filter(f => !f.isManual);
const manualAddedWechatIds = (formData.wechatGroupsOptions || []).filter(f => f.isManual === true);
return (
<div className={style.container}>
@@ -355,14 +385,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
</div>
{formData.groupAdminEnabled && (
<div
className={`${style.wechatSelectInput} ${(selectedWechatIds.length + manualAddedWechatIds.length) === 0 ? style.disabled : ''}`}
onClick={() => {
if ((selectedWechatIds.length + manualAddedWechatIds.length) === 0) {
Toast.show({ content: "请先选择固定微信号", position: "top" });
return;
}
setGroupAdminSelectionVisible(true);
}}
className={`${style.wechatSelectInput} ${selectedWechatIds.length === 0 ? style.disabled : ''}`}
onClick={handleOpenGroupAdminSelection}
>
<div className={style.selectInputWrapper}>
<span className={style.selectInputPlaceholder}>
@@ -383,8 +407,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
<input
type="text"
className={style.input}
value={formData.groupName}
onChange={e => onChange({ groupName: e.target.value })}
value={formData.groupNameTemplate}
onChange={e => onChange({ groupNameTemplate: e.target.value })}
placeholder="请输入群名称"
/>
</div>
@@ -448,8 +472,8 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
<input
type="time"
className={style.timeInput}
value={formData.executeTime || "09:00"}
onChange={e => onChange({ executeTime: e.target.value || "09:00" })}
value={formData.startTime || "09:00"}
onChange={e => onChange({ startTime: e.target.value || "09:00" })}
/>
<ClockCircleOutlined className={style.timeIcon} />
</div>
@@ -458,21 +482,25 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
<input
type="time"
className={style.timeInput}
value={formData.executeEndTime || "18:00"}
onChange={e => onChange({ executeEndTime: e.target.value || "18:00" })}
value={formData.endTime || "21:00"}
onChange={e => onChange({ endTime: e.target.value || "21:00" })}
/>
<ClockCircleOutlined className={style.timeIcon} />
</div>
</div>
</div>
{/* 错误提示 */}
{(selectedWechatIds.length + manualAddedWechatIds.length) !== 3 && (
<div className={style.errorTip}>
<ExclamationCircleOutlined className={style.errorIcon} />
<span className={style.errorText}>3</span>
{/* 是否启用 */}
<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" }}>
@@ -518,12 +546,14 @@ const BasicSettings = forwardRef<BasicSettingsRef, BasicSettingsProps>(
</button>
</div>
<div style={{ maxHeight: "50vh", overflowY: "auto" }}>
{(selectedWechatIds.length + manualAddedWechatIds.length) === 0 ? (
{selectedWechatIds.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px", color: "#94a3b8" }}>
{manualAddedWechatIds.length > 0
? "群管理员只能从已选择的微信号中选择,不能选择手动添加的微信号"
: "暂无固定微信号可选"}
</div>
) : (
[...selectedWechatIds, ...manualAddedWechatIds].map(friend => {
selectedWechatIds.map(friend => {
const isSelected = formData.groupAdminWechatIdOption?.id === friend.id;
return (
<div

View File

@@ -10,25 +10,25 @@ export interface DeviceSelectionStepRef {
}
interface DeviceSelectionStepProps {
selectedDevices?: DeviceSelectionItem[];
selectedDeviceIds?: number[];
deviceGroupsOptions?: DeviceSelectionItem[];
deviceGroups?: number[];
onSelect: (devices: DeviceSelectionItem[]) => void;
}
const DeviceSelectionStep = forwardRef<DeviceSelectionStepRef, DeviceSelectionStepProps>(
({ selectedDevices = [], selectedDeviceIds = [], onSelect }, ref) => {
({ deviceGroupsOptions = [], deviceGroups = [], onSelect }, ref) => {
const [deviceSelectionVisible, setDeviceSelectionVisible] = useState(false);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
validate: async () => {
if (selectedDeviceIds.length === 0) {
if (deviceGroups.length === 0) {
return false;
}
return true;
},
getValues: () => {
return { selectedDevices, selectedDeviceIds };
return { deviceGroupsOptions, deviceGroups };
},
}));
@@ -48,10 +48,10 @@ const DeviceSelectionStep = forwardRef<DeviceSelectionStepRef, DeviceSelectionSt
className={style.deviceSelector}
onClick={() => setDeviceSelectionVisible(true)}
>
{selectedDevices.length > 0 ? (
{deviceGroupsOptions.length > 0 ? (
<div className={style.selectedDevicesInfo}>
<span className={style.selectedCountText}>
{selectedDevices.length}
{deviceGroupsOptions.length}
</span>
</div>
) : (
@@ -64,9 +64,9 @@ const DeviceSelectionStep = forwardRef<DeviceSelectionStepRef, DeviceSelectionSt
</div>
{/* 已选设备列表 */}
{selectedDevices.length > 0 && (
{deviceGroupsOptions.length > 0 && (
<div className={style.selectedDevicesGrid}>
{selectedDevices.map((device) => (
{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}`}>
@@ -88,8 +88,8 @@ const DeviceSelectionStep = forwardRef<DeviceSelectionStepRef, DeviceSelectionSt
className={style.deviceCardDeleteButton}
onClick={(e) => {
e.stopPropagation();
const newSelectedDevices = selectedDevices.filter(d => d.id !== device.id);
onSelect(newSelectedDevices);
const newDeviceGroupsOptions = deviceGroupsOptions.filter(d => d.id !== device.id);
onSelect(newDeviceGroupsOptions);
}}
>
<DeleteOutlined className={style.deviceCardDeleteIcon} />
@@ -102,7 +102,7 @@ const DeviceSelectionStep = forwardRef<DeviceSelectionStepRef, DeviceSelectionSt
{/* 设备选择弹窗 */}
<div style={{ display: "none" }}>
<DeviceSelection
selectedOptions={selectedDevices}
selectedOptions={deviceGroupsOptions}
onSelect={handleDeviceSelect}
placeholder="选择设备"
showInput={false}

View File

@@ -19,24 +19,22 @@ const steps = [
const defaultForm: GroupCreateFormData = {
planType: 1, // 默认独立计划
name: "新建群计划",
name: "",
executorId: undefined,
executor: undefined,
selectedDevices: [],
selectedDeviceIds: [],
fixedWechatIds: [],
fixedWechatIdsOptions: [],
deviceGroupsOptions: [],
deviceGroups: [],
wechatGroups: [],
wechatGroupsOptions: [],
groupAdminEnabled: false,
groupAdminWechatId: undefined,
groupAdminWechatIdOption: undefined,
groupName: "卡若001",
groupMethod: 0, // 默认所有好友自动分组
maxGroupsPerDay: 10,
groupNameTemplate: "",
maxGroupsPerDay: 20,
groupSizeMin: 3,
groupSizeMax: 38,
executeType: 1, // 默认定时执行
executeTime: "09:00", // 默认开始时间
executeEndTime: "18:00", // 默认结束时间
startTime: "09:00", // 默认开始时间
endTime: "21:00", // 默认结束时间
poolGroups: [],
poolGroupsOptions: [],
status: 1, // 默认启用
@@ -61,30 +59,41 @@ const GroupCreateForm: React.FC = () => {
// 获取详情并回填表单
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: res.id,
planType: res.planType ?? 1,
id: String(res.id),
planType: config.planType ?? res.planType ?? 1,
name: res.name ?? "",
executorId: res.executorId,
executor: res.executor,
selectedDevices: res.selectedDevices || [],
selectedDeviceIds: res.selectedDeviceIds || [],
fixedWechatIds: res.fixedWechatIds || [],
fixedWechatIdsOptions: res.fixedWechatIdsOptions || [],
groupAdminEnabled: res.groupAdminEnabled ?? false,
groupAdminWechatId: res.groupAdminWechatId,
groupAdminWechatIdOption: res.groupAdminWechatIdOption,
groupName: res.groupName ?? "",
groupMethod: res.groupMethod ?? 0,
maxGroupsPerDay: res.maxGroupsPerDay ?? 10,
groupSizeMin: res.groupSizeMin ?? 3,
groupSizeMax: res.groupSizeMax ?? 38,
executeType: res.executeType ?? 1,
executeTime: res.executeTime ?? "09:00",
executeEndTime: res.executeEndTime ?? "18:00",
poolGroups: res.poolGroups || [],
poolGroupsOptions: res.poolGroupsOptions || [],
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);
@@ -163,9 +172,7 @@ const GroupCreateForm: React.FC = () => {
await createGroupCreate(submitData);
Toast.show({ content: "创建成功" });
}
// 注意:需要导航到列表页面,但目前列表页面还未创建
// navigate("/workspace/group-create");
navigate("/workspace");
navigate("/workspace/group-create");
} catch (e: any) {
Toast.show({ content: e?.message || "提交失败" });
} finally {
@@ -194,13 +201,13 @@ const GroupCreateForm: React.FC = () => {
return (
<DeviceSelectionStep
ref={deviceSelectionStepRef}
selectedDevices={formData.selectedDevices || []}
selectedDeviceIds={formData.selectedDeviceIds || []}
deviceGroupsOptions={formData.deviceGroupsOptions || []}
deviceGroups={formData.deviceGroups || []}
onSelect={(devices) => {
const deviceIds = devices.map(d => d.id);
handleFormDataChange({
selectedDevices: devices,
selectedDeviceIds: deviceIds,
deviceGroupsOptions: devices,
deviceGroups: deviceIds,
// 如果只有一个设备,也设置 executor 和 executorId 用于兼容
executor: devices.length === 1 ? devices[0] : undefined,
executorId: devices.length === 1 ? devices[0].id : undefined,

View File

@@ -9,21 +9,19 @@ export interface GroupCreateFormData {
name: string; // 计划名称
executor?: DeviceSelectionItem; // 执行智能体(执行者)- 单个设备(保留用于兼容)
executorId?: number; // 执行智能体ID设备ID保留用于兼容
selectedDevices?: DeviceSelectionItem[]; // 选中的设备列表
selectedDeviceIds?: number[]; // 选中的设备ID列表
fixedWechatIds: number[]; // 固定微信号ID列表必须3个
fixedWechatIdsOptions: FriendSelectionItem[]; // 固定微信号选项
deviceGroupsOptions?: DeviceSelectionItem[]; // 选中的设备列表
deviceGroups?: number[]; // 选中的设备ID列表
wechatGroups: number[]; // 固定微信号ID列表必须3个
wechatGroupsOptions: FriendSelectionItem[]; // 固定微信号选项
groupAdminEnabled: boolean; // 群管理员开关
groupAdminWechatId?: number; // 群管理员微信号ID
groupAdminWechatIdOption?: FriendSelectionItem; // 群管理员微信号选项
groupName: string; // 群名称
groupMethod: number; // 分组方式0-所有好友自动分组1-指定群数
maxGroupsPerDay: number; // 每日最大建群数当groupMethod为1时使用
groupNameTemplate: string; // 群名称模板
maxGroupsPerDay: number; // 每日最大建群数
groupSizeMin: number; // 群组最小人数
groupSizeMax: number; // 群组最大人数
executeType: number; // 执行类型0-立即执行1-定时执行
executeTime?: string; // 执行开始时间HH:mm默认 09:00
executeEndTime?: string; // 执行结束时间HH:mm默认 18:00
startTime?: string; // 执行开始时间HH:mm默认 09:00
endTime?: string; // 执行结束时间HH:mm默认 21:00
poolGroups?: string[]; // 流量池ID列表
poolGroupsOptions?: PoolSelectionItem[]; // 流量池选项列表
status: number; // 是否启用 (1: 启用, 0: 禁用)
@@ -44,7 +42,7 @@ export const formValidationRules = {
{ min: 2, max: 50, message: "计划名称长度应在2-50个字符之间" },
],
executorId: [{ required: true, message: "请选择执行智能体" }],
fixedWechatIds: [
wechatGroups: [
{ required: true, message: "请选择固定微信号" },
{
validator: (_: any, value: number[]) => {
@@ -55,9 +53,9 @@ export const formValidationRules = {
},
},
],
groupName: [
{ required: true, message: "请输入群名称" },
{ min: 2, max: 100, message: "群名称长度应在2-100个字符之间" },
groupNameTemplate: [
{ required: true, message: "请输入群名称模板" },
{ min: 2, max: 100, message: "群名称模板长度应在2-100个字符之间" },
],
maxGroupsPerDay: [
{ required: true, message: "请输入每日最大建群数" },
@@ -76,28 +74,4 @@ export const formValidationRules = {
{ required: true, message: "请输入群组最大人数" },
{ type: "number", min: 1, max: 500, message: "群组最大人数应在1-500之间" },
],
executeDate: [
{
validator: (_: any, value: string, callback: any) => {
// 如果执行类型是定时执行,则必填
const executeType = callback?.executeType ?? 1;
if (executeType === 1 && !value) {
return Promise.reject(new Error("请选择执行日期"));
}
return Promise.resolve();
},
},
],
executeTime: [
{
validator: (_: any, value: string, callback: any) => {
// 如果执行类型是定时执行,则必填
const executeType = callback?.executeType ?? 1;
if (executeType === 1 && !value) {
return Promise.reject(new Error("请选择执行时间"));
}
return Promise.resolve();
},
},
],
};

View File

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

View File

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

View File

@@ -62,6 +62,17 @@ const Workspace: React.FC = () => {
path: "/workspace/auto-group",
bgColor: "#f6ffed",
},
{
id: "group-create",
name: "自动建群(新版)",
description: "智能拉好友建群",
icon: (
<TeamOutlined className={styles.icon} style={{ color: "#52c41a" }} />
),
path: "/workspace/group-create",
bgColor: "#f6ffed",
isNew: true,
},
{
id: "traffic-distribution",
name: "流量分发",

View File

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

View File

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

View File

@@ -5,7 +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";
@@ -76,6 +80,11 @@ const workspaceRoutes = [
auth: true,
},
// 自动建群(新版)
{
path: "/workspace/group-create",
element: <GroupCreateList />,
auth: true,
},
{
path: "/workspace/group-create/new",
element: <GroupCreateForm />,
@@ -83,6 +92,21 @@ const workspaceRoutes = [
},
{
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,
},