Merge branch 'yongpxu-dev' into develop

This commit is contained in:
笔记本里的永平
2025-07-23 11:35:59 +08:00
89 changed files with 12706 additions and 1267 deletions

View File

@@ -1,4 +1,5 @@
# 基础环境变量示例
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_API_BASE_URL=http://www.yishi.com
# VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=Nkebao Base

33
nkebao/src/api/common.ts Normal file
View File

@@ -0,0 +1,33 @@
import request from "./request";
/**
* 通用文件上传方法(支持图片、文件)
* @param {File} file - 要上传的文件对象
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
* @returns {Promise<string>} - 上传成功后返回文件url
*/
export async function uploadFile(
file: File,
uploadUrl: string = "/v1/attachment/upload"
): Promise<string> {
try {
// 创建 FormData 对象用于文件上传
const formData = new FormData();
formData.append("file", file);
// 使用 request 方法上传文件,设置正确的 Content-Type
const res = await request(uploadUrl, formData, "POST", {
headers: {
"Content-Type": "multipart/form-data",
},
});
// 检查响应结果
if (res?.code === 200 && res?.data?.url) {
return res.data.url;
} else {
throw new Error(res?.msg || "文件上传失败");
}
} catch (e: any) {
throw new Error(e?.message || "文件上传失败");
}
}

44
nkebao/src/api/devices.ts Normal file
View File

@@ -0,0 +1,44 @@
import request from "./request";
// 获取设备列表
export const fetchDeviceList = (params: {
page?: number;
limit?: number;
keyword?: string;
}) => request("/v1/devices", params, "GET");
// 获取设备详情
export const fetchDeviceDetail = (id: string | number) =>
request(`/v1/devices/${id}`);
// 获取设备关联微信账号
export const fetchDeviceRelatedAccounts = (id: string | number) =>
request(`/v1/wechats/related-device/${id}`);
// 获取设备操作日志
export const fetchDeviceHandleLogs = (
id: string | number,
page = 1,
limit = 10
) => request(`/v1/devices/${id}/handle-logs`, { page, limit }, "GET");
// 更新设备任务配置
export const updateDeviceTaskConfig = (config: {
deviceId: string | number;
autoAddFriend?: boolean;
autoReply?: boolean;
momentsSync?: boolean;
aiChat?: boolean;
}) => request("/v1/devices/task-config", config, "POST");
// 删除设备
export const deleteDevice = (id: number) =>
request(`/v1/devices/${id}`, undefined, "DELETE");
// 获取设备二维码
export const fetchDeviceQRCode = (accountId: string) =>
request("/v1/api/device/add", { accountId }, "POST");
// 通过IMEI添加设备
export const addDeviceByImei = (imei: string, name: string) =>
request("/v1/api/device/add-by-imei", { imei, name }, "POST");

View File

@@ -1,22 +1,27 @@
import axios, { AxiosInstance, AxiosRequestConfig, Method, AxiosResponse } from 'axios';
import { Toast } from 'antd-mobile';
import axios, {
AxiosInstance,
AxiosRequestConfig,
Method,
AxiosResponse,
} from "axios";
import { Toast } from "antd-mobile";
const DEFAULT_DEBOUNCE_GAP = 1000;
const debounceMap = new Map<string, number>();
const instance: AxiosInstance = axios.create({
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || '/api',
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || "/api",
timeout: 10000,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
instance.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers = config.headers || {};
config.headers['Authorization'] = `Bearer ${token}`;
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
@@ -27,20 +32,20 @@ instance.interceptors.response.use(
if (code === 200 || success) {
return res.data.data ?? res.data;
}
Toast.show({ content: msg || '接口错误', position: 'top' });
Toast.show({ content: msg || "接口错误", position: "top" });
if (code === 401) {
localStorage.removeItem('token');
localStorage.removeItem("token");
const currentPath = window.location.pathname + window.location.search;
if (currentPath === '/login') {
window.location.href = '/login';
if (currentPath === "/login") {
window.location.href = "/login";
} else {
window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
}
}
return Promise.reject(msg || '接口错误');
return Promise.reject(msg || "接口错误");
},
err => {
Toast.show({ content: err.message || '网络异常', position: 'top' });
(err) => {
Toast.show({ content: err.message || "网络异常", position: "top" });
return Promise.reject(err);
}
);
@@ -48,17 +53,18 @@ instance.interceptors.response.use(
export function request(
url: string,
data?: any,
method: Method = 'GET',
method: Method = "GET",
config?: AxiosRequestConfig,
debounceGap?: number
): Promise<any> {
const gap = typeof debounceGap === 'number' ? debounceGap : DEFAULT_DEBOUNCE_GAP;
const gap =
typeof debounceGap === "number" ? debounceGap : DEFAULT_DEBOUNCE_GAP;
const key = `${method}_${url}_${JSON.stringify(data)}`;
const now = Date.now();
const last = debounceMap.get(key) || 0;
if (gap > 0 && now - last < gap) {
Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
return Promise.reject('请求过于频繁,请稍后再试');
// Toast.show({ content: '请求过于频繁,请稍后再试', position: 'top' });
return Promise.reject("请求过于频繁,请稍后再试");
}
debounceMap.set(key, now);
@@ -67,7 +73,7 @@ export function request(
method,
...config,
};
if (method.toUpperCase() === 'GET') {
if (method.toUpperCase() === "GET") {
axiosConfig.params = data;
} else {
axiosConfig.data = data;

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取设备列表
export function getDeviceList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/devices", params, "GET");
}

View File

@@ -0,0 +1,188 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
z-index: 10;
font-size: 18px;
}
.input {
padding-left: 38px !important;
height: 56px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.popupTitle {
font-size: 20px;
font-weight: 600;
text-align: center;
}
.popupSearchRow {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
}
.popupSearchInputWrap {
position: relative;
flex: 1;
}
.popupSearchInput {
padding-left: 36px !important;
border-radius: 12px !important;
height: 44px;
font-size: 15px;
border: 1px solid #e5e6eb !important;
background: #f8f9fa;
}
.statusSelect {
width: 120px;
height: 40px;
border-radius: 8px;
border: 1px solid #e5e6eb;
font-size: 15px;
padding: 0 10px;
background: #fff;
}
.deviceList {
flex: 1;
overflow-y: auto;
}
.deviceListInner {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.deviceItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid #f0f0f0;
background: #fff;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.deviceCheckbox {
margin-top: 4px;
}
.deviceInfo {
flex: 1;
}
.deviceInfoRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.deviceName {
font-weight: 500;
font-size: 16px;
color: #222;
}
.statusOnline {
width: 56px;
height: 24px;
border-radius: 12px;
background: #52c41a;
color: #fff;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.statusOffline {
width: 56px;
height: 24px;
border-radius: 12px;
background: #e5e6eb;
color: #888;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.deviceInfoDetail {
font-size: 13px;
color: #888;
margin-top: 4px;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.refreshBtn {
width: 36px;
height: 36px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
border-radius: 16px;
}
.pageInfo {
font-size: 14px;
color: #222;
margin: 0 8px;
}

View File

@@ -0,0 +1,380 @@
import React, { useState, useEffect, useCallback } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Checkbox, Popup, Toast } from "antd-mobile";
import { Input, Button } from "antd";
import { getDeviceList } from "./api";
import style from "./index.module.scss";
import { DeleteOutlined } from "@ant-design/icons";
// 设备选择项接口
interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
}
// 组件属性接口
interface DeviceSelectionProps {
selectedDevices: string[];
onSelect: (devices: string[]) => void;
placeholder?: string;
className?: string;
mode?: "input" | "dialog"; // 新增默认input
open?: boolean; // 仅mode=dialog时生效
onOpenChange?: (open: boolean) => void; // 仅mode=dialog时生效
selectedListMaxHeight?: number; // 新增已选列表最大高度默认500
showInput?: boolean; // 新增
showSelectedList?: boolean; // 新增
readonly?: boolean; // 新增
}
const PAGE_SIZE = 20;
const DeviceSelection: React.FC<DeviceSelectionProps> = ({
selectedDevices,
onSelect,
placeholder = "选择设备",
className = "",
mode = "input",
open,
onOpenChange,
selectedListMaxHeight = 300, // 默认300
showInput = true,
showSelectedList = true,
readonly = false,
}) => {
// 弹窗控制
const [popupVisible, setPopupVisible] = useState(false);
const isDialog = mode === "dialog";
const realVisible = isDialog ? !!open : popupVisible;
const setRealVisible = (v: boolean) => {
if (isDialog && onOpenChange) onOpenChange(v);
if (!isDialog) setPopupVisible(v);
};
// 设备数据
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
// 获取设备列表支持keyword和分页
const fetchDevices = useCallback(
async (keyword: string = "", page: number = 1) => {
setLoading(true);
try {
const res = await getDeviceList({
page,
limit: PAGE_SIZE,
keyword: keyword.trim() || undefined,
});
if (res && Array.isArray(res.list)) {
setDevices(
res.list.map((d: any) => ({
id: d.id?.toString() || "",
name: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
wxid: d.wechatId || "",
nickname: d.nickname || "",
usedInPlans: d.usedInPlans || 0,
}))
);
setTotal(res.total || 0);
}
} catch (error) {
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
},
[]
);
// 打开弹窗时获取第一页
const openPopup = () => {
if (readonly) return;
setSearchQuery("");
setCurrentPage(1);
setRealVisible(true);
fetchDevices("", 1);
};
// 搜索防抖
useEffect(() => {
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchDevices(searchQuery, 1);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, realVisible, fetchDevices]);
// 翻页时重新请求
useEffect(() => {
if (!realVisible) return;
fetchDevices(searchQuery, currentPage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPage]);
// 过滤设备(只保留状态过滤)
const filteredDevices = devices.filter((device) => {
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesStatus;
});
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedDevices.length === 0) return "";
return `已选择 ${selectedDevices.length} 个设备`;
};
// 获取已选设备详细信息
const selectedDeviceObjs = selectedDevices
.map((id) => devices.find((d) => d.id === id))
.filter(Boolean) as DeviceSelectionItem[];
// 删除已选设备
const handleRemoveDevice = (id: string) => {
if (readonly) return;
onSelect(selectedDevices.filter((d) => d !== id));
};
// 弹窗内容
const popupContent = (
<div className={style.popupContainer}>
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
</div>
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<SearchOutlined className={style.inputIcon} />
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={(val) => setSearchQuery(val)}
className={style.popupSearchInput}
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className={style.statusSelect}
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
<Button
fill="outline"
size="mini"
onClick={() => fetchDevices(searchQuery, currentPage)}
disabled={loading}
className={style.refreshBtn}
>
{loading ? (
<div className={style.loadingIcon}></div>
) : (
<ReloadOutlined />
)}
</Button>
</div>
<div className={style.deviceList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : (
<div className={style.deviceListInner}>
{filteredDevices.map((device) => (
<label key={device.id} className={style.deviceItem}>
<Checkbox
checked={selectedDevices.includes(device.id)}
onChange={() => handleDeviceToggle(device.id)}
className={style.deviceCheckbox}
/>
<div className={style.deviceInfo}>
<div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.name}</span>
<div
className={
device.status === "online"
? style.statusOnline
: style.statusOffline
}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
))}
</div>
)}
</div>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
<Button
fill="none"
size="mini"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
&lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
fill="none"
size="mini"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
&gt;
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedDevices.length}
</div>
<div className={style.footerBtnGroup}>
<Button fill="outline" onClick={() => setRealVisible(false)}>
</Button>
<Button color="primary" onClick={() => setRealVisible(false)}>
</Button>
</div>
</div>
</div>
);
return (
<>
{/* mode=input 显示输入框mode=dialog不显示 */}
{mode === "input" && showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选设备列表窗口 */}
{mode === "input" &&
showSelectedList &&
selectedDeviceObjs.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedDeviceObjs.map((device) => (
<div
key={device.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{device.name}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveDevice(device.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
{popupContent}
</Popup>
</>
);
};
export default DeviceSelection;

View File

@@ -0,0 +1,197 @@
import React from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
import { Input, Button, Checkbox, Popup } from "antd-mobile";
import style from "./index.module.scss";
interface DeviceSelectionItem {
id: string;
name: string;
imei: string;
wechatId: string;
status: "online" | "offline";
wxid?: string;
nickname?: string;
usedInPlans?: number;
}
interface SelectionPopupProps {
visible: boolean;
onClose: () => void;
selectedDevices: string[];
onSelect: (devices: string[]) => void;
devices: DeviceSelectionItem[];
loading: boolean;
searchQuery: string;
setSearchQuery: (v: string) => void;
statusFilter: string;
setStatusFilter: (v: string) => void;
onRefresh: () => void;
filteredDevices: DeviceSelectionItem[];
total: number;
currentPage: number;
totalPages: number;
setCurrentPage: (v: number) => void;
onCancel: () => void;
onConfirm: () => void;
}
const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible,
onClose,
selectedDevices,
onSelect,
devices,
loading,
searchQuery,
setSearchQuery,
statusFilter,
setStatusFilter,
onRefresh,
filteredDevices,
total,
currentPage,
totalPages,
setCurrentPage,
onCancel,
onConfirm,
}) => {
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
};
return (
<Popup
visible={visible}
// 禁止点击遮罩关闭
onMaskClick={() => {}}
position="bottom"
bodyStyle={{ height: "100vh" }}
closeOnMaskClick={false}
>
<div className={style.popupContainer}>
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
</div>
<div className={style.popupSearchRow}>
<div className={style.popupSearchInputWrap}>
<SearchOutlined className={style.inputIcon} />
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={setSearchQuery}
className={style.popupSearchInput}
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className={style.statusSelect}
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
<Button
fill="outline"
size="mini"
onClick={onRefresh}
disabled={loading}
className={style.refreshBtn}
>
{loading ? (
<div className={style.loadingIcon}></div>
) : (
<ReloadOutlined />
)}
</Button>
</div>
<div className={style.deviceList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : (
<div className={style.deviceListInner}>
{filteredDevices.map((device) => (
<label key={device.id} className={style.deviceItem}>
<Checkbox
checked={selectedDevices.includes(device.id)}
onChange={() => handleDeviceToggle(device.id)}
className={style.deviceCheckbox}
/>
<div className={style.deviceInfo}>
<div className={style.deviceInfoRow}>
<span className={style.deviceName}>{device.name}</span>
<div
className={
device.status === "online"
? style.statusOnline
: style.statusOffline
}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className={style.deviceInfoDetail}>
<div>IMEI: {device.imei}</div>
<div>: {device.wechatId}</div>
</div>
</div>
</label>
))}
</div>
)}
</div>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
<Button
fill="none"
size="mini"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
&lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
fill="none"
size="mini"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
&gt;
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedDevices.length}
</div>
<div className={style.footerBtnGroup}>
<Button fill="outline" onClick={onCancel}>
</Button>
<Button color="primary" onClick={onConfirm}>
</Button>
</div>
</div>
</div>
</Popup>
);
};
export default SelectionPopup;

View File

@@ -0,0 +1,11 @@
import request from "@/api/request";
// 获取好友列表
export function getFriendList(params: {
page: number;
limit: number;
deviceIds?: string; // 逗号分隔
keyword?: string;
}) {
return request("/v1/friend", params, "GET");
}

View File

@@ -0,0 +1,231 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.friendList {
flex: 1;
overflow-y: auto;
}
.friendListInner {
border-top: 1px solid #f0f0f0;
}
.friendItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.radioWrapper {
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.radioSelected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #1890ff;
display: flex;
align-items: center;
justify-content: center;
}
.radioUnselected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: center;
}
.radioDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1890ff;
}
.friendInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.friendAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.friendDetail {
flex: 1;
}
.friendName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.friendId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.friendCustomer {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.cancelBtn {
padding: 0 24px;
border-radius: 24px;
border: 1px solid #e5e6eb;
}
.confirmBtn {
padding: 0 24px;
border-radius: 24px;
}

View File

@@ -0,0 +1,422 @@
import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Popup, Toast } from "antd-mobile";
import { Button, Input } from "antd";
import { getFriendList } from "./api";
import style from "./index.module.scss";
// 微信好友接口类型
interface WechatFriend {
id: string;
nickname: string;
wechatId: string;
avatar: string;
customer: string;
}
// 组件属性接口
interface FriendSelectionProps {
selectedFriends: string[];
onSelect: (friends: string[]) => void;
onSelectDetail?: (friends: WechatFriend[]) => void;
deviceIds?: string[];
enableDeviceFilter?: boolean;
placeholder?: string;
className?: string;
visible?: boolean; // 新增
onVisibleChange?: (visible: boolean) => void; // 新增
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedIds: string[], selectedItems: WechatFriend[]) => void; // 新增
}
export default function FriendSelection({
selectedFriends,
onSelect,
onSelectDetail,
deviceIds = [],
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: FriendSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [friends, setFriends] = useState<WechatFriend[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalFriends, setTotalFriends] = useState(0);
const [loading, setLoading] = useState(false);
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗并请求第一页好友
const openPopup = () => {
if (readonly) return;
setCurrentPage(1);
setSearchQuery("");
setRealVisible(true);
fetchFriends(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (realVisible && currentPage !== 1) {
fetchFriends(currentPage, searchQuery);
}
}, [currentPage, realVisible, searchQuery]);
// 搜索防抖
useEffect(() => {
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchFriends(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, realVisible]);
// 获取好友列表API - 添加 keyword 参数
const fetchFriends = async (page: number, keyword: string = "") => {
setLoading(true);
try {
let params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
if (enableDeviceFilter) {
if (deviceIds.length === 0) {
setFriends([]);
setTotalFriends(0);
setTotalPages(1);
setLoading(false);
return;
}
params.deviceIds = deviceIds.join(",");
}
const res = await getFriendList(params);
if (res && Array.isArray(res.list)) {
setFriends(
res.list.map((friend: any) => ({
id: friend.id?.toString() || "",
nickname: friend.nickname || "",
wechatId: friend.wechatId || "",
avatar: friend.avatar || "",
customer: friend.customer || "",
}))
);
setTotalFriends(res.total || 0);
setTotalPages(Math.ceil((res.total || 0) / 20));
}
} catch (error) {
console.error("获取好友列表失败:", error);
Toast.show({ content: "获取好友列表失败", position: "top" });
} finally {
setLoading(false);
}
};
// 处理好友选择
const handleFriendToggle = (friendId: string) => {
let newIds: string[];
if (selectedFriends.includes(friendId)) {
newIds = selectedFriends.filter((id) => id !== friendId);
} else {
newIds = [...selectedFriends, friendId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = friends.filter((f) => newIds.includes(f.id));
onSelectDetail(selectedObjs);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedFriends.length === 0) return "";
return `已选择 ${selectedFriends.length} 个好友`;
};
// 获取已选好友详细信息
const selectedFriendObjs = selectedFriends
.map((id) => friends.find((f) => f.id === id))
.filter(Boolean) as WechatFriend[];
// 删除已选好友
const handleRemoveFriend = (id: string) => {
if (readonly) return;
onSelect(selectedFriends.filter((f) => f !== id));
};
// 确认按钮逻辑
const handleConfirm = () => {
setRealVisible(false);
if (onConfirm) {
onConfirm(selectedFriends, selectedFriendObjs);
}
};
// 清空搜索
const handleClearSearch = () => {
setSearchQuery("");
setCurrentPage(1);
fetchFriends(1, "");
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选好友列表窗口 */}
{showSelectedList && selectedFriendObjs.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedFriendObjs.map((friend) => (
<div
key={friend.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{friend.nickname || friend.wechatId || friend.id}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveFriend(friend.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<div className={style.popupContainer}>
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
<div className={style.searchWrapper}>
<Input
placeholder="搜索好友"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={readonly}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
{searchQuery && !readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
className={style.clearBtn}
onClick={handleClearSearch}
style={{
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
)}
</div>
</div>
<div className={style.friendList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : friends.length > 0 ? (
<div className={style.friendListInner}>
{friends.map((friend) => (
<label
key={friend.id}
className={style.friendItem}
onClick={() => !readonly && handleFriendToggle(friend.id)}
>
<div className={style.radioWrapper}>
<div
className={
selectedFriends.includes(friend.id)
? style.radioSelected
: style.radioUnselected
}
>
{selectedFriends.includes(friend.id) && (
<div className={style.radioDot}></div>
)}
</div>
</div>
<div className={style.friendInfo}>
<div className={style.friendAvatar}>
{friend.avatar ? (
<img
src={friend.avatar}
alt={friend.nickname}
className={style.avatarImg}
/>
) : (
friend.nickname.charAt(0)
)}
</div>
<div className={style.friendDetail}>
<div className={style.friendName}>
{friend.nickname}
</div>
<div className={style.friendId}>
ID: {friend.wechatId}
</div>
{friend.customer && (
<div className={style.friendCustomer}>
: {friend.customer}
</div>
)}
</div>
</div>
</label>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{deviceIds.length === 0
? "请先选择设备"
: searchQuery
? `没有找到包含"${searchQuery}"的好友`
: "没有找到好友"}
</div>
</div>
)}
</div>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {totalFriends} </div>
<div className={style.paginationControls}>
<Button
type="text"
size="small"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
&lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
type="text"
size="small"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
&gt;
</Button>
</div>
</div>
{/* 底部按钮栏 */}
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedFriends.length}
</div>
<div className={style.footerBtnGroup}>
<Button onClick={() => setRealVisible(false)}></Button>
<Button type="primary" onClick={handleConfirm}>
</Button>
</div>
</div>
</div>
</Popup>
</>
);
}

View File

@@ -0,0 +1,10 @@
import request from "@/api/request";
// 获取群组列表
export function getGroupList(params: {
page: number;
limit: number;
keyword?: string;
}) {
return request("/v1/chatroom", params, "GET");
}

View File

@@ -0,0 +1,222 @@
.inputWrapper {
position: relative;
}
.inputIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 20px;
}
.input {
padding-left: 38px !important;
height: 48px;
border-radius: 16px !important;
border: 1px solid #e5e6eb !important;
font-size: 16px;
background: #f8f9fa;
}
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader {
padding: 24px;
}
.popupTitle {
text-align: center;
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.searchWrapper {
position: relative;
margin-bottom: 16px;
}
.searchInput {
padding-left: 40px !important;
padding-top: 8px !important;
padding-bottom: 8px !important;
border-radius: 24px !important;
border: 1px solid #e5e6eb !important;
font-size: 15px;
background: #f8f9fa;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #bdbdbd;
font-size: 16px;
}
.clearBtn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
height: 24px;
width: 24px;
border-radius: 50%;
min-width: 24px;
}
.groupList {
flex: 1;
overflow-y: auto;
}
.groupListInner {
border-top: 1px solid #f0f0f0;
}
.groupItem {
display: flex;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f6fa;
}
}
.radioWrapper {
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.radioSelected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #1890ff;
display: flex;
align-items: center;
justify-content: center;
}
.radioUnselected {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #e5e6eb;
display: flex;
align-items: center;
justify-content: center;
}
.radioDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1890ff;
}
.groupInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.groupAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 500;
overflow: hidden;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.groupDetail {
flex: 1;
}
.groupName {
font-weight: 500;
font-size: 16px;
color: #222;
margin-bottom: 2px;
}
.groupId {
font-size: 13px;
color: #888;
margin-bottom: 2px;
}
.groupOwner {
font-size: 13px;
color: #bdbdbd;
}
.loadingBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.loadingText {
color: #888;
font-size: 15px;
}
.emptyBox {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.emptyText {
color: #888;
font-size: 15px;
}
.paginationRow {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
}
.totalCount {
font-size: 14px;
color: #888;
}
.paginationControls {
display: flex;
align-items: center;
gap: 8px;
}
.pageBtn {
padding: 0 8px;
height: 32px;
min-width: 32px;
}
.pageInfo {
font-size: 14px;
color: #222;
}
.popupFooter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}

View File

@@ -0,0 +1,413 @@
import React, { useState, useEffect } from "react";
import {
SearchOutlined,
CloseOutlined,
LeftOutlined,
RightOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { Button as AntdButton, Input as AntdInput } from "antd";
import { Popup, Toast } from "antd-mobile";
import { getGroupList } from "./api";
import style from "./index.module.scss";
// 群组接口类型
interface WechatGroup {
id: string;
chatroomId: string;
name: string;
avatar: string;
ownerWechatId: string;
ownerNickname: string;
ownerAvatar: string;
}
// 组件属性接口
interface GroupSelectionProps {
selectedGroups: string[];
onSelect: (groups: string[]) => void;
onSelectDetail?: (groups: WechatGroup[]) => void;
placeholder?: string;
className?: string;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedIds: string[], selectedItems: WechatGroup[]) => void; // 新增
}
export default function GroupSelection({
selectedGroups,
onSelect,
onSelectDetail,
placeholder = "选择群聊",
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [groups, setGroups] = useState<WechatGroup[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
// 获取已选群聊详细信息
const selectedGroupObjs = selectedGroups
.map((id) => groups.find((g) => g.id === id))
.filter(Boolean) as WechatGroup[];
// 删除已选群聊
const handleRemoveGroup = (id: string) => {
if (readonly) return;
onSelect(selectedGroups.filter((g) => g !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
if (onVisibleChange) onVisibleChange(v);
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setCurrentPage(1);
setSearchQuery("");
setRealVisible(true);
fetchGroups(1, "");
};
// 当页码变化时,拉取对应页数据(弹窗已打开时)
useEffect(() => {
if (realVisible && currentPage !== 1) {
fetchGroups(currentPage, searchQuery);
}
}, [currentPage, realVisible, searchQuery]);
// 搜索防抖
useEffect(() => {
if (!realVisible) return;
const timer = setTimeout(() => {
setCurrentPage(1);
fetchGroups(1, searchQuery);
}, 500);
return () => clearTimeout(timer);
}, [searchQuery, realVisible]);
// 获取群组列表API - 支持keyword
const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true);
try {
const params: any = {
page,
limit: 20,
};
if (keyword.trim()) {
params.keyword = keyword.trim();
}
const res = await getGroupList(params);
if (res && Array.isArray(res.list)) {
setGroups(
res.list.map((group: any) => ({
id: group.id?.toString() || "",
chatroomId: group.chatroomId || "",
name: group.name || "",
avatar: group.avatar || "",
ownerWechatId: group.ownerWechatId || "",
ownerNickname: group.ownerNickname || "",
ownerAvatar: group.ownerAvatar || "",
}))
);
setTotalGroups(res.total || 0);
setTotalPages(Math.ceil((res.total || 0) / 20));
}
} catch (error) {
console.error("获取群组列表失败:", error);
Toast.show({ content: "获取群组列表失败", position: "top" });
} finally {
setLoading(false);
}
};
// 处理群组选择
const handleGroupToggle = (groupId: string) => {
let newIds: string[];
if (selectedGroups.includes(groupId)) {
newIds = selectedGroups.filter((id) => id !== groupId);
} else {
newIds = [...selectedGroups, groupId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = groups.filter((g) => newIds.includes(g.id));
onSelectDetail(selectedObjs);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedGroups.length === 0) return "";
return `已选择 ${selectedGroups.length} 个群聊`;
};
// 确认按钮逻辑
const handleConfirm = () => {
setRealVisible(false);
if (onConfirm) {
onConfirm(selectedGroups, selectedGroupObjs);
}
};
// 清空搜索
const handleClearSearch = () => {
setSearchQuery("");
setCurrentPage(1);
fetchGroups(1, "");
};
return (
<>
{/* 输入框 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<AntdInput
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选群聊列表窗口 */}
{showSelectedList && selectedGroupObjs.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedGroupObjs.map((group) => (
<div
key={group.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{group.name || group.chatroomId || group.id}
</div>
{!readonly && (
<AntdButton
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveGroup(group.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
>
<div className={style.popupContainer}>
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
<div className={style.searchWrapper}>
<AntdInput
placeholder="搜索群聊"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={readonly}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
<SearchOutlined className={style.searchIcon} />
{searchQuery && !readonly && (
<AntdButton
type="text"
icon={<CloseOutlined />}
size="small"
className={style.clearBtn}
onClick={handleClearSearch}
style={{
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
)}
</div>
</div>
<div className={style.groupList}>
{loading ? (
<div className={style.loadingBox}>
<div className={style.loadingText}>...</div>
</div>
) : groups.length > 0 ? (
<div className={style.groupListInner}>
{groups.map((group) => (
<label
key={group.id}
className={style.groupItem}
onClick={() => !readonly && handleGroupToggle(group.id)}
>
<div className={style.radioWrapper}>
<div
className={
selectedGroups.includes(group.id)
? style.radioSelected
: style.radioUnselected
}
>
{selectedGroups.includes(group.id) && (
<div className={style.radioDot}></div>
)}
</div>
</div>
<div className={style.groupInfo}>
<div className={style.groupAvatar}>
{group.avatar ? (
<img
src={group.avatar}
alt={group.name}
className={style.avatarImg}
/>
) : (
group.name.charAt(0)
)}
</div>
<div className={style.groupDetail}>
<div className={style.groupName}>{group.name}</div>
<div className={style.groupId}>
ID: {group.chatroomId}
</div>
{group.ownerNickname && (
<div className={style.groupOwner}>
: {group.ownerNickname}
</div>
)}
</div>
</div>
</label>
))}
</div>
) : (
<div className={style.emptyBox}>
<div className={style.emptyText}>
{searchQuery
? `没有找到包含"${searchQuery}"的群聊`
: "没有找到群聊"}
</div>
</div>
)}
</div>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {totalGroups} </div>
<div className={style.paginationControls}>
<AntdButton
type="text"
size="small"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<LeftOutlined />
</AntdButton>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<AntdButton
type="text"
size="small"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<RightOutlined />
</AntdButton>
</div>
</div>
{/* 底部按钮栏 */}
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedGroups.length}
</div>
<div className={style.footerBtnGroup}>
<AntdButton type="default" onClick={() => setRealVisible(false)}>
</AntdButton>
<AntdButton type="primary" onClick={handleConfirm}>
</AntdButton>
</div>
</div>
</div>
</Popup>
</>
);
}

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from "react";
import React from "react";
import { TabBar } from "antd-mobile";
import { PieOutline, UserOutline } from "antd-mobile-icons";
import { HomeOutlined, TeamOutlined } from "@ant-design/icons";
import { useLocation, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
const tabs = [
{
@@ -12,13 +12,13 @@ const tabs = [
path: "/",
},
{
key: "scene",
key: "scenarios",
title: "场景获客",
icon: <TeamOutlined />,
path: "/scenarios",
},
{
key: "work",
key: "workspace",
title: "工作台",
icon: <PieOutline />,
path: "/workspace",
@@ -31,38 +31,18 @@ const tabs = [
},
];
// 需要展示菜单的路由白名单(可根据实际业务调整)
const menuPaths = ["/", "/scenarios", "/workspace", "/mine"];
interface MeauMobileProps {
activeKey: string;
}
const MeauMobile: React.FC = () => {
const location = useLocation();
const MeauMobile: React.FC<MeauMobileProps> = ({ activeKey }) => {
const navigate = useNavigate();
const [activeKey, setActiveKey] = useState("home");
// 根据当前路由自动设置 activeKey支持嵌套路由
useEffect(() => {
const found = tabs.find((tab) =>
tab.path === "/"
? location.pathname === "/"
: location.pathname.startsWith(tab.path)
);
if (found) setActiveKey(found.key);
}, [location.pathname]);
// 判断当前路由是否需要展示菜单
const showMenu = menuPaths.some((path) =>
path === "/"
? location.pathname === "/"
: location.pathname.startsWith(path)
);
if (!showMenu) return null;
return (
<TabBar
style={{ background: "#fff" }}
activeKey={activeKey}
onChange={(key) => {
setActiveKey(key);
const tab = tabs.find((t) => t.key === key);
if (tab && tab.path) navigate(tab.path);
}}

View File

@@ -0,0 +1,68 @@
import React, { useState } from "react";
import DeviceSelection from "./DeviceSelection";
import FriendSelection from "./FriendSelection";
import GroupSelection from "./GroupSelection";
import { Button, Space } from "antd-mobile";
export default function SelectionTest() {
// 设备选择
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const [deviceDialogOpen, setDeviceDialogOpen] = useState(false);
// 好友选择
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
const [friendDialogOpen, setFriendDialogOpen] = useState(false);
// 群组选择
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [groupDialogOpen, setGroupDialogOpen] = useState(false);
return (
<div style={{ padding: 24 }}>
<h2></h2>
<Space direction="vertical" block>
<div>
<b>DeviceSelection+</b>
<DeviceSelection
selectedDevices={selectedDevices}
onSelect={setSelectedDevices}
/>
</div>
<div>
<b>FriendSelection</b>
<Button color="primary" onClick={() => setFriendDialogOpen(true)}>
</Button>
<FriendSelection
selectedFriends={selectedFriends}
onSelect={setSelectedFriends}
placeholder="请选择微信好友"
className=""
visible={friendDialogOpen}
onVisibleChange={setFriendDialogOpen}
/>
</div>
<div>
<b>GroupSelection</b>
<Button color="primary" onClick={() => setGroupDialogOpen(true)}>
</Button>
<GroupSelection
selectedGroups={selectedGroups}
onSelect={setSelectedGroups}
placeholder="请选择群聊"
className=""
visible={groupDialogOpen}
onVisibleChange={setGroupDialogOpen}
/>
</div>
</Space>
<div style={{ marginTop: 32 }}>
<div>ID: {selectedDevices.join(", ")}</div>
<div>ID: {selectedFriends.join(", ")}</div>
<div>ID: {selectedGroups.join(", ")}</div>
</div>
</div>
);
}

View File

@@ -1,28 +1,369 @@
import React from "react";
import { NavBar } from "antd-mobile";
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { NavBar, Tabs, Switch, Toast, SpinLoading, Button } from "antd-mobile";
import { SettingOutlined, RedoOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import {
fetchDeviceDetail,
fetchDeviceRelatedAccounts,
fetchDeviceHandleLogs,
updateDeviceTaskConfig,
} from "@/api/devices";
import type { Device, WechatAccount, HandleLog } from "@/types/device";
const DeviceDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [device, setDevice] = useState<Device | null>(null);
const [tab, setTab] = useState("info");
const [accounts, setAccounts] = useState<WechatAccount[]>([]);
const [accountsLoading, setAccountsLoading] = useState(false);
const [logs, setLogs] = useState<HandleLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [featureSaving, setFeatureSaving] = useState<{ [k: string]: boolean }>(
{}
);
// 获取设备详情
const loadDetail = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const res = await fetchDeviceDetail(id);
setDevice(res);
} catch (e: any) {
Toast.show({ content: e.message || "获取设备详情失败", position: "top" });
} finally {
setLoading(false);
}
}, [id]);
// 获取关联账号
const loadAccounts = useCallback(async () => {
if (!id) return;
setAccountsLoading(true);
try {
const res = await fetchDeviceRelatedAccounts(id);
setAccounts(Array.isArray(res.accounts) ? res.accounts : []);
} catch (e: any) {
Toast.show({ content: e.message || "获取关联账号失败", position: "top" });
} finally {
setAccountsLoading(false);
}
}, [id]);
// 获取操作日志
const loadLogs = useCallback(async () => {
if (!id) return;
setLogsLoading(true);
try {
const res = await fetchDeviceHandleLogs(id, 1, 20);
setLogs(Array.isArray(res.list) ? res.list : []);
} catch (e: any) {
Toast.show({ content: e.message || "获取操作日志失败", position: "top" });
} finally {
setLogsLoading(false);
}
}, [id]);
useEffect(() => {
loadDetail();
// eslint-disable-next-line
}, [id]);
useEffect(() => {
if (tab === "accounts") loadAccounts();
if (tab === "logs") loadLogs();
// eslint-disable-next-line
}, [tab]);
// 功能开关
const handleFeatureChange = async (
feature: keyof Device["features"],
checked: boolean
) => {
if (!id) return;
setFeatureSaving((prev) => ({ ...prev, [feature]: true }));
try {
await updateDeviceTaskConfig({ deviceId: id, [feature]: checked });
setDevice((prev) =>
prev
? {
...prev,
features: { ...prev.features, [feature]: checked },
}
: prev
);
Toast.show({
content: `${getFeatureName(feature)}${checked ? "开启" : "关闭"}`,
});
} catch (e: any) {
Toast.show({ content: e.message || "设置失败", position: "top" });
} finally {
setFeatureSaving((prev) => ({ ...prev, [feature]: false }));
}
};
const getFeatureName = (feature: string) => {
const map: Record<string, string> = {
autoAddFriend: "自动加好友",
autoReply: "自动回复",
momentsSync: "朋友圈同步",
aiChat: "AI会话",
};
return map[feature] || feature;
};
return (
<Layout
header={
<NavBar
backArrow
onBack={() => navigate(-1)}
style={{ background: "#fff" }}
onBack={() => window.history.back()}
right={
<Button size="small" color="primary">
<SettingOutlined />
</Button>
}
>
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
</div>
</span>
</NavBar>
}
footer={<MeauMobile />}
loading={loading}
>
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
<h3></h3>
<p>...</p>
</div>
{!device ? (
<div style={{ padding: 32, textAlign: "center", color: "#888" }}>
<SpinLoading style={{ "--size": "32px" }} />
<div style={{ marginTop: 16 }}>...</div>
</div>
) : (
<div style={{ padding: 12 }}>
{/* 基本信息卡片 */}
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 16,
boxShadow: "0 1px 4px #eee",
}}
>
<div style={{ fontWeight: 600, fontSize: 18 }}>
{device.memo || "未命名设备"}
</div>
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
IMEI: {device.imei}
</div>
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
: {device.wechatId || "未绑定"}
</div>
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
: {device.totalFriend ?? "-"}
</div>
<div
style={{
fontSize: 13,
color:
device.status === "online" || device.alive === 1
? "#52c41a"
: "#aaa",
marginTop: 4,
}}
>
{device.status === "online" || device.alive === 1
? "在线"
: "离线"}
</div>
</div>
{/* 标签页 */}
<Tabs activeKey={tab} onChange={setTab} style={{ marginBottom: 12 }}>
<Tabs.Tab title="功能开关" key="info" />
<Tabs.Tab title="关联账号" key="accounts" />
<Tabs.Tab title="操作日志" key="logs" />
</Tabs>
{/* 功能开关 */}
{tab === "info" && (
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
boxShadow: "0 1px 4px #eee",
display: "flex",
flexDirection: "column",
gap: 18,
}}
>
{["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map(
(f) => (
<div
key={f}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div>
<div style={{ fontWeight: 500 }}>{getFeatureName(f)}</div>
</div>
<Switch
checked={
!!device.features?.[f as keyof Device["features"]]
}
loading={!!featureSaving[f]}
onChange={(checked) =>
handleFeatureChange(
f as keyof Device["features"],
checked
)
}
/>
</div>
)
)}
</div>
)}
{/* 关联账号 */}
{tab === "accounts" && (
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
boxShadow: "0 1px 4px #eee",
}}
>
{accountsLoading ? (
<div
style={{ textAlign: "center", color: "#888", padding: 32 }}
>
<SpinLoading />
</div>
) : accounts.length === 0 ? (
<div
style={{ textAlign: "center", color: "#aaa", padding: 32 }}
>
</div>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
{accounts.map((acc) => (
<div
key={acc.id}
style={{
display: "flex",
alignItems: "center",
gap: 12,
background: "#f7f8fa",
borderRadius: 8,
padding: 10,
}}
>
<img
src={acc.avatar || "/placeholder.svg"}
alt={acc.nickname}
style={{
width: 40,
height: 40,
borderRadius: 20,
background: "#eee",
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{acc.nickname}</div>
<div style={{ fontSize: 12, color: "#888" }}>
: {acc.wechatId}
</div>
<div style={{ fontSize: 12, color: "#888" }}>
: {acc.totalFriend}
</div>
<div style={{ fontSize: 12, color: "#aaa" }}>
: {acc.lastActive}
</div>
</div>
<span
style={{
fontSize: 12,
color: acc.wechatAlive === 1 ? "#52c41a" : "#aaa",
}}
>
{acc.wechatAliveText}
</span>
</div>
))}
</div>
)}
<div style={{ textAlign: "center", marginTop: 16 }}>
<Button size="small" onClick={loadAccounts}>
<RedoOutlined />
</Button>
</div>
</div>
)}
{/* 操作日志 */}
{tab === "logs" && (
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
boxShadow: "0 1px 4px #eee",
}}
>
{logsLoading ? (
<div
style={{ textAlign: "center", color: "#888", padding: 32 }}
>
<SpinLoading />
</div>
) : logs.length === 0 ? (
<div
style={{ textAlign: "center", color: "#aaa", padding: 32 }}
>
</div>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
{logs.map((log) => (
<div
key={log.id}
style={{
display: "flex",
flexDirection: "column",
gap: 2,
background: "#f7f8fa",
borderRadius: 8,
padding: 10,
}}
>
<div style={{ fontWeight: 500 }}>{log.content}</div>
<div style={{ fontSize: 12, color: "#888" }}>
: {log.username} · {log.createTime}
</div>
</div>
))}
</div>
)}
<div style={{ textAlign: "center", marginTop: 16 }}>
<Button size="small" onClick={loadLogs}>
<RedoOutlined />
</Button>
</div>
</div>
)}
</div>
)}
</Layout>
);
};

View File

@@ -1,37 +1,431 @@
import React from "react";
import { NavBar, Button } from "antd-mobile";
import { AddOutline } from "antd-mobile-icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
const Devices: React.FC = () => {
return (
<Layout
header={
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
</div>
}
right={
<Button size="small" color="primary">
<AddOutline />
<span style={{ marginLeft: 4, fontSize: 12 }}></span>
</Button>
}
/>
}
footer={<MeauMobile />}
>
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
<h3></h3>
<p>...</p>
</div>
</Layout>
);
};
export default Devices;
import React, { useEffect, useRef, useState, useCallback } from "react";
import { NavBar, Popup, Tabs, Toast, SpinLoading, Dialog } from "antd-mobile";
import { Button, Input, Pagination, Checkbox } from "antd";
import { useNavigate } from "react-router-dom";
import { AddOutline, DeleteOutline } from "antd-mobile-icons";
import {
ReloadOutlined,
SearchOutlined,
QrcodeOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import {
fetchDeviceList,
fetchDeviceQRCode,
addDeviceByImei,
deleteDevice,
} from "@/api/devices";
import type { Device } from "@/types/device";
import { comfirm } from "@/utils/common";
const Devices: React.FC = () => {
// 设备列表相关
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [status, setStatus] = useState<"all" | "online" | "offline">("all");
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [total, setTotal] = useState(0);
const [selected, setSelected] = useState<(string | number)[]>([]);
const observerRef = useRef<HTMLDivElement>(null);
const [usePagination, setUsePagination] = useState(true); // 新增:是否使用分页
// 添加设备弹窗
const [addVisible, setAddVisible] = useState(false);
const [addTab, setAddTab] = useState("scan");
const [qrLoading, setQrLoading] = useState(false);
const [qrCode, setQrCode] = useState<string | null>(null);
const [imei, setImei] = useState("");
const [name, setName] = useState("");
const [addLoading, setAddLoading] = useState(false);
// 删除弹窗
const [delVisible, setDelVisible] = useState(false);
const [delLoading, setDelLoading] = useState(false);
const navigate = useNavigate();
// 加载设备列表
const loadDevices = useCallback(
async (reset = false) => {
if (loading) return;
setLoading(true);
try {
const params: any = { page: reset ? 1 : page, limit: 20 };
if (search) params.keyword = search;
const res = await fetchDeviceList(params);
const list = Array.isArray(res.list) ? res.list : [];
setDevices((prev) => (reset ? list : [...prev, ...list]));
setTotal(res.total || 0);
setHasMore(list.length === 20);
if (reset) setPage(1);
} catch (e) {
Toast.show({ content: "获取设备列表失败", position: "top" });
setHasMore(false); // 请求失败后不再继续请求
} finally {
setLoading(false);
}
},
[loading, search, page]
);
// 首次加载和搜索
useEffect(() => {
loadDevices(true);
// eslint-disable-next-line
}, [search]);
// 无限滚动
useEffect(() => {
if (!hasMore || loading) return;
const observer = new window.IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
setPage((p) => p + 1);
}
},
{ threshold: 0.5 }
);
if (observerRef.current) observer.observe(observerRef.current);
return () => observer.disconnect();
}, [hasMore, loading]);
// 分页加载
useEffect(() => {
if (page === 1) return;
loadDevices();
// eslint-disable-next-line
}, [page]);
// 状态筛选
const filtered = devices.filter((d) => {
if (status === "all") return true;
if (status === "online") return d.status === "online" || d.alive === 1;
if (status === "offline") return d.status === "offline" || d.alive === 0;
return true;
});
// 获取二维码
const handleGetQr = async () => {
setQrLoading(true);
setQrCode(null);
try {
const accountId = localStorage.getItem("s2_accountId") || "";
if (!accountId) throw new Error("未获取到用户信息");
const res = await fetchDeviceQRCode(accountId);
setQrCode(res.qrCode);
} catch (e: any) {
Toast.show({ content: e.message || "获取二维码失败", position: "top" });
} finally {
setQrLoading(false);
}
};
// 手动添加设备
const handleAddDevice = async () => {
if (!imei.trim() || !name.trim()) {
Toast.show({ content: "请填写完整信息", position: "top" });
return;
}
setAddLoading(true);
try {
await addDeviceByImei(imei, name);
Toast.show({ content: "添加成功", position: "top" });
setAddVisible(false);
setImei("");
setName("");
loadDevices(true);
} catch (e: any) {
Toast.show({ content: e.message || "添加失败", position: "top" });
} finally {
setAddLoading(false);
}
};
// 删除设备
const handleDelete = async () => {
setDelLoading(true);
try {
for (const id of selected) {
await deleteDevice(Number(id));
}
Toast.show({ content: `删除成功`, position: "top" });
setSelected([]);
loadDevices(true);
} catch (e: any) {
if (e) Toast.show({ content: e.message || "删除失败", position: "top" });
} finally {
setDelLoading(false);
}
};
// 删除按钮点击
const handleDeleteClick = async () => {
try {
await comfirm(
`将删除${selected.length}个设备,删除后本设备配置的计划任务操作也将失效。确认删除?`,
{ title: "确认删除", confirmText: "确认删除", cancelText: "取消" }
);
handleDelete();
} catch {
// 用户取消,无需处理
}
};
// 跳转详情
const goDetail = (id: string | number) => {
window.location.href = `/devices/${id}`;
};
// 分页切换
const handlePageChange = (p: number) => {
setPage(p);
loadDevices(true);
};
return (
<Layout
header={
<>
<NavBar
back={null}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
style={{ background: "#fff" }}
right={
<Button
size="small"
type="primary"
onClick={() => setAddVisible(true)}
>
<AddOutline />
</Button>
}
>
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
</span>
</NavBar>
<div style={{ padding: "12px 12px 0 12px", background: "#fff" }}>
{/* 搜索栏 */}
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<Input
placeholder="搜索设备IMEI/备注"
value={search}
onChange={(e) => setSearch(e.target.value)}
prefix={<SearchOutlined />}
allowClear
style={{ flex: 1 }}
/>
<Button
onClick={() => loadDevices(true)}
icon={<ReloadOutlined />}
>
</Button>
</div>
{/* 筛选和删除 */}
<div style={{ display: "flex", gap: 8 }}>
<Tabs
activeKey={status}
onChange={(k) => setStatus(k as any)}
style={{ flex: 1 }}
>
<Tabs.Tab title="全部" key="all" />
<Tabs.Tab title="在线" key="online" />
<Tabs.Tab title="离线" key="offline" />
</Tabs>
<div style={{ paddingTop: 8 }}>
<Button
size="small"
type="primary"
danger
icon={<DeleteOutline />}
disabled={selected.length === 0}
onClick={handleDeleteClick}
>
</Button>
</div>
</div>
</div>
</>
}
footer={
<div style={{ padding: 16, textAlign: "center", background: "#fff" }}>
<Pagination
current={page}
pageSize={20}
total={total}
showSizeChanger={false}
onChange={handlePageChange}
/>
</div>
}
loading={loading && devices.length === 0}
>
<div style={{ padding: 12 }}>
{/* 设备列表 */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{filtered.map((device) => (
<div
key={device.id}
style={{
background: "#fff",
borderRadius: 12,
padding: 12,
boxShadow: "0 1px 4px #eee",
display: "flex",
alignItems: "center",
cursor: "pointer",
border: selected.includes(device.id)
? "1.5px solid #1677ff"
: "1px solid #f0f0f0",
}}
onClick={() => goDetail(device.id!)}
>
<Checkbox
checked={selected.includes(device.id)}
onChange={(e) => {
e.stopPropagation();
setSelected((prev) =>
e.target.checked
? [...prev, device.id!]
: prev.filter((id) => id !== device.id)
);
}}
onClick={(e) => e.stopPropagation()}
style={{ marginRight: 12 }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 16 }}>
{device.memo || "未命名设备"}
</div>
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
IMEI: {device.imei}
</div>
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
: {device.wechatId || "未绑定"}
</div>
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
: {device.totalFriend ?? "-"}
</div>
</div>
<span
style={{
fontSize: 12,
color:
device.status === "online" || device.alive === 1
? "#52c41a"
: "#aaa",
marginLeft: 8,
}}
>
{device.status === "online" || device.alive === 1
? "在线"
: "离线"}
</span>
</div>
))}
{/* 无限滚动提示(仅在不分页时显示) */}
{!usePagination && (
<div
ref={observerRef}
style={{ padding: 12, textAlign: "center", color: "#888" }}
>
{loading && <SpinLoading style={{ "--size": "24px" }} />}
{!hasMore && devices.length > 0 && "没有更多设备了"}
{!hasMore && devices.length === 0 && "暂无设备"}
</div>
)}
</div>
</div>
{/* 添加设备弹窗 */}
<Popup
visible={addVisible}
onMaskClick={() => setAddVisible(false)}
bodyStyle={{
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
minHeight: 320,
}}
>
<div style={{ padding: 20 }}>
<Tabs
activeKey={addTab}
onChange={setAddTab}
style={{ marginBottom: 16 }}
>
<Tabs.Tab title="扫码添加" key="scan" />
<Tabs.Tab title="手动添加" key="manual" />
</Tabs>
{addTab === "scan" && (
<div style={{ textAlign: "center", minHeight: 200 }}>
<Button
type="error"
onClick={handleGetQr}
loading={qrLoading}
icon={<QrcodeOutlined />}
>
</Button>
{qrCode && (
<div style={{ marginTop: 16 }}>
<img
src={qrCode}
alt="二维码"
style={{
width: 180,
height: 180,
background: "#f5f5f5",
borderRadius: 8,
}}
/>
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
</div>
</div>
)}
</div>
)}
{addTab === "manual" && (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Input
placeholder="设备名称"
value={name}
onChange={(e) => setName(e.target.value)}
allowClear
/>
<Input
placeholder="设备IMEI"
value={imei}
onChange={(e) => setImei(e.target.value)}
allowClear
/>
<Button
color="primary"
onClick={handleAddDevice}
loading={addLoading}
>
</Button>
</div>
)}
</div>
</Popup>
</Layout>
);
};
export default Devices;

View File

@@ -1,11 +1,8 @@
.home-page {
padding: 12px;
background: #f8f6f3;
min-height: 100vh;
}
.content-wrapper {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
@@ -58,7 +55,6 @@
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
@@ -79,8 +75,8 @@
}
.stat-label {
font-size: 16px;
color: #666;
font-size: 14px;
color: #333;
line-height: 1.2;
font-weight: bold;
}
@@ -159,7 +155,6 @@
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;

View File

@@ -0,0 +1,258 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronRight, Settings, Bell, LogOut, Smartphone, MessageCircle, Database, FolderOpen } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useAuth } from '@/contexts/AuthContext';
import { useToast } from '@/components/ui/toast';
import Layout from '@/components/Layout';
import BottomNav from '@/components/BottomNav';
import UnifiedHeader from '@/components/UnifiedHeader';
import '@/components/Layout.css';
export default function Profile() {
const navigate = useNavigate();
const { user, logout, isAuthenticated } = useAuth();
const { toast } = useToast();
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [userInfo, setUserInfo] = useState<any>(null);
const [stats, setStats] = useState({
devices: 12,
wechat: 25,
traffic: 8,
content: 156,
});
// 从localStorage获取用户信息
useEffect(() => {
const userInfoStr = localStorage.getItem('userInfo');
if (userInfoStr) {
setUserInfo(JSON.parse(userInfoStr));
}
}, []);
// 用户信息
const currentUserInfo = {
name: userInfo?.username || user?.username || "卡若",
email: userInfo?.email || "zhangsan@example.com",
role: "管理员",
joinDate: "2023-01-15",
lastLogin: "2024-01-20 14:30",
};
// 功能模块数据
const functionModules = [
{
id: "devices",
title: "设备管理",
description: "管理您的设备和微信账号",
icon: <Smartphone className="h-5 w-5 text-blue-500" />,
count: stats.devices,
path: "/devices",
bgColor: "bg-blue-50",
},
{
id: "wechat",
title: "微信号管理",
description: "管理微信账号和好友",
icon: <MessageCircle className="h-5 w-5 text-green-500" />,
count: stats.wechat,
path: "/wechat-accounts",
bgColor: "bg-green-50",
},
{
id: "traffic",
title: "流量池",
description: "管理用户流量池和分组",
icon: <Database className="h-5 w-5 text-purple-500" />,
count: stats.traffic,
path: "/traffic-pool",
bgColor: "bg-purple-50",
},
{
id: "content",
title: "内容库",
description: "管理营销内容和素材",
icon: <FolderOpen className="h-5 w-5 text-orange-500" />,
count: stats.content,
path: "/content",
bgColor: "bg-orange-50",
},
];
// 加载统计数据
const loadStats = async () => {
try {
// 这里可以调用实际的API
// const [deviceStats, wechatStats, trafficStats, contentStats] = await Promise.allSettled([
// getDeviceStats(),
// getWechatStats(),
// getTrafficStats(),
// getContentStats(),
// ]);
// 暂时使用模拟数据
setStats({
devices: 12,
wechat: 25,
traffic: 8,
content: 156,
});
} catch (error) {
console.error("加载统计数据失败:", error);
}
};
useEffect(() => {
loadStats();
}, []);
const handleLogout = () => {
// 清除本地存储的用户信息
localStorage.removeItem('token');
localStorage.removeItem('token_expired');
localStorage.removeItem('s2_accountId');
localStorage.removeItem('userInfo');
setShowLogoutDialog(false);
logout();
navigate('/login');
toast({
title: '退出成功',
description: '您已安全退出系统',
});
};
const handleFunctionClick = (path: string) => {
navigate(path);
};
if (!isAuthenticated) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-gray-500"></div>
</div>
);
}
return (
<Layout
header={
<UnifiedHeader
title="我的"
showBack={false}
titleColor="blue"
actions={[
{
type: 'icon',
icon: Bell,
onClick: () => console.log('Notifications'),
},
{
type: 'icon',
icon: Settings,
onClick: () => console.log('Settings'),
},
]}
/>
}
footer={<BottomNav />}
>
<div className="bg-gray-50 pb-16">
<div className="p-4 space-y-4">
{/* 用户信息卡片 */}
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-4">
<Avatar className="h-16 w-16">
<AvatarImage src={userInfo?.avatar || user?.avatar || ''} />
<AvatarFallback className="bg-gray-200 text-gray-600 text-lg font-medium">
{currentUserInfo.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<h2 className="text-lg font-medium">{currentUserInfo.name}</h2>
<span className="px-2 py-1 text-xs bg-gradient-to-r from-orange-400 to-orange-500 text-white rounded-full font-medium shadow-sm">
{currentUserInfo.role}
</span>
</div>
<p className="text-sm text-gray-600 mb-2">{currentUserInfo.email}</p>
<div className="text-xs text-gray-500">
<div>: {currentUserInfo.lastLogin}</div>
</div>
</div>
<div className="flex flex-col space-y-2">
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 我的功能 */}
<Card>
<CardContent className="p-4">
<div className="space-y-2">
{functionModules.map((module) => (
<div
key={module.id}
className="flex items-center p-4 rounded-lg border hover:bg-gray-50 cursor-pointer transition-colors w-full"
onClick={() => handleFunctionClick(module.path)}
>
<div className={`p-2 rounded-lg ${module.bgColor} mr-3`}>{module.icon}</div>
<div className="flex-1">
<div className="font-medium text-sm">{module.title}</div>
<div className="text-xs text-gray-500">{module.description}</div>
</div>
<div className="flex items-center space-x-2">
<span className="px-2 py-1 text-xs bg-gray-50 text-gray-700 rounded-full border border-gray-200 font-medium shadow-sm">
{module.count}
</span>
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* 退出登录 */}
<Button
variant="outline"
className="w-full text-red-600 border-red-200 hover:bg-red-50 bg-transparent"
onClick={() => setShowLogoutDialog(true)}
>
<LogOut className="h-4 w-4 mr-2" />
退
</Button>
</div>
</div>
{/* 退出登录确认对话框 */}
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>退</DialogTitle>
<DialogDescription>
退退使
</DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={() => setShowLogoutDialog(false)}>
</Button>
<Button variant="destructive" onClick={handleLogout}>
退
</Button>
</div>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@@ -1,7 +1,5 @@
.mine-page {
padding: 16px;
background-color: #f5f5f5;
min-height: 100vh;
padding: 12px;
}
.user-card {
@@ -59,22 +57,24 @@
.menu-card {
margin-bottom: 16px;
border-radius: 12px;
overflow: hidden;
:global(.adm-card-body) {
padding: 0;
:global(.adm-list-body) {
border: none;
}
:global(.adm-card-body) {
padding: 14px 0 0 0;
}
:global(.adm-list-body-inner) {
margin-top: 0px;
}
:global(.adm-list-item) {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
padding: 0px;
:global(.adm-list-item-content) {
padding: 0;
border: 1px solid #f0f0f0;
margin-bottom: 12px;
padding: 0 12px;
border-radius: 12px;
}
:global(.adm-list-item-content-prefix) {
@@ -104,9 +104,6 @@
}
}
.logout-section {
padding: 0 16px;
}
.logout-btn {
border-radius: 8px;

View File

@@ -1,78 +1,179 @@
import React from "react";
import { Card, NavBar, List, Button } from "antd-mobile";
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Card, NavBar, List, Button, Dialog, Toast } from "antd-mobile";
import {
UserOutline,
AppOutline,
BellOutline,
HeartOutline,
StarOutline,
MessageOutline,
SendOutline,
MailOutline,
} from "antd-mobile-icons";
LogoutOutlined,
PhoneOutlined,
MessageOutlined,
DatabaseOutlined,
FolderOpenOutlined,
BellOutlined,
SettingOutlined,
} from "@ant-design/icons";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
const Mine: React.FC = () => {
const userInfo = {
name: "张三",
avatar: "https://via.placeholder.com/60",
level: "VIP会员",
points: 1280,
const navigate = useNavigate();
const [userInfo, setUserInfo] = useState<any>(null);
const [stats, setStats] = useState({
devices: 12,
wechat: 25,
traffic: 8,
content: 156,
});
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
// 从localStorage获取用户信息
useEffect(() => {
const userInfoStr = localStorage.getItem("userInfo");
if (userInfoStr) {
setUserInfo(JSON.parse(userInfoStr));
}
}, []);
// 用户信息
const currentUserInfo = {
name: userInfo?.username || "售前",
email: userInfo?.email || "zhangsan@example.com",
role: "管理员",
lastLogin: "2024-01-20 14:30",
avatar: userInfo?.avatar || "",
};
const menuItems = [
// 功能模块数据
const functionModules = [
{
icon: <UserOutline />,
title: "个人资料",
subtitle: "修改个人信息",
path: "/profile",
id: "devices",
title: "设备管理",
description: "管理您的设备和微信账号",
icon: <PhoneOutlined />,
count: stats.devices,
path: "/devices",
bgColor: "#e6f7ff",
iconColor: "#1890ff",
},
{
icon: <AppOutline />,
title: "系统设置",
subtitle: "应用设置与偏好",
path: "/settings",
id: "wechat",
title: "微信号管理",
description: "管理微信账号和好友",
icon: <MessageOutlined />,
count: stats.wechat,
path: "/wechat-accounts",
bgColor: "#f6ffed",
iconColor: "#52c41a",
},
{
icon: <BellOutline />,
title: "消息通知",
subtitle: "通知设置",
path: "/notifications",
id: "traffic",
title: "流量池",
description: "管理用户流量池和分组",
icon: <DatabaseOutlined />,
count: stats.traffic,
path: "/traffic-pool",
bgColor: "#f9f0ff",
iconColor: "#722ed1",
},
{
icon: <HeartOutline />,
title: "我的收藏",
subtitle: "收藏的内容",
path: "/favorites",
},
{
icon: <StarOutline />,
title: "我的评价",
subtitle: "查看评价记录",
path: "/reviews",
},
{
icon: <MessageOutline />,
title: "意见反馈",
subtitle: "问题反馈与建议",
path: "/feedback",
},
{
icon: <SendOutline />,
title: "联系客服",
subtitle: "在线客服",
path: "/customer-service",
},
{
icon: <MailOutline />,
title: "关于我们",
subtitle: "版本信息",
path: "/about",
id: "content",
title: "内容库",
description: "管理营销内容和素材",
icon: <FolderOpenOutlined />,
count: stats.content,
path: "/content",
bgColor: "#fff7e6",
iconColor: "#fa8c16",
},
];
// 加载统计数据
const loadStats = async () => {
try {
// 这里可以调用实际的API
// const [deviceStats, wechatStats, trafficStats, contentStats] = await Promise.allSettled([
// getDeviceStats(),
// getWechatStats(),
// getTrafficStats(),
// getContentStats(),
// ]);
// 暂时使用模拟数据
setStats({
devices: 12,
wechat: 25,
traffic: 8,
content: 156,
});
} catch (error) {
console.error("加载统计数据失败:", error);
}
};
useEffect(() => {
loadStats();
}, []);
const handleLogout = () => {
// 清除本地存储的用户信息
localStorage.removeItem("token");
localStorage.removeItem("token_expired");
localStorage.removeItem("s2_accountId");
localStorage.removeItem("userInfo");
setShowLogoutDialog(false);
navigate("/login");
Toast.show({
content: "退出成功",
position: "top",
});
};
const handleFunctionClick = (path: string) => {
navigate(path);
};
// 渲染用户头像
const renderUserAvatar = () => {
if (currentUserInfo.avatar) {
return <img src={currentUserInfo.avatar} alt="头像" />;
}
return (
<div
style={{
width: "100%",
height: "100%",
backgroundColor: "#1890ff",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "24px",
fontWeight: "bold",
}}
>
</div>
);
};
// 渲染功能模块图标
const renderModuleIcon = (module: any) => (
<div
style={{
width: "40px",
height: "40px",
backgroundColor: module.bgColor,
borderRadius: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: module.iconColor,
fontSize: "20px",
}}
>
{module.icon}
</div>
);
return (
<Layout
header={
@@ -82,60 +183,118 @@ const Mine: React.FC = () => {
</div>
</NavBar>
}
footer={<MeauMobile />}
footer={<MeauMobile activeKey="mine" />}
>
<div className={style["mine-page"]}>
{/* 用户信息卡片 */}
<Card className={style["user-card"]}>
<div className={style["user-info"]}>
<div className={style["user-avatar"]}>
<img src={userInfo.avatar} alt="头像" />
</div>
<div className={style["user-avatar"]}>{renderUserAvatar()}</div>
<div className={style["user-details"]}>
<div className={style["user-name"]}>{userInfo.name}</div>
<div className={style["user-level"]}>{userInfo.level}</div>
<div className={style["user-points"]}>
: {userInfo.points}
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "4px",
}}
>
<div className={style["user-name"]}>{currentUserInfo.name}</div>
<span
style={{
padding: "2px 8px",
backgroundColor: "#fa8c16",
color: "white",
borderRadius: "12px",
fontSize: "12px",
fontWeight: "500",
}}
>
{currentUserInfo.role}
</span>
</div>
<div
style={{ fontSize: "14px", color: "#666", marginBottom: "4px" }}
>
{currentUserInfo.email}
</div>
<div style={{ fontSize: "12px", color: "#666" }}>
: {currentUserInfo.lastLogin}
</div>
</div>
<div
style={{ display: "flex", flexDirection: "column", gap: "8px" }}
>
<BellOutlined style={{ fontSize: "20px", color: "#666" }} />
<SettingOutlined style={{ fontSize: "20px", color: "#666" }} />
</div>
</div>
</Card>
{/* 菜单列表 */}
{/* 我的功能 */}
<Card className={style["menu-card"]}>
<List>
{menuItems.map((item, index) => (
{functionModules.map((module) => (
<List.Item
key={index}
prefix={item.icon}
title={item.title}
description={item.subtitle}
key={module.id}
prefix={renderModuleIcon(module)}
title={module.title}
description={module.description}
extra={
<span
style={{
padding: "2px 8px",
backgroundColor: "#f0f0f0",
borderRadius: "12px",
fontSize: "12px",
color: "#666",
}}
>
{module.count}
</span>
}
arrow
onClick={() => {
// 这里可以添加导航逻辑
console.log(`点击了: ${item.title}`);
}}
onClick={() => handleFunctionClick(module.path)}
/>
))}
</List>
</Card>
{/* 退出登录按钮 */}
<div className={style["logout-section"]}>
<Button
block
color="danger"
fill="outline"
className={style["logout-btn"]}
onClick={() => {
// 这里可以添加退出登录逻辑
console.log("退出登录");
}}
>
退
</Button>
</div>
<Button
block
color="danger"
fill="outline"
className={style["logout-btn"]}
onClick={() => setShowLogoutDialog(true)}
>
<LogoutOutlined style={{ marginRight: "8px" }} />
退
</Button>
</div>
{/* 退出登录确认对话框 */}
<Dialog
content="您确定要退出登录吗?退出后需要重新登录才能使用完整功能。"
visible={showLogoutDialog}
closeOnAction
actions={[
[
{
key: "cancel",
text: "取消",
},
{
key: "confirm",
text: "确认退出",
bold: true,
danger: true,
onClick: handleLogout,
},
],
]}
onClose={() => setShowLogoutDialog(false)}
/>
</Layout>
);
};

View File

@@ -1,10 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const Scenarios: React.FC = () => {
return (
<PlaceholderPage title="场景管理" showAddButton addButtonText="新建场景" />
);
};
export default Scenarios;

View File

@@ -82,19 +82,12 @@ const Scene: React.FC = () => {
<Layout
header={
<NavBar back={null} style={{ background: "#fff" }}>
<div className={style["nav-title"]}></div>
<Button
size="small"
color="primary"
onClick={handleNewPlan}
className={style["new-plan-btn"]}
style={{ marginLeft: "auto" }}
>
<div className="nav-title"></div>
<Button size="small" color="primary" onClick={handleNewPlan}>
<PlusOutlined />
</Button>
</NavBar>
}
footer={<MeauMobile />}
>
<div className={style["error"]}>
<div className={style["error-text"]}>{error}</div>
@@ -113,21 +106,20 @@ const Scene: React.FC = () => {
<NavBar
back={null}
style={{ background: "#fff" }}
left={<div className={style["nav-title"]}></div>}
left={<div className="nav-title"></div>}
right={
<Button
size="small"
color="primary"
onClick={handleNewPlan}
className={style["new-plan-btn"]}
style={{ marginLeft: "auto" }}
className="new-plan-btn"
>
<PlusOutlined />
</Button>
}
></NavBar>
}
footer={<MeauMobile />}
footer={<MeauMobile activeKey="scenarios" />}
>
<div className={style["scene-page"]}>
<div className={style["scenarios-grid"]}>

View File

@@ -2,18 +2,6 @@
padding:0 16px;
}
.nav-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.new-plan-btn {
font-size: 14px;
height: 32px;
padding: 0 12px;
}
.loading {
display: flex;
flex-direction: column;

View File

@@ -24,7 +24,7 @@ import {
ClockCircleOutlined,
DownOutlined,
} from "@ant-design/icons";
import { LeftOutline } from "antd-mobile-icons";
import { ArrowLeftOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import {
@@ -356,11 +356,11 @@ const ScenarioList: React.FC = () => {
back={null}
style={{ background: "#fff" }}
left={
<div className={style["nav-title"]}>
<span style={{ verticalAlign: "middle" }}>
<LeftOutline onClick={() => navigate(-1)} fontSize={24} />
</span>
{scenarioName}
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
right={
@@ -368,12 +368,13 @@ const ScenarioList: React.FC = () => {
size="small"
color="primary"
onClick={handleCreateNewPlan}
className={style["new-plan-btn"]}
>
<PlusOutlined />
</Button>
}
/>
>
<span className="nav-title"> {scenarioName}</span>
</NavBar>
{/* 搜索栏 */}
<div className={style["search-bar"]}>
<div className={style["search-input-wrapper"]}>

View File

@@ -0,0 +1,53 @@
import request from "@/api/request";
// 获取场景类型列表
export function getScenarioTypes() {
return request("/v1/scenarios/types", undefined, "GET");
}
// 创建计划
export function createPlan(data: any) {
return request("/v1/scenarios/plans", data, "POST");
}
// 更新计划
export function updatePlan(planId: string, data: any) {
return request(`/v1/scenarios/plans/${planId}`, data, "PUT");
}
// 获取计划详情
export function getPlanDetail(planId: string) {
return request(`/v1/scenarios/plans/${planId}`, undefined, "GET");
}
// PlanDetail 类型定义(可根据实际接口返回结构补充字段)
export interface PlanDetail {
name: string;
scenario: number;
posters: any[];
device: string[];
remarkType: string;
greeting: string;
addInterval: number;
startTime: string;
endTime: string;
enabled: boolean;
sceneId: string | number;
remarkFormat: string;
addFriendInterval: number;
// 其它字段可扩展
[key: string]: any;
}
// 兼容旧代码的接口命名
export function getPlanScenes() {
return getScenarioTypes();
}
export function createScenarioPlan(data: any) {
return createPlan(data);
}
export function fetchPlanDetail(planId: string) {
return getPlanDetail(planId);
}
export function updateScenarioPlan(planId: string, data: any) {
return updatePlan(planId, data);
}

View File

@@ -1,21 +1,20 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { NavBar, Button, Toast, SpinLoading, Steps, Popup } from "antd-mobile";
import { LeftOutline } from "antd-mobile-icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import { LeftOutlined } from "@ant-design/icons";
import { Button, Steps, message } from "antd";
import BasicSettings from "./steps/BasicSettings";
import FriendRequestSettings from "./steps/FriendRequestSettings";
import MessageSettings from "./steps/MessageSettings";
import Layout from "@/components/Layout/Layout";
import {
getScenarioTypes,
createPlan,
updatePlan,
getPlanDetail,
} from "./page.api";
import style from "./page.module.scss";
getPlanScenes,
createScenarioPlan,
fetchPlanDetail,
PlanDetail,
updateScenarioPlan,
} from "./index.api";
// 步骤定义
// 步骤定义 - 只保留三个步骤
const steps = [
{ id: 1, title: "步骤一", subtitle: "基础设置" },
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
@@ -26,7 +25,7 @@ const steps = [
interface FormData {
name: string;
scenario: number;
posters: any[];
posters: any[]; // 后续可替换为具体Poster类型
device: string[];
remarkType: string;
greeting: string;
@@ -39,8 +38,8 @@ interface FormData {
addFriendInterval: number;
}
const NewPlan: React.FC = () => {
const navigate = useNavigate();
export default function NewPlan() {
const router = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<FormData>({
name: "",
@@ -64,57 +63,53 @@ const NewPlan: React.FC = () => {
planId: string;
}>();
const [isEdit, setIsEdit] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setSceneLoading(true);
try {
// 获取场景类型
const res = await getScenarioTypes();
if (res?.data) {
setSceneList(res.data);
}
if (planId) {
setIsEdit(true);
// 获取计划详情
const detailRes = await getPlanDetail(planId);
if (detailRes.code === 200 && detailRes.data) {
const detail = detailRes.data;
setFormData((prev) => ({
...prev,
name: detail.name ?? "",
scenario: Number(detail.scenario) || 1,
posters: detail.posters ?? [],
device: detail.device ?? [],
remarkType: detail.remarkType ?? "phone",
greeting: detail.greeting ?? "",
addInterval: detail.addInterval ?? 1,
startTime: detail.startTime ?? "09:00",
endTime: detail.endTime ?? "18:00",
enabled: detail.enabled ?? true,
sceneId: Number(detail.scenario) || 1,
remarkFormat: detail.remarkFormat ?? "",
addFriendInterval: detail.addFriendInterval ?? 1,
}));
}
} else if (scenarioId) {
//获取场景类型
getPlanScenes()
.then((data) => {
setSceneList(data || []);
})
.catch((err) => {
message.error(err.message || "获取场景类型失败");
})
.finally(() => setSceneLoading(false));
if (planId) {
setIsEdit(true);
//获取计划详情
try {
const detail = await fetchPlanDetail(planId);
setFormData((prev) => ({
...prev,
scenario: Number(scenarioId) || 1,
name: detail.name ?? "",
scenario: Number(detail.scenario) || 1,
posters: detail.posters ?? [],
device: detail.device ?? [],
remarkType: detail.remarkType ?? "phone",
greeting: detail.greeting ?? "",
addInterval: detail.addInterval ?? 1,
startTime: detail.startTime ?? "09:00",
endTime: detail.endTime ?? "18:00",
enabled: detail.enabled ?? true,
sceneId: Number(detail.scenario) || 1,
remarkFormat: detail.remarkFormat ?? "",
addFriendInterval: detail.addFriendInterval ?? 1,
tips: detail.tips ?? "",
}));
} catch (err) {
message.error(err.message || "获取计划详情失败");
}
} else {
if (scenarioId) {
setFormData((prev) => ({
...prev,
...{ scenario: Number(scenarioId) || 1 },
}));
}
} catch (error) {
Toast.show({
content: "加载数据失败",
position: "top",
});
} finally {
setSceneLoading(false);
}
};
@@ -125,52 +120,35 @@ const NewPlan: React.FC = () => {
// 处理保存
const handleSave = async () => {
if (!formData.name.trim()) {
Toast.show({
content: "请输入计划名称",
position: "top",
});
return;
}
setSaving(true);
try {
let result;
if (isEdit && planId) {
// 编辑
// 编辑:拼接后端需要的完整参数
const editData = {
...formData,
id: Number(planId),
planId: Number(planId),
// 兼容后端需要的字段
// 你可以根据实际需要补充其它字段
};
result = await updatePlan(planId, editData);
result = await updateScenarioPlan(planId, editData);
} else {
// 新建
result = await createPlan(formData);
}
if (result.code === 200) {
Toast.show({
content: isEdit ? "计划已更新" : "获客计划已创建",
position: "top",
});
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
navigate(
`/scenarios/list/${formData.sceneId}/${sceneItem?.name || ""}`
);
} else {
Toast.show({
content: result.msg || "操作失败",
position: "top",
});
result = await createScenarioPlan(formData);
}
message.success(isEdit ? "计划已更新" : "获客计划已创建");
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
router(`/scenarios/list/${formData.sceneId}/${sceneItem.name}`);
} catch (error) {
Toast.show({
content: isEdit ? "更新计划失败,请重试" : "创建计划失败,请重试",
position: "top",
});
} finally {
setSaving(false);
message.error(
error instanceof Error
? error.message
: typeof error === "string"
? error
: isEdit
? "更新计划失败,请重试"
: "创建计划失败,请重试"
);
}
};
@@ -194,6 +172,7 @@ const NewPlan: React.FC = () => {
case 1:
return (
<BasicSettings
isEdit={isEdit}
formData={formData}
onChange={onChange}
onNext={handleNext}
@@ -215,9 +194,8 @@ const NewPlan: React.FC = () => {
<MessageSettings
formData={formData}
onChange={onChange}
onNext={handleNext}
onNext={handleSave}
onPrev={handlePrev}
saving={saving}
/>
);
default:
@@ -225,69 +203,37 @@ const NewPlan: React.FC = () => {
}
};
if (sceneLoading) {
return (
<Layout
header={
<NavBar back={null} style={{ background: "#fff" }}>
<div className={style["nav-title"]}>
{isEdit ? "编辑计划" : "新建计划"}
</div>
</NavBar>
}
footer={<MeauMobile />}
>
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
<div className={style["loading-text"]}>...</div>
</div>
</Layout>
);
}
return (
<Layout
header={
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className={style["nav-title"]}>
{isEdit ? "编辑计划" : "新建计划"}
<>
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between h-14 px-4">
<div className="flex items-center">
<Button
type="text"
shape="circle"
icon={<LeftOutlined />}
onClick={() => router(-1)}
/>
</div>
</div>
}
right={
<Button
size="small"
onClick={() => navigate(-1)}
className={style["back-btn"]}
>
<LeftOutline />
</Button>
}
/>
</header>
<div className="px-4 py-6">
<Steps current={currentStep - 1}>
{steps.map((step, idx) => (
<Steps.Step
key={step.id}
title={step.title}
description={step.subtitle}
/>
))}
</Steps>
</div>
</>
}
footer={<MeauMobile />}
>
<div className={style["new-plan-page"]}>
{/* 步骤指示器 */}
<div className={style["steps-container"]}>
<Steps current={currentStep - 1}>
{steps.map((step) => (
<Steps.Step
key={step.id}
title={step.title}
description={step.subtitle}
/>
))}
</Steps>
</div>
{/* 步骤内容 */}
<div className={style["step-content"]}>{renderStepContent()}</div>
</div>
<div className="p-4">{renderStepContent()}</div>
</Layout>
);
};
export default NewPlan;
}

View File

@@ -1,20 +0,0 @@
import request from "@/api/request";
// 获取场景类型列表
export function getScenarioTypes() {
return request("/api/scenarios/types", undefined, "GET");
}
// 创建计划
export function createPlan(data: any) {
return request("/api/scenarios/plans", data, "POST");
}
// 更新计划
export function updatePlan(planId: string, data: any) {
return request(`/api/scenarios/plans/${planId}`, data, "PUT");
}
// 获取计划详情
export function getPlanDetail(planId: string) {
return request(`/api/scenarios/plans/${planId}`, undefined, "GET");
}

View File

@@ -1,43 +0,0 @@
.new-plan-page {
background: #f5f5f5;
min-height: 100vh;
}
.nav-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.back-btn {
height: 32px;
width: 32px;
padding: 0;
border-radius: 50%;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 16px;
}
.loading-text {
color: #666;
font-size: 14px;
}
.steps-container {
background: white;
padding: 20px 16px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.step-content {
flex: 1;
padding: 0 16px;
}

View File

@@ -1,63 +0,0 @@
.basic-settings {
padding: 16px 0;
}
.form-card {
margin-bottom: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-item {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.adm-form-item-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.adm-input {
border-radius: 8px;
}
.adm-selector {
border-radius: 8px;
}
}
.time-input {
width: 120px;
border-radius: 8px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 40vh;
gap: 16px;
}
.loading-text {
color: #666;
font-size: 14px;
}
.actions {
padding: 20px 0;
}
.next-btn {
width: 100%;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +0,0 @@
.friend-request-settings {
padding: 16px 0;
}
.form-card {
margin-bottom: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-item {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.adm-form-item-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.adm-input {
border-radius: 8px;
}
.adm-selector {
border-radius: 8px;
}
.adm-text-area {
border-radius: 8px;
}
}
.actions {
padding: 20px 0;
}
.prev-btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}
.next-btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}

View File

@@ -1,113 +1,259 @@
import React from "react";
import {
Form,
Input,
Selector,
Button,
Card,
Space,
TextArea,
} from "antd-mobile";
import style from "./FriendRequestSettings.module.scss";
interface FriendRequestSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext: () => void;
onPrev: () => void;
}
const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
formData,
onChange,
onNext,
onPrev,
}) => {
const remarkTypeOptions = [
{ label: "手机号", value: "phone" },
{ label: "微信号", value: "wechat" },
{ label: "QQ号", value: "qq" },
{ label: "自定义", value: "custom" },
];
const handleNext = () => {
if (!formData.greeting.trim()) {
// 可以添加验证逻辑
}
onNext();
};
return (
<div className={style["friend-request-settings"]}>
<Card className={style["form-card"]}>
<Form layout="vertical">
{/* 备注类型 */}
<Form.Item label="备注类型" required className={style["form-item"]}>
<Selector
options={remarkTypeOptions}
value={[formData.remarkType]}
onChange={(value) => onChange({ remarkType: value[0] })}
/>
</Form.Item>
{/* 备注格式 */}
{formData.remarkType === "custom" && (
<Form.Item label="备注格式" required className={style["form-item"]}>
<Input
placeholder="请输入备注格式,如:{name}-{phone}"
value={formData.remarkFormat}
onChange={(value) => onChange({ remarkFormat: value })}
clearable
/>
</Form.Item>
)}
{/* 打招呼消息 */}
<Form.Item label="打招呼消息" required className={style["form-item"]}>
<TextArea
placeholder="请输入打招呼消息"
value={formData.greeting}
onChange={(value) => onChange({ greeting: value })}
rows={4}
maxLength={200}
showCount
/>
</Form.Item>
{/* 好友申请间隔 */}
<Form.Item label="好友申请间隔(分钟)" className={style["form-item"]}>
<Input
type="number"
placeholder="请输入好友申请间隔"
value={formData.addFriendInterval.toString()}
onChange={(value) =>
onChange({ addFriendInterval: Number(value) || 1 })
}
min={1}
max={60}
/>
</Form.Item>
</Form>
</Card>
{/* 操作按钮 */}
<div className={style["actions"]}>
<Space style={{ width: "100%" }}>
<Button size="large" onClick={onPrev} className={style["prev-btn"]}>
</Button>
<Button
color="primary"
size="large"
onClick={handleNext}
className={style["next-btn"]}
>
</Button>
</Space>
</div>
</div>
);
};
export default FriendRequestSettings;
"use client";
import React, { useState, useEffect } from "react";
import {
Form,
Input,
Button,
Checkbox,
Modal,
Alert,
Select,
message,
} from "antd";
import { QuestionCircleOutlined, MessageOutlined } from "@ant-design/icons";
import DeviceSelection from "@/components/DeviceSelection";
interface FriendRequestSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext: () => void;
onPrev: () => void;
}
// 招呼语模板
const greetingTemplates = [
"你好,请通过",
"你好,了解XX,请通过",
"你好我是XX产品的客服请通过",
"你好,感谢关注我们的产品",
"你好,很高兴为您服务",
];
// 备注类型选项
const remarkTypes = [
{ value: "phone", label: "手机号" },
{ value: "nickname", label: "昵称" },
{ value: "source", label: "来源" },
];
const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
formData,
onChange,
onNext,
onPrev,
}) => {
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
const [hasWarnings, setHasWarnings] = useState(false);
const [selectedDevices, setSelectedDevices] = useState<any[]>(
formData.selectedDevices || []
);
const [showRemarkTip, setShowRemarkTip] = useState(false);
// 获取场景标题
const getScenarioTitle = () => {
switch (formData.scenario) {
case "douyin":
return "抖音直播";
case "xiaohongshu":
return "小红书";
case "weixinqun":
return "微信群";
case "gongzhonghao":
return "公众号";
default:
return formData.name || "获客计划";
}
};
// 使用useEffect设置默认值
useEffect(() => {
if (!formData.greeting) {
onChange({
...formData,
greeting: "你好,请通过",
remarkType: "phone", // 默认选择手机号
remarkFormat: `手机号+${getScenarioTitle()}`, // 默认备注格式
addFriendInterval: 1,
});
}
}, [formData, formData.greeting, onChange]);
// 检查是否有未完成的必填项
useEffect(() => {
const hasIncompleteFields = !formData.greeting?.trim();
setHasWarnings(hasIncompleteFields);
}, [formData]);
const handleTemplateSelect = (template: string) => {
onChange({ ...formData, greeting: template });
setIsTemplateDialogOpen(false);
};
const handleNext = () => {
// 即使有警告也允许进入下一步,但会显示提示
onNext();
};
return (
<>
<div className="space-y-6">
<div>
<span className="font-medium text-base"></span>
<div className="mt-2">
<DeviceSelection
selectedDevices={selectedDevices.map((d) => d.id)}
onSelect={(deviceIds) => {
const newSelectedDevices = deviceIds.map((id) => ({
id,
name: `设备 ${id}`,
status: "online",
}));
setSelectedDevices(newSelectedDevices);
onChange({ ...formData, device: deviceIds });
}}
placeholder="选择设备"
/>
</div>
</div>
<div className="mb-4">
<div className="flex items-center space-x-2 mb-1 relative">
<span className="font-medium text-base"></span>
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-gray-200 text-gray-500 text-xs cursor-pointer hover:bg-gray-300 transition-colors"
onMouseEnter={() => setShowRemarkTip(true)}
onMouseLeave={() => setShowRemarkTip(false)}
onClick={() => setShowRemarkTip((v) => !v)}
>
?
</span>
{showRemarkTip && (
<div className="absolute left-24 top-0 z-20 w-64 p-3 bg-white border border-gray-200 rounded shadow-lg text-sm text-gray-700">
<div></div>
<div className="mt-2 text-xs text-gray-500"></div>
<div className="mt-1 text-blue-600">
{formData.remarkType === "phone" &&
`138****1234+${getScenarioTitle()}`}
{formData.remarkType === "nickname" &&
`小红书用户2851+${getScenarioTitle()}`}
{formData.remarkType === "source" &&
`抖音直播+${getScenarioTitle()}`}
</div>
</div>
)}
</div>
<Select
value={formData.remarkType || "phone"}
onChange={(value) => onChange({ ...formData, remarkType: value })}
className="w-full mt-2"
>
{remarkTypes.map((type) => (
<Select.Option key={type.value} value={type.value}>
{type.label}
</Select.Option>
))}
</Select>
</div>
<div>
<div className="flex items-center justify-between">
<span className="font-medium text-base"></span>
<Button
onClick={() => setIsTemplateDialogOpen(true)}
className="text-blue-500"
>
<MessageOutlined className="h-4 w-4 mr-2" />
</Button>
</div>
<Input
value={formData.greeting}
onChange={(e) =>
onChange({ ...formData, greeting: e.target.value })
}
placeholder="请输入招呼语"
className="mt-2"
/>
</div>
<div>
<span className="font-medium text-base"></span>
<div className="flex items-center space-x-2 mt-2">
<Input
type="number"
value={formData.addFriendInterval || 1}
onChange={(e) =>
onChange({
...formData,
addFriendInterval: Number(e.target.value),
})
}
/>
<div className="w-10"></div>
</div>
</div>
<div>
<span className="font-medium text-base"></span>
<div className="flex items-center space-x-2 mt-2">
<Input
type="time"
value={formData.addFriendTimeStart || "09:00"}
onChange={(e) =>
onChange({ ...formData, addFriendTimeStart: e.target.value })
}
className="w-32"
/>
<span></span>
<Input
type="time"
value={formData.addFriendTimeEnd || "18:00"}
onChange={(e) =>
onChange({ ...formData, addFriendTimeEnd: e.target.value })
}
className="w-32"
/>
</div>
</div>
{hasWarnings && (
<Alert
message="警告"
description="您有未完成的设置项,建议完善后再进入下一步。"
type="warning"
showIcon
className="bg-amber-50 border-amber-200"
/>
)}
<div className="flex justify-between pt-4">
<Button onClick={onPrev}></Button>
<Button type="primary" onClick={handleNext}>
</Button>
</div>
</div>
<Modal
open={isTemplateDialogOpen}
onCancel={() => setIsTemplateDialogOpen(false)}
footer={null}
>
<div className="space-y-2">
{greetingTemplates.map((template, index) => (
<Button
key={index}
onClick={() => handleTemplateSelect(template)}
style={{ width: "100%", marginBottom: 8 }}
>
{template}
</Button>
))}
</div>
</Modal>
</>
);
};
export default FriendRequestSettings;

View File

@@ -1,64 +0,0 @@
.message-settings {
padding: 16px 0;
}
.form-card {
margin-bottom: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-item {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.adm-form-item-label {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.adm-input {
border-radius: 8px;
}
.adm-text-area {
border-radius: 8px;
}
}
.switch-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
span {
font-size: 14px;
color: #333;
}
}
.actions {
padding: 20px 0;
}
.prev-btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}
.save-btn {
flex: 1;
height: 48px;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
}

View File

@@ -1,140 +1,603 @@
import React from "react";
import {
Form,
Input,
Selector,
Button,
Card,
Space,
TextArea,
Switch,
} from "antd-mobile";
import style from "./MessageSettings.module.scss";
interface MessageSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext: () => void;
onPrev: () => void;
saving: boolean;
}
const MessageSettings: React.FC<MessageSettingsProps> = ({
formData,
onChange,
onNext,
onPrev,
saving,
}) => {
const handleSave = () => {
onNext();
};
return (
<div className={style["message-settings"]}>
<Card className={style["form-card"]}>
<Form layout="vertical">
{/* 启用状态 */}
<Form.Item label="启用状态" className={style["form-item"]}>
<div className={style["switch-item"]}>
<span></span>
<Switch
checked={formData.enabled}
onChange={(checked) => onChange({ enabled: checked })}
/>
</div>
</Form.Item>
{/* 自动回复消息 */}
<Form.Item label="自动回复消息" className={style["form-item"]}>
<TextArea
placeholder="请输入自动回复消息"
value={formData.autoReply || ""}
onChange={(value) => onChange({ autoReply: value })}
rows={4}
maxLength={500}
showCount
/>
</Form.Item>
{/* 关键词回复 */}
<Form.Item label="关键词回复" className={style["form-item"]}>
<TextArea
placeholder="请输入关键词回复规则,格式:关键词=回复内容"
value={formData.keywordReply || ""}
onChange={(value) => onChange({ keywordReply: value })}
rows={4}
maxLength={1000}
showCount
/>
</Form.Item>
{/* 群发消息 */}
<Form.Item label="群发消息" className={style["form-item"]}>
<TextArea
placeholder="请输入群发消息内容"
value={formData.groupMessage || ""}
onChange={(value) => onChange({ groupMessage: value })}
rows={4}
maxLength={500}
showCount
/>
</Form.Item>
{/* 消息发送间隔 */}
<Form.Item label="消息发送间隔(秒)" className={style["form-item"]}>
<Input
type="number"
placeholder="请输入消息发送间隔"
value={formData.messageInterval?.toString() || "5"}
onChange={(value) =>
onChange({ messageInterval: Number(value) || 5 })
}
min={1}
max={300}
/>
</Form.Item>
{/* 每日发送限制 */}
<Form.Item label="每日发送限制" className={style["form-item"]}>
<Input
type="number"
placeholder="请输入每日发送限制数量"
value={formData.dailyLimit?.toString() || "100"}
onChange={(value) =>
onChange({ dailyLimit: Number(value) || 100 })
}
min={1}
max={1000}
/>
</Form.Item>
</Form>
</Card>
{/* 操作按钮 */}
<div className={style["actions"]}>
<Space style={{ width: "100%" }}>
<Button
size="large"
onClick={onPrev}
className={style["prev-btn"]}
disabled={saving}
>
</Button>
<Button
color="primary"
size="large"
onClick={handleSave}
className={style["save-btn"]}
loading={saving}
>
{saving ? "保存中..." : "保存计划"}
</Button>
</Space>
</div>
</div>
);
};
export default MessageSettings;
import React, { useState } from "react";
import { Form, Input, Button, Tabs, Modal, Alert, Upload, message } from "antd";
import {
PlusOutlined,
CloseOutlined,
UploadOutlined,
ClockCircleOutlined,
MessageOutlined,
PictureOutlined,
VideoCameraOutlined,
FileOutlined,
AppstoreOutlined,
LinkOutlined,
TeamOutlined,
} from "@ant-design/icons";
interface MessageContent {
id: string;
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group";
content: string;
sendInterval?: number;
intervalUnit?: "seconds" | "minutes";
scheduledTime?: {
hour: number;
minute: number;
second: number;
};
title?: string;
description?: string;
address?: string;
coverImage?: string;
groupId?: string;
linkUrl?: string;
}
interface DayPlan {
day: number;
messages: MessageContent[];
}
interface MessageSettingsProps {
formData: any;
onChange: (data: any) => void;
onNext: () => void;
onPrev: () => void;
}
// 消息类型配置
const messageTypes = [
{ id: "text", icon: MessageOutlined, label: "文本" },
{ id: "image", icon: PictureOutlined, label: "图片" },
{ id: "video", icon: VideoCameraOutlined, label: "视频" },
{ id: "file", icon: FileOutlined, label: "文件" },
{ id: "miniprogram", icon: AppstoreOutlined, label: "小程序" },
{ id: "link", icon: LinkOutlined, label: "链接" },
{ id: "group", icon: TeamOutlined, label: "邀请入群" },
];
// 模拟群组数据
const mockGroups = [
{ id: "1", name: "产品交流群1", memberCount: 156 },
{ id: "2", name: "产品交流群2", memberCount: 234 },
{ id: "3", name: "产品交流群3", memberCount: 89 },
];
const MessageSettings: React.FC<MessageSettingsProps> = ({
formData,
onChange,
onNext,
onPrev,
}) => {
const [dayPlans, setDayPlans] = useState<DayPlan[]>([
{
day: 0,
messages: [
{
id: "1",
type: "text",
content: "",
sendInterval: 5,
intervalUnit: "seconds",
},
],
},
]);
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false);
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
const [selectedGroupId, setSelectedGroupId] = useState("");
// 添加新消息
const handleAddMessage = (dayIndex: number, type = "text") => {
const updatedPlans = [...dayPlans];
const newMessage: MessageContent = {
id: Date.now().toString(),
type: type as MessageContent["type"],
content: "",
};
if (dayPlans[dayIndex].day === 0) {
newMessage.sendInterval = 5;
newMessage.intervalUnit = "seconds";
} else {
newMessage.scheduledTime = {
hour: 9,
minute: 0,
second: 0,
};
}
updatedPlans[dayIndex].messages.push(newMessage);
setDayPlans(updatedPlans);
onChange({ ...formData, messagePlans: updatedPlans });
};
// 更新消息内容
const handleUpdateMessage = (
dayIndex: number,
messageIndex: number,
updates: Partial<MessageContent>
) => {
const updatedPlans = [...dayPlans];
updatedPlans[dayIndex].messages[messageIndex] = {
...updatedPlans[dayIndex].messages[messageIndex],
...updates,
};
setDayPlans(updatedPlans);
onChange({ ...formData, messagePlans: updatedPlans });
};
// 删除消息
const handleRemoveMessage = (dayIndex: number, messageIndex: number) => {
const updatedPlans = [...dayPlans];
updatedPlans[dayIndex].messages.splice(messageIndex, 1);
setDayPlans(updatedPlans);
onChange({ ...formData, messagePlans: updatedPlans });
};
// 切换时间单位
const toggleIntervalUnit = (dayIndex: number, messageIndex: number) => {
const message = dayPlans[dayIndex].messages[messageIndex];
const newUnit = message.intervalUnit === "minutes" ? "seconds" : "minutes";
handleUpdateMessage(dayIndex, messageIndex, { intervalUnit: newUnit });
};
// 添加新的天数计划
const handleAddDayPlan = () => {
const newDay = dayPlans.length;
setDayPlans([
...dayPlans,
{
day: newDay,
messages: [
{
id: Date.now().toString(),
type: "text",
content: "",
scheduledTime: {
hour: 9,
minute: 0,
second: 0,
},
},
],
},
]);
setIsAddDayPlanOpen(false);
message.success(`已添加第${newDay}天的消息计划`);
};
// 选择群组
const handleSelectGroup = (groupId: string) => {
setSelectedGroupId(groupId);
setIsGroupSelectOpen(false);
message.success(
`已选择群组:${mockGroups.find((g) => g.id === groupId)?.name}`
);
};
// 处理文件上传
const handleFileUpload = (
dayIndex: number,
messageIndex: number,
type: "image" | "video" | "file"
) => {
message.success(
`${
type === "image" ? "图片" : type === "video" ? "视频" : "文件"
}上传成功`
);
};
const items = dayPlans.map((plan, dayIndex) => ({
key: plan.day.toString(),
label: plan.day === 0 ? "即时消息" : `${plan.day}`,
children: (
<div className="space-y-4">
{plan.messages.map((message, messageIndex) => (
<div key={message.id} className="space-y-4 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{plan.day === 0 ? (
<>
<div className="w-10"></div>
<div className="w-40">
<Input
type="number"
value={String(message.sendInterval)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
sendInterval: Number(e.target.value),
})
}
/>
</div>
<Button
onClick={() => toggleIntervalUnit(dayIndex, messageIndex)}
className="flex items-center space-x-1"
>
<ClockCircleOutlined className="h-3 w-3" />
<span>
{message.intervalUnit === "minutes" ? "分钟" : "秒"}
</span>
</Button>
</>
) : (
<>
<div className="font-medium"></div>
<div className="flex items-center space-x-1">
<Input
type="number"
min={0}
max={23}
value={String(message.scheduledTime?.hour || 0)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
hour: Number(e.target.value),
},
})
}
className="w-16"
/>
<span>:</span>
<Input
type="number"
min={0}
max={59}
value={String(message.scheduledTime?.minute || 0)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
minute: Number(e.target.value),
},
})
}
className="w-16"
/>
<span>:</span>
<Input
type="number"
min={0}
max={59}
value={String(message.scheduledTime?.second || 0)}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
scheduledTime: {
...(message.scheduledTime || {
hour: 0,
minute: 0,
second: 0,
}),
second: Number(e.target.value),
},
})
}
className="w-16"
/>
</div>
</>
)}
</div>
<Button
onClick={() => handleRemoveMessage(dayIndex, messageIndex)}
>
<CloseOutlined className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center space-x-2 bg-white p-2 rounded-lg">
{messageTypes.map((type) => (
<Button
key={type.id}
type={message.type === type.id ? "primary" : "default"}
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
type: type.id as any,
})
}
className="flex flex-col items-center p-2 h-auto"
>
<type.icon className="h-4 w-4" />
</Button>
))}
</div>
{message.type === "text" && (
<Input.TextArea
value={message.content}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
content: e.target.value,
})
}
placeholder="请输入消息内容"
className="min-h-[100px]"
/>
)}
{message.type === "miniprogram" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.title}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
title: e.target.value,
})
}
placeholder="请输入小程序标题"
/>
</div>
<div className="space-y-2">
<div className="font-medium"></div>
<Input
value={message.description}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
description: e.target.value,
})
}
placeholder="请输入小程序描述"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.address}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
address: e.target.value,
})
}
placeholder="请输入小程序路径"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
coverImage: undefined,
})
}
>
<CloseOutlined className="h-4 w-4" />
</Button>
</div>
) : (
<Button
onClick={() =>
handleFileUpload(dayIndex, messageIndex, "image")
}
>
<UploadOutlined className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
{message.type === "link" && (
<div className="space-y-4">
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.title}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
title: e.target.value,
})
}
placeholder="请输入链接标题"
/>
</div>
<div className="space-y-2">
<div className="font-medium"></div>
<Input
value={message.description}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
description: e.target.value,
})
}
placeholder="请输入链接描述"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Input
value={message.linkUrl}
onChange={(e) =>
handleUpdateMessage(dayIndex, messageIndex, {
linkUrl: e.target.value,
})
}
placeholder="请输入链接地址"
/>
</div>
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<div className="border-2 border-dashed rounded-lg p-4 text-center">
{message.coverImage ? (
<div className="relative">
<img
src={message.coverImage || "/placeholder.svg"}
alt="封面"
className="max-w-[200px] mx-auto rounded-lg"
/>
<Button
onClick={() =>
handleUpdateMessage(dayIndex, messageIndex, {
coverImage: undefined,
})
}
>
<CloseOutlined className="h-4 w-4" />
</Button>
</div>
) : (
<Button
onClick={() =>
handleFileUpload(dayIndex, messageIndex, "image")
}
>
<UploadOutlined className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</div>
</div>
)}
{message.type === "group" && (
<div className="space-y-2">
<div className="font-medium">
<span className="text-red-500">*</span>
</div>
<Button onClick={() => setIsGroupSelectOpen(true)}>
{selectedGroupId
? mockGroups.find((g) => g.id === selectedGroupId)?.name
: "选择邀请入的群"}
</Button>
</div>
)}
{(message.type === "image" ||
message.type === "video" ||
message.type === "file") && (
<div className="border-2 border-dashed rounded-lg p-4 text-center">
<Button
onClick={() =>
handleFileUpload(
dayIndex,
messageIndex,
message.type as any
)
}
>
<UploadOutlined className="h-4 w-4 mr-2" />
{message.type === "image"
? "图片"
: message.type === "video"
? "视频"
: "文件"}
</Button>
</div>
)}
</div>
))}
<Button onClick={() => handleAddMessage(dayIndex)} className="w-full">
<PlusOutlined className="w-4 h-4 mr-2" />
</Button>
</div>
),
}));
return (
<>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Button onClick={() => setIsAddDayPlanOpen(true)}>
<PlusOutlined className="h-4 w-4" />
</Button>
</div>
<Tabs defaultActiveKey="0" items={items} />
<div className="flex justify-between pt-4">
<Button onClick={onPrev}></Button>
<Button type="primary" onClick={onNext}>
</Button>
</div>
</div>
{/* 添加天数计划弹窗 */}
<Modal
title="添加消息计划"
open={isAddDayPlanOpen}
onCancel={() => setIsAddDayPlanOpen(false)}
onOk={() => {
handleAddDayPlan();
setIsAddDayPlanOpen(false);
}}
>
<p className="text-sm text-gray-500 mb-4"></p>
<Button onClick={handleAddDayPlan} className="w-full">
{dayPlans.length}
</Button>
</Modal>
{/* 选择群聊弹窗 */}
<Modal
title="选择群聊"
open={isGroupSelectOpen}
onCancel={() => setIsGroupSelectOpen(false)}
onOk={() => {
handleSelectGroup(selectedGroupId);
setIsGroupSelectOpen(false);
}}
>
<div className="space-y-2">
{mockGroups.map((group) => (
<div
key={group.id}
className={`p-4 rounded-lg cursor-pointer hover:bg-gray-100 ${
selectedGroupId === group.id
? "bg-blue-50 border border-blue-200"
: ""
}`}
onClick={() => handleSelectGroup(group.id)}
>
<div className="font-medium">{group.name}</div>
<div className="text-sm text-gray-500">
{group.memberCount}
</div>
</div>
))}
</div>
</Modal>
</>
);
};
export default MessageSettings;

View File

@@ -1,10 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const AutoGroup: React.FC = () => {
return (
<PlaceholderPage title="自动分组" showAddButton addButtonText="新建分组" />
);
};
export default AutoGroup;

View File

@@ -1,8 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const AutoGroupDetail: React.FC = () => {
return <PlaceholderPage title="自动分组详情" />;
};
export default AutoGroupDetail;

View File

@@ -0,0 +1,6 @@
import request from "@/api/request";
// 获取自动建群任务详情
export function getAutoGroupDetail(id: string) {
return request(`/api/auto-group/detail/${id}`);
}

View File

@@ -0,0 +1,149 @@
.autoGroupDetail {
padding: 16px 0 80px 0;
background: #f7f8fa;
min-height: 100vh;
}
.headerBar {
display: flex;
align-items: center;
height: 48px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
font-size: 18px;
font-weight: 600;
padding: 0 16px;
}
.title {
font-size: 18px;
font-weight: 600;
color: #222;
flex: 1;
text-align: center;
}
.infoCard {
margin-bottom: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
border: none;
background: #fff;
padding: 16px;
}
.infoGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.infoTitle {
font-size: 14px;
font-weight: 500;
color: #1677ff;
margin-bottom: 4px;
}
.infoItem {
font-size: 13px;
color: #444;
margin-bottom: 2px;
}
.progressSection {
margin-top: 16px;
}
.progressCard {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
border: none;
background: #fff;
padding: 16px;
margin-bottom: 16px;
}
.progressHeader {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 15px;
font-weight: 500;
margin-bottom: 8px;
}
.groupList {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
.groupCard {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
border: none;
background: #fff;
padding: 12px 16px;
}
.groupHeader {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 15px;
font-weight: 500;
margin-bottom: 8px;
}
.memberGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
margin-top: 8px;
}
.memberItem {
background: #f5f7fa;
border-radius: 8px;
padding: 4px 8px;
font-size: 13px;
color: #333;
display: flex;
align-items: center;
}
.warnText {
color: #faad14;
font-size: 13px;
margin-top: 8px;
display: flex;
align-items: center;
}
.successText {
color: #389e0d;
font-size: 13px;
margin-top: 8px;
display: flex;
align-items: center;
}
.successAlert {
color: #389e0d;
background: #f6ffed;
border-radius: 8px;
padding: 8px 0;
text-align: center;
margin-top: 12px;
font-size: 14px;
}
.emptyCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
margin-top: 32px;
}
.emptyTitle {
font-size: 16px;
color: #888;
margin: 12px 0 4px 0;
}
.emptyDesc {
font-size: 13px;
color: #bbb;
margin-bottom: 16px;
}

View File

@@ -0,0 +1,384 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Card,
Button,
Toast,
ProgressBar,
Tag,
SpinLoading,
} from "antd-mobile";
import { TeamOutline, LeftOutline } from "antd-mobile-icons";
import { AlertOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import style from "./index.module.scss";
interface GroupMember {
id: string;
nickname: string;
wechatId: string;
tags: string[];
}
interface Group {
id: string;
members: GroupMember[];
}
interface GroupTaskDetail {
id: string;
name: string;
status: "preparing" | "creating" | "completed" | "paused";
totalGroups: number;
currentGroupIndex: number;
groups: Group[];
createTime: string;
lastUpdateTime: string;
creator: string;
deviceCount: number;
targetFriends: number;
groupSize: { min: number; max: number };
timeRange: { start: string; end: string };
targetTags: string[];
groupNameTemplate: string;
groupDescription: string;
}
const mockTaskDetail: GroupTaskDetail = {
id: "1",
name: "VIP客户建群",
status: "creating",
totalGroups: 5,
currentGroupIndex: 2,
groups: Array.from({ length: 5 }).map((_, index) => ({
id: `group-${index}`,
members: Array.from({ length: Math.floor(Math.random() * 10) + 30 }).map(
(_, mIndex) => ({
id: `member-${index}-${mIndex}`,
nickname: `用户${mIndex + 1}`,
wechatId: `wx_${mIndex}`,
tags: [`标签${(mIndex % 3) + 1}`],
})
),
})),
createTime: "2024-11-20 19:04:14",
lastUpdateTime: "2025-02-06 13:12:35",
creator: "admin",
deviceCount: 2,
targetFriends: 156,
groupSize: { min: 20, max: 50 },
timeRange: { start: "09:00", end: "21:00" },
targetTags: ["VIP客户", "高价值"],
groupNameTemplate: "VIP客户交流群{序号}",
groupDescription: "VIP客户专属交流群提供优质服务",
};
const GroupPreview: React.FC<{
groupIndex: number;
members: GroupMember[];
isCreating: boolean;
isCompleted: boolean;
onRetry?: () => void;
}> = ({ groupIndex, members, isCreating, isCompleted, onRetry }) => {
const [expanded, setExpanded] = useState(false);
const targetSize = 38;
return (
<Card className={style.groupCard}>
<div className={style.groupHeader}>
<div>
{groupIndex + 1}
<Tag
color={isCompleted ? "success" : isCreating ? "warning" : "default"}
style={{ marginLeft: 8 }}
>
{isCompleted ? "已完成" : isCreating ? "创建中" : "等待中"}
</Tag>
</div>
<div style={{ color: "#888", fontSize: 12 }}>
<TeamOutline style={{ marginRight: 4 }} />
{members.length}/{targetSize}
</div>
</div>
{isCreating && !isCompleted && (
<ProgressBar
percent={Math.round((members.length / targetSize) * 100)}
style={{ margin: "8px 0" }}
/>
)}
{expanded ? (
<>
<div className={style.memberGrid}>
{members.map((member) => (
<div key={member.id} className={style.memberItem}>
<span>{member.nickname}</span>
{member.tags.length > 0 && (
<Tag color="primary" style={{ marginLeft: 4 }}>
{member.tags[0]}
</Tag>
)}
</div>
))}
</div>
<Button
size="mini"
fill="none"
block
onClick={() => setExpanded(false)}
style={{ marginTop: 8 }}
>
</Button>
</>
) : (
<Button
size="mini"
fill="none"
block
onClick={() => setExpanded(true)}
style={{ marginTop: 8 }}
>
({members.length})
</Button>
)}
{!isCompleted && members.length < targetSize && (
<div className={style.warnText}>
<AlertOutlined style={{ marginRight: 4 }} />
{targetSize}
{onRetry && (
<Button
size="mini"
fill="none"
color="primary"
style={{ marginLeft: 8 }}
onClick={onRetry}
>
</Button>
)}
</div>
)}
{isCompleted && <div className={style.successText}></div>}
</Card>
);
};
const GroupCreationProgress: React.FC<{
taskDetail: GroupTaskDetail;
onComplete: () => void;
}> = ({ taskDetail, onComplete }) => {
const [groups, setGroups] = useState<Group[]>(taskDetail.groups);
const [currentGroupIndex, setCurrentGroupIndex] = useState(
taskDetail.currentGroupIndex
);
const [status, setStatus] = useState<GroupTaskDetail["status"]>(
taskDetail.status
);
useEffect(() => {
if (status === "creating" && currentGroupIndex < groups.length) {
const timer = setTimeout(() => {
if (currentGroupIndex === groups.length - 1) {
setStatus("completed");
onComplete();
} else {
setCurrentGroupIndex((prev) => prev + 1);
}
}, 3000);
return () => clearTimeout(timer);
}
}, [status, currentGroupIndex, groups.length, onComplete]);
const handleRetryGroup = (groupIndex: number) => {
setGroups((prev) =>
prev.map((group, index) => {
if (index === groupIndex) {
return {
...group,
members: [
...group.members,
{
id: `retry-member-${Date.now()}`,
nickname: `补充用户${group.members.length + 1}`,
wechatId: `wx_retry_${Date.now()}`,
tags: ["新加入"],
},
],
};
}
return group;
})
);
};
return (
<div className={style.progressSection}>
<Card className={style.progressCard}>
<div className={style.progressHeader}>
<div>
<Tag
color={
status === "completed"
? "success"
: status === "creating"
? "warning"
: "default"
}
style={{ marginLeft: 8 }}
>
{status === "preparing"
? "准备中"
: status === "creating"
? "创建中"
: "已完成"}
</Tag>
</div>
<div style={{ color: "#888", fontSize: 12 }}>
{currentGroupIndex + 1}/{groups.length}
</div>
</div>
<ProgressBar
percent={Math.round(((currentGroupIndex + 1) / groups.length) * 100)}
style={{ marginTop: 8 }}
/>
</Card>
<div className={style.groupList}>
{groups.map((group, index) => (
<GroupPreview
key={group.id}
groupIndex={index}
members={group.members}
isCreating={status === "creating" && index === currentGroupIndex}
isCompleted={status === "completed" || index < currentGroupIndex}
onRetry={() => handleRetryGroup(index)}
/>
))}
</div>
{status === "completed" && (
<div className={style.successAlert}></div>
)}
</div>
);
};
const AutoGroupDetail: React.FC = () => {
const { id } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [taskDetail, setTaskDetail] = useState<GroupTaskDetail | null>(null);
useEffect(() => {
setLoading(true);
setTimeout(() => {
setTaskDetail(mockTaskDetail);
setLoading(false);
}, 800);
}, [id]);
const handleComplete = () => {
Toast.show({ content: "所有群组已创建完成" });
};
if (loading) {
return (
<Layout
header={
<div className={style.headerBar}>
<Button fill="none" size="small" onClick={() => navigate(-1)}>
<LeftOutline />
</Button>
<div className={style.title}></div>
</div>
}
footer={<MeauMobile />}
loading={true}
>
<div style={{ minHeight: 300 }} />
</Layout>
);
}
if (!taskDetail) {
return (
<Layout
header={
<div className={style.headerBar}>
<Button fill="none" size="small" onClick={() => navigate(-1)}>
<LeftOutline />
</Button>
<div className={style.title}></div>
</div>
}
footer={<MeauMobile />}
>
<Card className={style.emptyCard}>
<AlertOutlined style={{ fontSize: 48, color: "#ccc" }} />
<div className={style.emptyTitle}></div>
<div className={style.emptyDesc}>ID是否正确</div>
<Button
color="primary"
onClick={() => navigate("/workspace/auto-group")}
>
</Button>
</Card>
</Layout>
);
}
return (
<Layout
header={
<div className={style.headerBar}>
<Button fill="none" size="small" onClick={() => navigate(-1)}>
<LeftOutline />
</Button>
<div className={style.title}>{taskDetail.name} - </div>
</div>
}
footer={<MeauMobile />}
>
<div className={style.autoGroupDetail}>
<Card className={style.infoCard}>
<div className={style.infoGrid}>
<div>
<div className={style.infoTitle}></div>
<div className={style.infoItem}>{taskDetail.name}</div>
<div className={style.infoItem}>
{taskDetail.createTime}
</div>
<div className={style.infoItem}>{taskDetail.creator}</div>
<div className={style.infoItem}>
{taskDetail.deviceCount}
</div>
</div>
<div>
<div className={style.infoTitle}></div>
<div className={style.infoItem}>
{taskDetail.groupSize.min}-{taskDetail.groupSize.max}{" "}
</div>
<div className={style.infoItem}>
{taskDetail.timeRange.start} -{" "}
{taskDetail.timeRange.end}
</div>
<div className={style.infoItem}>
{taskDetail.targetTags.join(", ")}
</div>
<div className={style.infoItem}>
{taskDetail.groupNameTemplate}
</div>
</div>
</div>
</Card>
<GroupCreationProgress
taskDetail={taskDetail}
onComplete={handleComplete}
/>
</div>
</Layout>
);
};
export default AutoGroupDetail;

View File

@@ -0,0 +1,11 @@
import request from "@/api/request";
// 新建自动建群任务
export function createAutoGroup(data: any) {
return request("/api/auto-group/create", data, "POST");
}
// 编辑自动建群任务
export function updateAutoGroup(id: string, data: any) {
return request(`/api/auto-group/update/${id}`, data, "POST");
}

View File

@@ -0,0 +1,34 @@
.autoGroupForm {
padding: 10px;
background: #f7f8fa;
}
.headerBar {
display: flex;
align-items: center;
height: 48px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
font-size: 18px;
font-weight: 600;
padding: 0 16px;
}
.title {
font-size: 18px;
font-weight: 600;
color: #222;
flex: 1;
text-align: center;
}
.timeRangeRow {
display: flex;
align-items: center;
gap: 8px;
}
.groupSizeRow {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,253 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Form,
Input,
Button,
Toast,
Switch,
Selector,
TextArea,
NavBar,
} from "antd-mobile";
import { ArrowLeftOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import { createAutoGroup, updateAutoGroup } from "./api";
const defaultForm = {
name: "",
deviceCount: 1,
targetFriends: 0,
createInterval: 300,
maxGroupsPerDay: 10,
timeRange: { start: "09:00", end: "21:00" },
groupSize: { min: 20, max: 50 },
targetTags: [],
groupNameTemplate: "VIP客户交流群{序号}",
groupDescription: "",
};
const tagOptions = [
{ label: "VIP客户", value: "VIP客户" },
{ label: "高价值", value: "高价值" },
{ label: "潜在客户", value: "潜在客户" },
{ label: "中意向", value: "中意向" },
];
const AutoGroupForm: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams();
const isEdit = Boolean(id);
const [form, setForm] = useState<any>(defaultForm);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isEdit) {
// 这里应请求详情接口回填表单演示用mock
setForm({
...defaultForm,
name: "VIP客户建群",
deviceCount: 2,
targetFriends: 156,
createInterval: 300,
maxGroupsPerDay: 20,
timeRange: { start: "09:00", end: "21:00" },
groupSize: { min: 20, max: 50 },
targetTags: ["VIP客户", "高价值"],
groupNameTemplate: "VIP客户交流群{序号}",
groupDescription: "VIP客户专属交流群提供优质服务",
});
}
}, [isEdit, id]);
const handleSubmit = async () => {
setLoading(true);
try {
if (isEdit) {
await updateAutoGroup(id as string, form);
Toast.show({ content: "编辑成功" });
} else {
await createAutoGroup(form);
Toast.show({ content: "创建成功" });
}
navigate("/workspace/auto-group");
} catch (e) {
Toast.show({ content: "提交失败" });
} finally {
setLoading(false);
}
};
return (
<Layout
header={
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
>
<span className="nav-title">
{isEdit ? "编辑建群任务" : "新建建群任务"}
</span>
</NavBar>
}
>
<div className={style.autoGroupForm}>
<Form
layout="vertical"
footer={
<Button
block
color="primary"
loading={loading}
onClick={handleSubmit}
>
{isEdit ? "保存修改" : "创建任务"}
</Button>
}
>
<Form.Item label="任务名称" name="name" required>
<Input
value={form.name}
onChange={(val) => setForm((f: any) => ({ ...f, name: val }))}
placeholder="请输入任务名称"
/>
</Form.Item>
<Form.Item label="执行设备数量" name="deviceCount" required>
<Input
type="number"
value={form.deviceCount}
onChange={(val) =>
setForm((f: any) => ({ ...f, deviceCount: Number(val) }))
}
placeholder="请输入设备数量"
/>
</Form.Item>
<Form.Item label="目标好友数" name="targetFriends" required>
<Input
type="number"
value={form.targetFriends}
onChange={(val) =>
setForm((f: any) => ({ ...f, targetFriends: Number(val) }))
}
placeholder="请输入目标好友数"
/>
</Form.Item>
<Form.Item label="建群间隔(秒)" name="createInterval" required>
<Input
type="number"
value={form.createInterval}
onChange={(val) =>
setForm((f: any) => ({ ...f, createInterval: Number(val) }))
}
placeholder="请输入建群间隔"
/>
</Form.Item>
<Form.Item label="每日最大建群数" name="maxGroupsPerDay" required>
<Input
type="number"
value={form.maxGroupsPerDay}
onChange={(val) =>
setForm((f: any) => ({ ...f, maxGroupsPerDay: Number(val) }))
}
placeholder="请输入最大建群数"
/>
</Form.Item>
<Form.Item label="执行时间段" name="timeRange" required>
<div className={style.timeRangeRow}>
<Input
value={form.timeRange.start}
onChange={(val) =>
setForm((f: any) => ({
...f,
timeRange: { ...f.timeRange, start: val },
}))
}
placeholder="开始时间"
/>
<span style={{ margin: "0 8px" }}>-</span>
<Input
value={form.timeRange.end}
onChange={(val) =>
setForm((f: any) => ({
...f,
timeRange: { ...f.timeRange, end: val },
}))
}
placeholder="结束时间"
/>
</div>
</Form.Item>
<Form.Item label="群组规模" name="groupSize" required>
<div className={style.groupSizeRow}>
<Input
type="number"
value={form.groupSize.min}
onChange={(val) =>
setForm((f: any) => ({
...f,
groupSize: { ...f.groupSize, min: Number(val) },
}))
}
placeholder="最小人数"
/>
<span style={{ margin: "0 8px" }}>-</span>
<Input
type="number"
value={form.groupSize.max}
onChange={(val) =>
setForm((f: any) => ({
...f,
groupSize: { ...f.groupSize, max: Number(val) },
}))
}
placeholder="最大人数"
/>
</div>
</Form.Item>
<Form.Item label="目标标签" name="targetTags">
<Selector
options={tagOptions}
multiple
value={form.targetTags}
onChange={(val) =>
setForm((f: any) => ({ ...f, targetTags: val }))
}
/>
</Form.Item>
<Form.Item label="群名称模板" name="groupNameTemplate" required>
<Input
value={form.groupNameTemplate}
onChange={(val) =>
setForm((f: any) => ({ ...f, groupNameTemplate: val }))
}
placeholder="请输入群名称模板"
/>
</Form.Item>
<Form.Item label="群描述" name="groupDescription">
<TextArea
value={form.groupDescription}
onChange={(val) =>
setForm((f: any) => ({ ...f, groupDescription: val }))
}
placeholder="请输入群描述"
rows={3}
maxLength={100}
showCount
/>
</Form.Item>
</Form>
</div>
</Layout>
);
};
export default AutoGroupForm;

View File

@@ -0,0 +1,8 @@
import request from "@/api/request";
// 获取自动建群任务列表
export function getAutoGroupList(params?: any) {
return request("/api/auto-group/list", params, "GET");
}
// 其他相关API可按需添加

View File

@@ -0,0 +1,173 @@
.autoGroupList {
padding: 0 12px;
}
.taskList {
}
.taskCard {
margin-bottom: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
border: none;
background: #fff;
padding: 16px;
}
.taskHeader {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
}
.taskTitle {
flex: 1;
font-size: 16px;
font-weight: 500;
color: #222;
}
.statusRunning {
background: #e6f7e6;
color: #389e0d;
border-radius: 8px;
padding: 2px 8px;
font-size: 12px;
margin-left: 8px;
}
.statusPaused {
background: #f5f5f5;
color: #888;
border-radius: 8px;
padding: 2px 8px;
font-size: 12px;
margin-left: 8px;
}
.statusCompleted {
background: #e6f4ff;
color: #1677ff;
border-radius: 8px;
padding: 2px 8px;
font-size: 12px;
margin-left: 8px;
}
.taskInfoGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
margin-bottom: 8px;
font-size: 13px;
}
.infoLabel {
color: #888;
font-size: 12px;
}
.infoValue {
color: #222;
font-weight: 500;
font-size: 14px;
}
.taskFooter {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #888;
border-top: 1px solid #f0f0f0;
padding-top: 8px;
margin-top: 8px;
}
.footerLeft {
display: flex;
align-items: center;
}
.footerRight {
display: flex;
align-items: center;
}
.expandPanel {
margin-top: 16px;
padding-top: 12px;
border-top: 1px dashed #e0e0e0;
}
.expandGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.expandTitle {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
display: flex;
align-items: center;
color: #1677ff;
}
.expandInfo {
font-size: 13px;
color: #444;
margin-bottom: 2px;
}
.expandTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
background: #f0f5ff;
color: #1677ff;
border-radius: 8px;
padding: 2px 8px;
font-size: 12px;
}
.menuItem {
padding: 8px 12px;
font-size: 14px;
color: #222;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s;
}
.menuItem:hover {
background: #f5f5f5;
}
.menuItemDanger {
padding: 8px 12px;
font-size: 14px;
color: #e53e3e;
cursor: pointer;
border-radius: 6px;
transition: background 0.2s;
}
.menuItemDanger:hover {
background: #fff1f0;
}
.emptyCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
margin-top: 32px;
}
.emptyTitle {
font-size: 16px;
color: #888;
margin: 12px 0 4px 0;
}
.emptyDesc {
font-size: 13px;
color: #bbb;
margin-bottom: 16px;
}

View File

@@ -0,0 +1,397 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
Card,
Switch,
ProgressBar,
Popover,
Toast,
NavBar,
} from "antd-mobile";
import { Input } from "antd";
import {
MoreOutline,
AddCircleOutline,
UserAddOutline,
ClockCircleOutline,
TeamOutline,
CalendarOutline,
} from "antd-mobile-icons";
import {
ReloadOutlined,
SettingOutlined,
PlusOutlined,
ArrowLeftOutlined,
SearchOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
interface GroupTask {
id: string;
name: string;
status: "running" | "paused" | "completed";
deviceCount: number;
targetFriends: number;
createdGroups: number;
lastCreateTime: string;
createTime: string;
creator: string;
createInterval: number;
maxGroupsPerDay: number;
timeRange: { start: string; end: string };
groupSize: { min: number; max: number };
targetTags: string[];
groupNameTemplate: string;
groupDescription: string;
}
const mockTasks: GroupTask[] = [
{
id: "1",
name: "VIP客户建群",
deviceCount: 2,
targetFriends: 156,
createdGroups: 12,
lastCreateTime: "2025-02-06 13:12:35",
createTime: "2024-11-20 19:04:14",
creator: "admin",
status: "running",
createInterval: 300,
maxGroupsPerDay: 20,
timeRange: { start: "09:00", end: "21:00" },
groupSize: { min: 20, max: 50 },
targetTags: ["VIP客户", "高价值"],
groupNameTemplate: "VIP客户交流群{序号}",
groupDescription: "VIP客户专属交流群提供优质服务",
},
{
id: "2",
name: "产品推广建群",
deviceCount: 1,
targetFriends: 89,
createdGroups: 8,
lastCreateTime: "2024-03-04 14:09:35",
createTime: "2024-03-04 14:29:04",
creator: "manager",
status: "paused",
createInterval: 600,
maxGroupsPerDay: 10,
timeRange: { start: "10:00", end: "20:00" },
groupSize: { min: 15, max: 30 },
targetTags: ["潜在客户", "中意向"],
groupNameTemplate: "产品推广群{序号}",
groupDescription: "产品推广交流群,了解最新产品信息",
},
];
const getStatusColor = (status: string) => {
switch (status) {
case "running":
return style.statusRunning;
case "paused":
return style.statusPaused;
case "completed":
return style.statusCompleted;
default:
return style.statusPaused;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "running":
return "进行中";
case "paused":
return "已暂停";
case "completed":
return "已完成";
default:
return "未知";
}
};
const AutoGroupList: React.FC = () => {
const navigate = useNavigate();
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [tasks, setTasks] = useState<GroupTask[]>(mockTasks);
const toggleExpand = (taskId: string) => {
setExpandedTaskId(expandedTaskId === taskId ? null : taskId);
};
const handleDelete = (taskId: string) => {
const taskToDelete = tasks.find((task) => task.id === taskId);
if (!taskToDelete) return;
window.confirm(`确定要删除"${taskToDelete.name}"吗?`) &&
setTasks(tasks.filter((task) => task.id !== taskId));
Toast.show({ content: "删除成功" });
};
const handleEdit = (taskId: string) => {
navigate(`/workspace/auto-group/${taskId}/edit`);
};
const handleView = (taskId: string) => {
navigate(`/workspace/auto-group/${taskId}`);
};
const handleCopy = (taskId: string) => {
const taskToCopy = tasks.find((task) => task.id === taskId);
if (taskToCopy) {
const newTask = {
...taskToCopy,
id: `${Date.now()}`,
name: `${taskToCopy.name} (复制)`,
createTime: new Date().toISOString().replace("T", " ").substring(0, 19),
};
setTasks([...tasks, newTask]);
Toast.show({ content: "复制成功" });
}
};
const toggleTaskStatus = (taskId: string) => {
setTasks((prev) =>
prev.map((task) =>
task.id === taskId
? {
...task,
status: task.status === "running" ? "paused" : "running",
}
: task
)
);
Toast.show({ content: "状态已切换" });
};
const handleCreateNew = () => {
navigate("/workspace/auto-group/new");
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<Layout
header={
<>
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
right={
<Button size="small" color="primary" onClick={handleCreateNew}>
<PlusOutlined />
</Button>
}
>
<span className="nav-title"></span>
</NavBar>
{/* 搜索栏 */}
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索计划名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
<Button
size="small"
onClick={() => {}}
loading={false}
className="refresh-btn"
>
<ReloadOutlined />
</Button>
</div>
</>
}
>
<div className={style.autoGroupList}>
<div className={style.taskList}>
{filteredTasks.length === 0 ? (
<Card className={style.emptyCard}>
<UserAddOutline style={{ fontSize: 48, color: "#ccc" }} />
<div className={style.emptyTitle}></div>
<div className={style.emptyDesc}></div>
<Button color="primary" onClick={handleCreateNew}>
<AddCircleOutline />
</Button>
</Card>
) : (
filteredTasks.map((task) => (
<Card key={task.id} className={style.taskCard}>
<div className={style.taskHeader}>
<div className={style.taskTitle}>{task.name}</div>
<span className={getStatusColor(task.status)}>
{getStatusText(task.status)}
</span>
<Switch
checked={task.status === "running"}
onChange={() => toggleTaskStatus(task.id)}
disabled={task.status === "completed"}
style={{ marginLeft: 8 }}
/>
<Popover
content={
<div>
<div
className={style.menuItem}
onClick={() => handleView(task.id)}
>
</div>
<div
className={style.menuItem}
onClick={() => handleEdit(task.id)}
>
</div>
<div
className={style.menuItem}
onClick={() => handleCopy(task.id)}
>
</div>
<div
className={style.menuItemDanger}
onClick={() => handleDelete(task.id)}
>
</div>
</div>
}
trigger="click"
>
<MoreOutline style={{ fontSize: 20, marginLeft: 8 }} />
</Popover>
</div>
<div className={style.taskInfoGrid}>
<div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>{task.deviceCount} </div>
</div>
<div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>
{task.targetFriends}
</div>
</div>
<div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>
{task.createdGroups}
</div>
</div>
<div>
<div className={style.infoLabel}></div>
<div className={style.infoValue}>{task.creator}</div>
</div>
</div>
<div className={style.taskFooter}>
<div className={style.footerLeft}>
<ClockCircleOutline style={{ marginRight: 4 }} />
{task.lastCreateTime}
</div>
<div className={style.footerRight}>
{task.createTime}
<Button
size="mini"
fill="none"
onClick={() => toggleExpand(task.id)}
style={{ marginLeft: 8 }}
>
{expandedTaskId === task.id ? "收起" : "展开"}
</Button>
</div>
</div>
{expandedTaskId === task.id && (
<div className={style.expandPanel}>
<div className={style.expandGrid}>
<div>
<div className={style.expandTitle}>
<SettingOutlined style={{ marginRight: 4 }} />{" "}
</div>
<div className={style.expandInfo}>
{task.createInterval}
</div>
<div className={style.expandInfo}>
{task.maxGroupsPerDay}
</div>
<div className={style.expandInfo}>
{task.timeRange.start} -{" "}
{task.timeRange.end}
</div>
<div className={style.expandInfo}>
{task.groupSize.min}-{task.groupSize.max}
</div>
</div>
<div>
<div className={style.expandTitle}>
<TeamOutline style={{ marginRight: 4 }} />
</div>
<div className={style.expandTags}>
{task.targetTags.map((tag) => (
<span key={tag} className={style.tag}>
{tag}
</span>
))}
</div>
</div>
<div>
<div className={style.expandTitle}>
<UserAddOutline style={{ marginRight: 4 }} />
</div>
<div className={style.expandInfo}>
{task.groupNameTemplate}
</div>
<div className={style.expandInfo}>
{task.groupDescription}
</div>
</div>
<div>
<div className={style.expandTitle}>
<CalendarOutline style={{ marginRight: 4 }} />{" "}
</div>
<div className={style.expandInfo}>
{task.createdGroups} /{" "}
{task.maxGroupsPerDay}
</div>
<ProgressBar
percent={Math.round(
(task.createdGroups / task.maxGroupsPerDay) * 100
)}
style={{ marginTop: 8 }}
/>
</div>
</div>
</div>
)}
</Card>
))
)}
</div>
</div>
</Layout>
);
};
export default AutoGroupList;

View File

@@ -1,38 +0,0 @@
import React from "react";
import { NavBar, Button } from "antd-mobile";
import { PlusOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
const AutoLike: React.FC = () => {
return (
<Layout
header={
<NavBar
backArrow
style={{ background: "#fff" }}
onBack={() => window.history.back()}
left={
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
</div>
}
right={
<Button size="small" color="primary">
<PlusOutlined />
<span style={{ marginLeft: 4, fontSize: 12 }}></span>
</Button>
}
/>
}
footer={<MeauMobile />}
>
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
<h3></h3>
<p>...</p>
</div>
</Layout>
);
};
export default AutoLike;

View File

@@ -1,8 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const AutoLikeDetail: React.FC = () => {
return <PlaceholderPage title="自动点赞详情" />;
};
export default AutoLikeDetail;

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -1,30 +0,0 @@
import React from "react";
import { NavBar } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
const NewAutoLike: React.FC = () => {
return (
<Layout
header={
<NavBar
backArrow
style={{ background: "#fff" }}
onBack={() => window.history.back()}
>
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
</div>
</NavBar>
}
footer={<MeauMobile />}
>
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
<h3></h3>
<p>...</p>
</div>
</Layout>
);
};
export default NewAutoLike;

View File

@@ -0,0 +1,63 @@
import request from "@/api/request";
import {
LikeTask,
CreateLikeTaskData,
UpdateLikeTaskData,
LikeRecord,
PaginatedResponse,
} from "@/pages/workspace/auto-like/record/api";
// 获取自动点赞任务列表
export function fetchAutoLikeTasks(
params = { type: 1, page: 1, limit: 100 }
): Promise<LikeTask[]> {
return request("/v1/workbench/list", params, "GET");
}
// 获取单个任务详情
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
return request("/v1/workbench/detail", { id }, "GET");
}
// 创建自动点赞任务
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
}
// 更新自动点赞任务
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
}
// 删除自动点赞任务
export function deleteAutoLikeTask(id: string): Promise<any> {
return request("/v1/workbench/delete", { id }, "DELETE");
}
// 切换任务状态
export function toggleAutoLikeTask(id: string, status: string): Promise<any> {
return request("/v1/workbench/update-status", { id, status }, "POST");
}
// 复制自动点赞任务
export function copyAutoLikeTask(id: string): Promise<any> {
return request("/v1/workbench/copy", { id }, "POST");
}
// 获取点赞记录
export function fetchLikeRecords(
workbenchId: string,
page: number = 1,
limit: number = 20,
keyword?: string
): Promise<PaginatedResponse<LikeRecord>> {
const params: any = {
workbenchId,
page: page.toString(),
limit: limit.toString(),
};
if (keyword) {
params.keyword = keyword;
}
return request("/v1/workbench/records", params, "GET");
}

View File

@@ -0,0 +1,281 @@
.task-list {
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.task-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.task-title-section {
display: flex;
align-items: center;
gap: 8px;
}
.task-name {
font-size: 18px;
font-weight: 600;
color: #333;
}
.task-status {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.active {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
&.inactive {
background: #f5f5f5;
color: #666;
border: 1px solid #d9d9d9;
}
}
.task-controls {
display: flex;
align-items: center;
gap: 8px;
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 24px;
&:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
}
input:checked + .slider {
background-color: #1890ff;
}
input:checked + .slider:before {
transform: translateX(20px);
}
}
.menu-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
border-radius: 4px;
color: #666;
&:hover {
background: #f5f5f5;
}
}
.menu-dropdown {
position: absolute;
right: 0;
top: 28px;
background: white;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 100;
min-width: 120px;
padding: 4px;
border: 1px solid #e5e5e5;
}
.menu-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
border-radius: 4px;
font-size: 14px;
gap: 8px;
transition: background 0.2s;
&:hover {
background: #f5f5f5;
}
&.danger {
color: #ff4d4f;
&:hover {
background: #fff2f0;
}
}
}
.task-info {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.info-section {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.info-label {
font-size: 16px;
color: #666;
}
.info-value {
font-size: 16px;
color: #333;
font-weight: 600;
}
.task-stats {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #666;
border-top: 1px solid #f0f0f0;
padding-top: 10px;
}
.stats-item {
display: flex;
align-items: center;
gap: 8px;
}
.stats-icon {
font-size: 16px;
&.blue {
color: #1890ff;
}
&.green {
color: #52c41a;
}
}
.stats-label {
font-weight: 500;
}
.stats-value {
color: #333;
font-weight: 600;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 16px;
}
.loading-text {
color: #666;
font-size: 14px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-icon {
font-size: 48px;
color: #d9d9d9;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
margin-bottom: 8px;
}
.empty-subtext {
font-size: 14px;
color: #999;
}
// 移动端适配
@media (max-width: 768px) {
.task-info {
grid-template-columns: 1fr;
gap: 16px;
}
.task-stats {
gap: 12px;
align-items: flex-start;
}
.header-content {
padding: 12px 16px;
}
.search-section {
padding: 12px 16px;
}
.task-list {
padding: 0 12px;
}
}

View File

@@ -0,0 +1,403 @@
import React, { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { NavBar, Button, Toast, SpinLoading, Dialog, Card } from "antd-mobile";
import { Input } from "antd";
import {
PlusOutlined,
CopyOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined,
EyeOutlined,
EditOutlined,
MoreOutlined,
LikeOutlined,
} from "@ant-design/icons";
import { ArrowLeftOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import {
fetchAutoLikeTasks,
deleteAutoLikeTask,
toggleAutoLikeTask,
copyAutoLikeTask,
} from "./api";
import { LikeTask } from "@/pages/workspace/auto-like/record/api";
import style from "./index.module.scss";
// 卡片菜单组件
interface CardMenuProps {
onView: () => void;
onEdit: () => void;
onCopy: () => void;
onDelete: () => void;
}
const CardMenu: React.FC<CardMenuProps> = ({
onView,
onEdit,
onCopy,
onDelete,
}) => {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div style={{ position: "relative" }}>
<button onClick={() => setOpen((v) => !v)} className={style["menu-btn"]}>
<MoreOutlined />
</button>
{open && (
<div ref={menuRef} className={style["menu-dropdown"]}>
<div
onClick={() => {
onView();
setOpen(false);
}}
className={style["menu-item"]}
>
<EyeOutlined />
</div>
<div
onClick={() => {
onEdit();
setOpen(false);
}}
className={style["menu-item"]}
>
<EditOutlined />
</div>
<div
onClick={() => {
onCopy();
setOpen(false);
}}
className={style["menu-item"]}
>
<CopyOutlined />
</div>
<div
onClick={() => {
onDelete();
setOpen(false);
}}
className={`${style["menu-item"]} ${style["danger"]}`}
>
<DeleteOutlined />
</div>
</div>
)}
</div>
);
};
const AutoLike: React.FC = () => {
const navigate = useNavigate();
const [tasks, setTasks] = useState<LikeTask[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(false);
// 获取任务列表
const fetchTasks = async () => {
setLoading(true);
try {
const Res: any = await fetchAutoLikeTasks();
// 直接就是任务数组,无需再解包
const mappedTasks = Res?.list?.map((task: any) => ({
...task,
status: task.status || 2, // 默认为关闭状态
deviceCount: task.deviceCount || 0,
targetGroup: task.targetGroup || "全部好友",
likeInterval: task.likeInterval || 60,
maxLikesPerDay: task.maxLikesPerDay || 100,
lastLikeTime: task.lastLikeTime || "暂无",
createTime: task.createTime || "",
updateTime: task.updateTime || "",
todayLikeCount: task.todayLikeCount || 0,
totalLikeCount: task.totalLikeCount || 0,
}));
setTasks(mappedTasks);
} catch (error) {
console.error("获取自动点赞任务失败:", error);
Toast.show({
content: "获取任务列表失败",
position: "top",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
// 删除任务
const handleDelete = async (id: string) => {
const result = await Dialog.confirm({
content: "确定要删除这个任务吗?",
confirmText: "删除",
cancelText: "取消",
});
if (result) {
try {
await deleteAutoLikeTask(id);
Toast.show({
content: "删除成功",
position: "top",
});
fetchTasks(); // 重新获取列表
} catch (error) {
Toast.show({
content: "删除失败",
position: "top",
});
}
}
};
// 编辑任务
const handleEdit = (taskId: string) => {
navigate(`/workspace/auto-like/edit/${taskId}`);
};
// 查看任务
const handleView = (taskId: string) => {
navigate(`/workspace/auto-like/detail/${taskId}`);
};
// 复制任务
const handleCopy = async (id: string) => {
try {
await copyAutoLikeTask(id);
Toast.show({
content: "复制成功",
position: "top",
});
fetchTasks(); // 重新获取列表
} catch (error) {
Toast.show({
content: "复制失败",
position: "top",
});
}
};
// 切换任务状态
const toggleTaskStatus = async (id: string, status: number) => {
try {
const newStatus = status === 1 ? "2" : "1";
await toggleAutoLikeTask(id, newStatus);
Toast.show({
content: status === 1 ? "已暂停" : "已启动",
position: "top",
});
fetchTasks(); // 重新获取列表
} catch (error) {
Toast.show({
content: "操作失败",
position: "top",
});
}
};
// 创建新任务
const handleCreateNew = () => {
navigate("/workspace/auto-like/new");
};
// 过滤任务
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<Layout
header={
<>
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
right={
<Button size="small" color="primary" onClick={handleCreateNew}>
<PlusOutlined />
</Button>
}
>
<span className="nav-title"></span>
</NavBar>
{/* 搜索栏 */}
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索计划名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
<Button
size="small"
onClick={fetchTasks}
loading={loading}
className="refresh-btn"
>
<ReloadOutlined />
</Button>
</div>
</>
}
>
<div className={style["auto-like-page"]}>
{/* 任务列表 */}
<div className={style["task-list"]}>
{loading ? (
<div className={style["loading"]}>
<SpinLoading color="primary" />
<div className={style["loading-text"]}>...</div>
</div>
) : filteredTasks.length === 0 ? (
<div className={style["empty-state"]}>
<div className={style["empty-icon"]}>
<LikeOutlined />
</div>
<div className={style["empty-text"]}></div>
<div className={style["empty-subtext"]}>
</div>
</div>
) : (
filteredTasks.map((task) => (
<Card key={task.id} className={style["task-card"]}>
<div className={style["task-header"]}>
<div className={style["task-title-section"]}>
<h3 className={style["task-name"]}>{task.name}</h3>
<span
className={`${style["task-status"]} ${
Number(task.status) === 1
? style["active"]
: style["inactive"]
}`}
>
{Number(task.status) === 1 ? "进行中" : "已暂停"}
</span>
</div>
<div className={style["task-controls"]}>
<label className={style["switch"]}>
<input
type="checkbox"
checked={Number(task.status) === 1}
onChange={() =>
toggleTaskStatus(task.id, Number(task.status))
}
/>
<span className={style["slider"]}></span>
</label>
<CardMenu
onView={() => handleView(task.id)}
onEdit={() => handleEdit(task.id)}
onCopy={() => handleCopy(task.id)}
onDelete={() => handleDelete(task.id)}
/>
</div>
</div>
<div className={style["task-info"]}>
<div className={style["info-section"]}>
<div className={style["info-item"]}>
<span className={style["info-label"]}></span>
<span className={style["info-value"]}>
{task.deviceCount}
</span>
</div>
<div className={style["info-item"]}>
<span className={style["info-label"]}></span>
<span className={style["info-value"]}>
{task.targetGroup}
</span>
</div>
<div className={style["info-item"]}>
<span className={style["info-label"]}></span>
<span className={style["info-value"]}>
{task.updateTime}
</span>
</div>
</div>
<div className={style["info-section"]}>
<div className={style["info-item"]}>
<span className={style["info-label"]}></span>
<span className={style["info-value"]}>
{task.likeInterval}
</span>
</div>
<div className={style["info-item"]}>
<span className={style["info-label"]}></span>
<span className={style["info-value"]}>
{task.maxLikesPerDay}
</span>
</div>
<div className={style["info-item"]}>
<span className={style["info-label"]}></span>
<span className={style["info-value"]}>
{task.createTime}
</span>
</div>
</div>
</div>
<div className={style["task-stats"]}>
<div className={style["stats-item"]}>
<LikeOutlined
className={`${style["stats-icon"]} ${style["blue"]}`}
/>
<span className={style["stats-label"]}></span>
<span className={style["stats-value"]}>
{task.lastLikeTime}
</span>
</div>
<div className={style["stats-item"]}>
<LikeOutlined
className={`${style["stats-icon"]} ${style["green"]}`}
/>
<span className={style["stats-label"]}></span>
<span className={style["stats-value"]}>
{task.totalLikeCount || 0}
</span>
</div>
</div>
</Card>
))
)}
</div>
</div>
</Layout>
);
};
export default AutoLike;

View File

@@ -0,0 +1,21 @@
import request from "@/api/request";
import {
CreateLikeTaskData,
UpdateLikeTaskData,
LikeTask,
} from "@/pages/workspace/auto-like/record/api";
// 获取自动点赞任务详情
export function fetchAutoLikeTaskDetail(id: string): Promise<LikeTask | null> {
return request("/v1/workbench/detail", { id }, "GET");
}
// 创建自动点赞任务
export function createAutoLikeTask(data: CreateLikeTaskData): Promise<any> {
return request("/v1/workbench/create", { ...data, type: 1 }, "POST");
}
// 更新自动点赞任务
export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise<any> {
return request("/v1/workbench/update", { ...data, type: 1 }, "POST");
}

View File

@@ -0,0 +1,344 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
PlusOutlined,
MinusOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons";
import { Button, Input, Switch, message, Spin } from "antd";
import { NavBar } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import {
createAutoLikeTask,
updateAutoLikeTask,
fetchAutoLikeTaskDetail,
} from "./api";
import {
CreateLikeTaskData,
UpdateLikeTaskData,
ContentType,
} from "@/pages/workspace/auto-like/record/api";
import style from "./new.module.scss";
const contentTypeLabels: Record<ContentType, string> = {
text: "文字",
image: "图片",
video: "视频",
link: "链接",
};
const NewAutoLike: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEditMode = !!id;
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(isEditMode);
const [autoEnabled, setAutoEnabled] = useState(false);
const [formData, setFormData] = useState<CreateLikeTaskData>({
name: "",
interval: 5,
maxLikes: 200,
startTime: "08:00",
endTime: "22:00",
contentTypes: ["text", "image", "video"],
devices: [],
friends: [],
targetTags: [],
friendMaxLikes: 10,
enableFriendTags: false,
friendTags: "",
});
useEffect(() => {
if (isEditMode && id) {
fetchTaskDetail();
}
}, [id, isEditMode]);
const fetchTaskDetail = async () => {
setIsLoading(true);
try {
const taskDetail = await fetchAutoLikeTaskDetail(id!);
if (taskDetail) {
const config = (taskDetail as any).config || taskDetail;
setFormData({
name: taskDetail.name || "",
interval: config.likeInterval || config.interval || 5,
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
startTime: config.timeRange?.start || config.startTime || "08:00",
endTime: config.timeRange?.end || config.endTime || "22:00",
contentTypes: config.contentTypes || ["text", "image", "video"],
devices: config.devices || [],
friends: config.friends || [],
targetTags: config.targetTags || [],
friendMaxLikes: config.friendMaxLikes || 10,
enableFriendTags: config.enableFriendTags || false,
friendTags: config.friendTags || "",
});
setAutoEnabled(
(taskDetail as any).status === 1 ||
(taskDetail as any).status === "running"
);
}
} catch (error) {
message.error("获取任务详情失败");
navigate("/workspace/auto-like");
} finally {
setIsLoading(false);
}
};
const handleUpdateFormData = (data: Partial<CreateLikeTaskData>) => {
setFormData((prev) => ({ ...prev, ...data }));
};
const handleNext = () => setCurrentStep((prev) => Math.min(prev + 1, 3));
const handlePrev = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
const handleComplete = async () => {
if (!formData.name.trim()) {
message.warning("请输入任务名称");
return;
}
if (!formData.devices || formData.devices.length === 0) {
message.warning("请选择执行设备");
return;
}
setIsSubmitting(true);
try {
if (isEditMode && id) {
await updateAutoLikeTask({ ...formData, id });
message.success("更新成功");
} else {
await createAutoLikeTask(formData);
message.success("创建成功");
}
navigate("/workspace/auto-like");
} catch (error) {
message.error(isEditMode ? "更新失败" : "创建失败");
} finally {
setIsSubmitting(false);
}
};
// 步骤1基础设置
const renderBasicSettings = () => (
<div className={style["form-section"]}>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<Input
placeholder="请输入任务名称"
value={formData.name}
onChange={(e) => handleUpdateFormData({ name: e.target.value })}
className={style["form-input"]}
/>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<div className={style["stepper-group"]}>
<Button
icon={<MinusOutlined />}
onClick={() =>
handleUpdateFormData({
interval: Math.max(1, formData.interval - 1),
})
}
className={style["stepper-btn"]}
/>
<span className={style["stepper-value"]}>{formData.interval} </span>
<Button
icon={<PlusOutlined />}
onClick={() =>
handleUpdateFormData({ interval: formData.interval + 1 })
}
className={style["stepper-btn"]}
/>
</div>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<div className={style["stepper-group"]}>
<Button
icon={<MinusOutlined />}
onClick={() =>
handleUpdateFormData({
maxLikes: Math.max(1, formData.maxLikes - 10),
})
}
className={style["stepper-btn"]}
/>
<span className={style["stepper-value"]}>{formData.maxLikes} </span>
<Button
icon={<PlusOutlined />}
onClick={() =>
handleUpdateFormData({ maxLikes: formData.maxLikes + 10 })
}
className={style["stepper-btn"]}
/>
</div>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<div className={style["time-range"]}>
<Input
type="time"
value={formData.startTime}
onChange={(e) =>
handleUpdateFormData({ startTime: e.target.value })
}
className={style["time-input"]}
/>
<span className={style["time-separator"]}></span>
<Input
type="time"
value={formData.endTime}
onChange={(e) => handleUpdateFormData({ endTime: e.target.value })}
className={style["time-input"]}
/>
</div>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<div className={style["content-types"]}>
{(["text", "image", "video", "link"] as ContentType[]).map((type) => (
<span
key={type}
className={
formData.contentTypes.includes(type)
? style["content-type-tag-active"]
: style["content-type-tag"]
}
onClick={() => {
const newTypes = formData.contentTypes.includes(type)
? formData.contentTypes.filter((t) => t !== type)
: [...formData.contentTypes, type];
handleUpdateFormData({ contentTypes: newTypes });
}}
>
{contentTypeLabels[type]}
</span>
))}
</div>
</div>
<div className={style["form-item"]}>
<label className={style["form-label"]}></label>
<Switch checked={autoEnabled} onChange={setAutoEnabled} />
</div>
<div className={style["form-actions"]}>
<Button
type="primary"
block
onClick={handleNext}
size="large"
className={style["main-btn"]}
>
</Button>
</div>
</div>
);
// 步骤2设备选择占位
const renderDeviceSelection = () => (
<div className={style["form-section"]}>
<div className={style["placeholder-content"]}>
<span className={style["placeholder-icon"]}>[]</span>
<div className={style["placeholder-text"]}>...</div>
<div className={style["placeholder-subtext"]}>
{formData.devices?.length || 0}
</div>
</div>
<div className={style["form-actions"]}>
<Button
onClick={handlePrev}
size="large"
className={style["secondary-btn"]}
>
</Button>
<Button
type="primary"
onClick={handleNext}
size="large"
className={style["main-btn"]}
>
</Button>
</div>
</div>
);
// 步骤3好友设置占位
const renderFriendSettings = () => (
<div className={style["form-section"]}>
<div className={style["placeholder-content"]}>
<span className={style["placeholder-icon"]}>[]</span>
<div className={style["placeholder-text"]}>...</div>
<div className={style["placeholder-subtext"]}>
{formData.friends?.length || 0}
</div>
</div>
<div className={style["form-actions"]}>
<Button
onClick={handlePrev}
size="large"
className={style["secondary-btn"]}
>
</Button>
<Button
type="primary"
onClick={handleComplete}
size="large"
loading={isSubmitting}
className={style["main-btn"]}
>
{isEditMode ? "更新任务" : "创建任务"}
</Button>
</div>
</div>
);
return (
<Layout
header={
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
>
<span className="nav-title">
{isEditMode ? "编辑自动点赞" : "新建自动点赞"}
</span>
</NavBar>
}
>
<div className={style["new-page-bg"]}>
<div className={style["new-page-center"]}>
{/* 步骤器保留新项目的 */}
{/* 你可以在这里插入新项目的步骤器组件 */}
<div className={style["form-card"]}>
{currentStep === 1 && renderBasicSettings()}
{currentStep === 2 && renderDeviceSelection()}
{currentStep === 3 && renderFriendSettings()}
{isLoading && (
<div className={style["loading"]}>
<Spin />
</div>
)}
</div>
</div>
</div>
</Layout>
);
};
export default NewAutoLike;

View File

@@ -0,0 +1,250 @@
.new-page-bg {
min-height: 100vh;
background: #f8f9fa;
}
.nav-bar {
display: flex;
align-items: center;
height: 56px;
background: #fff;
box-shadow: 0 1px 0 #f0f0f0;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 10;
}
.nav-back-btn {
border: none;
background: none;
font-size: 20px;
color: #222;
margin-right: 8px;
box-shadow: none;
padding: 0;
min-width: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-title {
font-size: 18px;
font-weight: 600;
color: #222;
margin-left: 4px;
}
.new-page-center {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 32px;
}
.form-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 2px 16px rgba(0,0,0,0.06);
padding: 36px 32px 32px 32px;
min-width: 340px;
max-width: 420px;
width: 100%;
}
.form-section {
width: 100%;
}
.form-item {
margin-bottom: 28px;
display: flex;
flex-direction: column;
}
.form-label {
font-size: 15px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.form-input {
border-radius: 10px !important;
height: 44px;
font-size: 15px;
padding-left: 14px;
background: #f8f9fa;
border: 1px solid #e5e6eb;
transition: border 0.2s;
}
.form-input:focus {
border-color: #1890ff;
background: #fff;
}
.stepper-group {
display: flex;
align-items: center;
gap: 12px;
}
.stepper-btn {
border-radius: 8px !important;
width: 36px;
height: 36px;
font-size: 18px;
background: #f5f6fa;
border: 1px solid #e5e6eb;
color: #222;
display: flex;
align-items: center;
justify-content: center;
transition: border 0.2s;
}
.stepper-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.stepper-value {
font-size: 15px;
font-weight: 600;
color: #333;
min-width: 60px;
text-align: center;
}
.time-range {
display: flex;
align-items: center;
gap: 10px;
}
.time-input {
border-radius: 10px !important;
height: 44px;
font-size: 15px;
padding-left: 14px;
background: #f8f9fa;
border: 1px solid #e5e6eb;
width: 120px;
}
.time-separator {
font-size: 15px;
color: #888;
font-weight: 500;
}
.content-types {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.content-type-tag {
border-radius: 8px;
background: #f5f6fa;
color: #666;
font-size: 14px;
padding: 6px 18px;
cursor: pointer;
border: 1px solid #e5e6eb;
transition: all 0.2s;
}
.content-type-tag-active {
border-radius: 8px;
background: #e6f4ff;
color: #1890ff;
font-size: 14px;
padding: 6px 18px;
cursor: pointer;
border: 1px solid #1890ff;
font-weight: 600;
transition: all 0.2s;
}
.form-actions {
display: flex;
gap: 16px;
margin-top: 12px;
}
.main-btn {
border-radius: 10px !important;
height: 44px;
font-size: 16px;
font-weight: 600;
background: #1890ff;
border: none;
box-shadow: 0 2px 8px rgba(24,144,255,0.08);
transition: background 0.2s;
}
.main-btn:hover {
background: #1677ff;
}
.secondary-btn {
border-radius: 10px !important;
height: 44px;
font-size: 16px;
font-weight: 600;
background: #fff;
border: 1.5px solid #e5e6eb;
color: #222;
transition: border 0.2s;
}
.secondary-btn:hover {
border-color: #1890ff;
color: #1890ff;
}
.placeholder-content {
text-align: center;
color: #888;
padding: 40px 0 24px 0;
}
.placeholder-icon {
font-size: 32px;
color: #d9d9d9;
margin-bottom: 12px;
display: block;
}
.placeholder-text {
font-size: 16px;
color: #333;
margin-bottom: 6px;
}
.placeholder-subtext {
font-size: 14px;
color: #999;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
}
@media (max-width: 600px) {
.form-card {
min-width: 0;
max-width: 100vw;
padding: 18px 6px 18px 6px;
}
.new-page-center {
margin-top: 12px;
}
}

View File

@@ -0,0 +1,119 @@
// 自动点赞任务状态
export type LikeTaskStatus = 1 | 2; // 1: 开启, 2: 关闭
// 内容类型
export type ContentType = "text" | "image" | "video" | "link";
// 设备信息
export interface Device {
id: string;
name: string;
status: "online" | "offline";
lastActive: string;
}
// 好友信息
export interface Friend {
id: string;
nickname: string;
wechatId: string;
avatar: string;
tags: string[];
region: string;
source: string;
}
// 点赞记录
export interface LikeRecord {
id: string;
workbenchId: string;
momentsId: string;
snsId: string;
wechatAccountId: string;
wechatFriendId: string;
likeTime: string;
content: string;
resUrls: string[];
momentTime: string;
userName: string;
operatorName: string;
operatorAvatar: string;
friendName: string;
friendAvatar: string;
}
// 自动点赞任务
export interface LikeTask {
id: string;
name: string;
status: LikeTaskStatus;
deviceCount: number;
targetGroup: string;
likeCount: number;
lastLikeTime: string;
createTime: string;
creator: string;
likeInterval: number;
maxLikesPerDay: number;
timeRange: { start: string; end: string };
contentTypes: ContentType[];
targetTags: string[];
devices: string[];
friends: string[];
friendMaxLikes: number;
friendTags: string;
enableFriendTags: boolean;
todayLikeCount: number;
totalLikeCount: number;
updateTime: string;
}
// 创建任务数据
export interface CreateLikeTaskData {
name: string;
interval: number;
maxLikes: number;
startTime: string;
endTime: string;
contentTypes: ContentType[];
devices: string[];
friends?: string[];
friendMaxLikes: number;
friendTags?: string;
enableFriendTags: boolean;
targetTags: string[];
}
// 更新任务数据
export interface UpdateLikeTaskData extends CreateLikeTaskData {
id: string;
}
// 任务配置
export interface TaskConfig {
interval: number;
maxLikes: number;
startTime: string;
endTime: string;
contentTypes: ContentType[];
devices: string[];
friends: string[];
friendMaxLikes: number;
friendTags: string;
enableFriendTags: boolean;
}
// API响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 分页响应类型
export interface PaginatedResponse<T> {
list: T[];
total: number;
page: number;
limit: number;
}

View File

@@ -0,0 +1,173 @@
import { request } from "../../../../api/request";
import {
LikeTask,
CreateLikeTaskData,
UpdateLikeTaskData,
LikeRecord,
ApiResponse,
PaginatedResponse,
} from "@/pages/workspace/auto-like/record/api";
// 获取自动点赞任务列表
export async function fetchAutoLikeTasks(): Promise<LikeTask[]> {
try {
const res = await request<ApiResponse<PaginatedResponse<LikeTask>>>({
url: "/v1/workbench/list",
method: "GET",
params: {
type: 1,
page: 1,
limit: 100,
},
});
if (res.code === 200 && res.data) {
return res.data.list || [];
}
return [];
} catch (error) {
console.error("获取自动点赞任务失败:", error);
return [];
}
}
// 获取单个任务详情
export async function fetchAutoLikeTaskDetail(
id: string
): Promise<LikeTask | null> {
try {
console.log(`Fetching task detail for id: ${id}`);
const res = await request<any>({
url: "/v1/workbench/detail",
method: "GET",
params: { id },
});
console.log("Task detail API response:", res);
if (res.code === 200) {
if (res.data) {
if (typeof res.data === "object") {
return res.data;
} else {
console.error(
"Task detail API response data is not an object:",
res.data
);
return null;
}
} else {
console.error("Task detail API response missing data field:", res);
return null;
}
}
console.error("Task detail API error:", res.msg || "Unknown error");
return null;
} catch (error) {
console.error("获取任务详情失败:", error);
return null;
}
}
// 创建自动点赞任务
export async function createAutoLikeTask(
data: CreateLikeTaskData
): Promise<ApiResponse> {
return request({
url: "/v1/workbench/create",
method: "POST",
data: {
...data,
type: 1, // 自动点赞类型
},
});
}
// 更新自动点赞任务
export async function updateAutoLikeTask(
data: UpdateLikeTaskData
): Promise<ApiResponse> {
return request({
url: "/v1/workbench/update",
method: "POST",
data: {
...data,
type: 1, // 自动点赞类型
},
});
}
// 删除自动点赞任务
export async function deleteAutoLikeTask(id: string): Promise<ApiResponse> {
return request({
url: "/v1/workbench/delete",
method: "DELETE",
params: { id },
});
}
// 切换任务状态
export async function toggleAutoLikeTask(
id: string,
status: string
): Promise<ApiResponse> {
return request({
url: "/v1/workbench/update-status",
method: "POST",
data: { id, status },
});
}
// 复制自动点赞任务
export async function copyAutoLikeTask(id: string): Promise<ApiResponse> {
return request({
url: "/v1/workbench/copy",
method: "POST",
data: { id },
});
}
// 获取点赞记录
export async function fetchLikeRecords(
workbenchId: string,
page: number = 1,
limit: number = 20,
keyword?: string
): Promise<PaginatedResponse<LikeRecord>> {
try {
const params: any = {
workbenchId,
page: page.toString(),
limit: limit.toString(),
};
if (keyword) {
params.keyword = keyword;
}
const res = await request<ApiResponse<PaginatedResponse<LikeRecord>>>({
url: "/v1/workbench/records",
method: "GET",
params,
});
if (res.code === 200 && res.data) {
return res.data;
}
return {
list: [],
total: 0,
page: 1,
limit: 20,
};
} catch (error) {
console.error("获取点赞记录失败:", error);
return {
list: [],
total: 0,
page: 1,
limit: 20,
};
}
}

View File

@@ -0,0 +1,297 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { NavBar, Button, Toast, SpinLoading, Card, Avatar } from "antd-mobile";
import { Input } from "antd";
import InfiniteList from "@/components/InfiniteList/InfiniteList";
import {
SearchOutlined,
ReloadOutlined,
LikeOutlined,
UserOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import { fetchLikeRecords, fetchAutoLikeTaskDetail } from "./data";
import { LikeRecord, LikeTask } from "./api";
import style from "./record.module.scss";
// 格式化日期
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch (error) {
return dateString;
}
};
const AutoLikeDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [records, setRecords] = useState<LikeRecord[]>([]);
const [taskDetail, setTaskDetail] = useState<LikeTask | null>(null);
const [recordsLoading, setRecordsLoading] = useState(false);
const [taskLoading, setTaskLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(true);
const pageSize = 10;
// 获取任务详情
const fetchTaskDetail = async () => {
if (!id) return;
setTaskLoading(true);
try {
const detail = await fetchAutoLikeTaskDetail(id);
setTaskDetail(detail);
} catch (error) {
Toast.show({
content: "获取任务详情失败",
position: "top",
});
} finally {
setTaskLoading(false);
}
};
// 获取点赞记录
const fetchRecords = async (
page: number = 1,
isLoadMore: boolean = false
) => {
if (!id) return;
if (!isLoadMore) {
setRecordsLoading(true);
}
try {
const response = await fetchLikeRecords(id, page, pageSize, searchTerm);
const newRecords = response.list || [];
if (isLoadMore) {
setRecords((prev) => [...prev, ...newRecords]);
} else {
setRecords(newRecords);
}
setTotal(response.total || 0);
setCurrentPage(page);
setHasMore(newRecords.length === pageSize);
} catch (error) {
Toast.show({
content: "获取点赞记录失败",
position: "top",
});
} finally {
setRecordsLoading(false);
}
};
useEffect(() => {
fetchTaskDetail();
fetchRecords(1, false);
}, [id]);
const handleSearch = () => {
setCurrentPage(1);
fetchRecords(1, false);
};
const handleRefresh = () => {
fetchRecords(currentPage, false);
};
const handleLoadMore = async () => {
if (hasMore && !recordsLoading) {
await fetchRecords(currentPage + 1, true);
}
};
const renderRecordItem = (record: LikeRecord) => (
<Card key={record.id} className={style["record-card"]}>
<div className={style["record-header"]}>
<div className={style["user-info"]}>
<Avatar
src={
record.friendAvatar ||
"https://api.dicebear.com/7.x/avataaars/svg?seed=fallback"
}
className={style["user-avatar"]}
>
<UserOutlined />
</Avatar>
<div className={style["user-details"]}>
<div className={style["user-name"]} title={record.friendName}>
{record.friendName}
</div>
<div className={style["user-role"]}></div>
</div>
</div>
<div className={style["record-time"]}>
{formatDate(record.momentTime || record.likeTime)}
</div>
</div>
<div className={style["record-content"]}>
{record.content && (
<p className={style["content-text"]}>{record.content}</p>
)}
{Array.isArray(record.resUrls) && record.resUrls.length > 0 && (
<div className={style["content-images"]}>
{record.resUrls.slice(0, 9).map((image: string, idx: number) => (
<div key={idx} className={style["image-item"]}>
<img
src={image}
alt={`内容图片 ${idx + 1}`}
className={style["content-image"]}
/>
</div>
))}
</div>
)}
</div>
<div className={style["like-info"]}>
<Avatar
src={
record.operatorAvatar ||
"https://api.dicebear.com/7.x/avataaars/svg?seed=operator"
}
className={style["operator-avatar"]}
>
<UserOutlined />
</Avatar>
<div className={style["like-text"]}>
<span className={style["operator-name"]} title={record.operatorName}>
{record.operatorName}
</span>
<span className={style["like-action"]}></span>
</div>
</div>
</Card>
);
return (
<Layout
header={
<NavBar
backArrow
style={{ background: "#fff" }}
onBack={() => window.history.back()}
left={
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
</div>
}
/>
}
footer={<MeauMobile />}
>
<div className={style["detail-page"]}>
{/* 任务信息卡片 */}
{taskDetail && (
<div className={style["task-info-card"]}>
<div className={style["task-header"]}>
<h3 className={style["task-name"]}>{taskDetail.name}</h3>
<span
className={`${style["task-status"]} ${
Number(taskDetail.status) === 1
? style["active"]
: style["inactive"]
}`}
>
{Number(taskDetail.status) === 1 ? "进行中" : "已暂停"}
</span>
</div>
<div className={style["task-stats"]}>
<div className={style["stat-item"]}>
<LikeOutlined className={style["stat-icon"]} />
<span className={style["stat-label"]}></span>
<span className={style["stat-value"]}>
{taskDetail.todayLikeCount || 0}
</span>
</div>
<div className={style["stat-item"]}>
<LikeOutlined className={style["stat-icon"]} />
<span className={style["stat-label"]}></span>
<span className={style["stat-value"]}>
{taskDetail.totalLikeCount || 0}
</span>
</div>
</div>
</div>
)}
{/* 搜索区域 */}
<div className={style["search-section"]}>
<div className={style["search-wrapper"]}>
<div className={style["search-input-wrapper"]}>
<SearchOutlined className={style["search-icon"]} />
<Input
placeholder="搜索好友昵称或内容"
className={style["search-input"]}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onPressEnter={handleSearch}
/>
</div>
<Button
size="small"
onClick={handleSearch}
className={style["search-btn"]}
>
</Button>
<button
className={style["refresh-btn"]}
onClick={handleRefresh}
disabled={recordsLoading}
>
<ReloadOutlined
style={{
animation: recordsLoading
? "spin 1s linear infinite"
: "none",
}}
/>
</button>
</div>
</div>
{/* 记录列表 */}
<div className={style["records-section"]}>
{recordsLoading && currentPage === 1 ? (
<div className={style["loading"]}>
<SpinLoading color="primary" />
<div className={style["loading-text"]}>...</div>
</div>
) : records.length === 0 ? (
<div className={style["empty-state"]}>
<LikeOutlined className={style["empty-icon"]} />
<div className={style["empty-text"]}></div>
</div>
) : (
<InfiniteList
data={records}
renderItem={renderRecordItem}
hasMore={hasMore}
loadMore={handleLoadMore}
className={style["records-list"]}
/>
)}
</div>
</div>
</Layout>
);
};
export default AutoLikeDetail;

View File

@@ -0,0 +1,351 @@
.detail-page {
background: #f5f5f5;
min-height: 100vh;
padding-bottom: 80px;
}
.task-info-card {
background: white;
margin: 16px;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.task-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
.task-status {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
&.active {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
&.inactive {
background: #f5f5f5;
color: #666;
border: 1px solid #d9d9d9;
}
}
.task-stats {
display: flex;
justify-content: space-between;
gap: 16px;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #666;
}
.stat-icon {
font-size: 16px;
color: #1890ff;
}
.stat-label {
font-weight: 500;
}
.stat-value {
color: #333;
font-weight: 600;
}
.search-section {
padding: 0 16px 16px;
}
.search-wrapper {
display: flex;
align-items: center;
gap: 8px;
background: white;
border-radius: 8px;
padding: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-input-wrapper {
position: relative;
flex: 1;
}
.search-input {
width: 100%;
height: 36px;
padding: 0 12px 0 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
&:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
outline: none;
}
}
.search-icon {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
color: #999;
font-size: 14px;
}
.search-btn {
height: 36px;
padding: 0 12px;
border-radius: 6px;
font-size: 14px;
white-space: nowrap;
}
.refresh-btn {
height: 36px;
width: 36px;
padding: 0;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d9d9d9;
background: white;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.records-section {
padding: 0 16px;
}
.records-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-card {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.record-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
max-width: 65%;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
}
.user-details {
min-width: 0;
}
.user-name {
font-size: 14px;
font-weight: 600;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-role {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.record-time {
font-size: 12px;
color: #666;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.record-content {
margin-bottom: 12px;
}
.content-text {
font-size: 14px;
color: #333;
line-height: 1.5;
margin-bottom: 12px;
white-space: pre-line;
}
.content-images {
display: grid;
gap: 4px;
&.single {
grid-template-columns: 1fr;
}
&.double {
grid-template-columns: 1fr 1fr;
}
&.multiple {
grid-template-columns: repeat(3, 1fr);
}
}
.image-item {
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
}
.content-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.like-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
}
.operator-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}
.like-text {
font-size: 14px;
color: #666;
min-width: 0;
}
.operator-name {
font-weight: 600;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
max-width: 100%;
}
.like-action {
margin-left: 4px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
gap: 16px;
}
.loading-text {
color: #666;
font-size: 14px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-icon {
font-size: 48px;
color: #d9d9d9;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
color: #999;
}
// 移动端适配
@media (max-width: 768px) {
.task-stats {
flex-direction: column;
gap: 12px;
}
.search-wrapper {
flex-direction: column;
gap: 12px;
}
.search-btn {
width: 100%;
}
.user-info {
max-width: 60%;
}
.content-images {
&.multiple {
grid-template-columns: repeat(2, 1fr);
}
}
}

View File

@@ -1,10 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const GroupPush: React.FC = () => {
return (
<PlaceholderPage title="群发推送" showAddButton addButtonText="新建推送" />
);
};
export default GroupPush;

View File

@@ -0,0 +1,73 @@
import request from "../../../../api/request";
export interface GroupPushTask {
id: string;
name: string;
status: number; // 1: 运行中, 2: 已暂停
deviceCount: number;
targetGroups: string[];
pushCount: number;
successCount: number;
lastPushTime: string;
createTime: string;
creator: string;
pushInterval: number;
maxPushPerDay: number;
timeRange: { start: string; end: string };
messageType: "text" | "image" | "video" | "link";
messageContent: string;
targetTags: string[];
pushMode: "immediate" | "scheduled";
scheduledTime?: string;
}
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
export async function fetchGroupPushTasks(): Promise<GroupPushTask[]> {
const response = await request("/v1/workbench/list", { type: 3 }, "GET");
if (Array.isArray(response)) return response;
if (response && Array.isArray(response.data)) return response.data;
return [];
}
export async function deleteGroupPushTask(id: string): Promise<ApiResponse> {
return request(`/v1/workspace/group-push/tasks/${id}`, {}, "DELETE");
}
export async function toggleGroupPushTask(
id: string,
status: string
): Promise<ApiResponse> {
return request(
`/v1/workspace/group-push/tasks/${id}/toggle`,
{ status },
"POST"
);
}
export async function copyGroupPushTask(id: string): Promise<ApiResponse> {
return request(`/v1/workspace/group-push/tasks/${id}/copy`, {}, "POST");
}
export async function createGroupPushTask(
taskData: Partial<GroupPushTask>
): Promise<ApiResponse> {
return request("/v1/workspace/group-push/tasks", taskData, "POST");
}
export async function updateGroupPushTask(
id: string,
taskData: Partial<GroupPushTask>
): Promise<ApiResponse> {
return request(`/v1/workspace/group-push/tasks/${id}`, taskData, "PUT");
}
export async function getGroupPushTaskDetail(
id: string
): Promise<GroupPushTask> {
return request(`/v1/workspace/group-push/tasks/${id}`);
}

View File

@@ -0,0 +1,100 @@
.searchBar {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0 8px 0;
}
.taskList {
display: flex;
flex-direction: column;
gap: 16px;
}
.emptyCard {
text-align: center;
padding: 48px 0;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.taskCard {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 20px 16px 12px 16px;
}
.taskHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.taskTitle {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.taskActions {
display: flex;
align-items: center;
gap: 8px;
}
.taskInfoGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
font-size: 13px;
color: #666;
margin-bottom: 12px;
}
.progressBlock {
margin-bottom: 12px;
}
.progressLabel {
font-size: 13px;
color: #888;
margin-bottom: 4px;
}
.taskFooter {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #888;
border-top: 1px dashed #eee;
padding-top: 8px;
margin-top: 8px;
}
.expandedPanel {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #eee;
}
.expandedGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
@media (max-width: 600px) {
.taskCard {
padding: 12px 6px 8px 6px;
}
.expandedGrid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,261 @@
import React, { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, Badge, Button, Progress, Spin } from "antd";
import {
ArrowLeftOutlined,
SettingOutlined,
TeamOutlined,
MessageOutlined,
CalendarOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import {
getGroupPushTaskDetail,
GroupPushTask,
} from "@/pages/workspace/group-push/detail/groupPush";
import styles from "./index.module.scss";
const Detail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [task, setTask] = useState<GroupPushTask | null>(null);
useEffect(() => {
if (!id) return;
setLoading(true);
getGroupPushTaskDetail(id)
.then((res) => {
setTask(res.data || res); // 兼容两种返回格式
})
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
<ArrowLeftOutlined
onClick={() => navigate(-1)}
style={{ marginRight: 12, cursor: "pointer" }}
/>
</div>
}
footer={<MeauMobile />}
>
<div style={{ padding: 48, textAlign: "center" }}>
<Spin />
</div>
</Layout>
);
}
if (!task) {
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
<ArrowLeftOutlined
onClick={() => navigate(-1)}
style={{ marginRight: 12, cursor: "pointer" }}
/>
</div>
}
footer={<MeauMobile />}
>
<div style={{ padding: 48, textAlign: "center", color: "#888" }}>
</div>
</Layout>
);
}
const getStatusColor = (status: number) => {
switch (status) {
case 1:
return "green";
case 2:
return "gray";
default:
return "gray";
}
};
const getStatusText = (status: number) => {
switch (status) {
case 1:
return "进行中";
case 2:
return "已暂停";
default:
return "未知";
}
};
const getMessageTypeText = (type: string) => {
switch (type) {
case "text":
return "文字";
case "image":
return "图片";
case "video":
return "视频";
case "link":
return "链接";
default:
return "未知";
}
};
const getSuccessRate = (pushCount: number, successCount: number) => {
if (pushCount === 0) return 0;
return Math.round((successCount / pushCount) * 100);
};
return (
<Layout
header={
<div
style={{
background: "#fff",
padding: "0 16px",
fontWeight: 600,
fontSize: 18,
}}
>
<ArrowLeftOutlined
onClick={() => navigate(-1)}
style={{ marginRight: 12, cursor: "pointer" }}
/>
</div>
}
footer={<MeauMobile />}
>
<div className={styles.bg}>
<Card className={styles.taskCard}>
<div className={styles.taskHeader}>
<div className={styles.taskTitle}>
<span>{task.name}</span>
<Badge
color={getStatusColor(task.status)}
text={getStatusText(task.status)}
style={{ marginLeft: 8 }}
/>
</div>
</div>
<div className={styles.taskInfoGrid}>
<div>{task.deviceCount} </div>
<div>{task.targetGroups.length} </div>
<div>
{task.successCount}/{task.pushCount}
</div>
<div>{task.creator}</div>
</div>
<div className={styles.progressBlock}>
<div className={styles.progressLabel}></div>
<Progress
percent={getSuccessRate(task.pushCount, task.successCount)}
size="small"
/>
</div>
<div className={styles.taskFooter}>
<div>
<CalendarOutlined /> {task.lastPushTime}
</div>
<div>{task.createTime}</div>
</div>
<div className={styles.expandedPanel}>
<div className={styles.expandedGrid}>
<div>
<SettingOutlined /> <b></b>
<div>{task.pushInterval} </div>
<div>{task.maxPushPerDay} </div>
<div>
{task.timeRange.start} - {task.timeRange.end}
</div>
<div>
{task.pushMode === "immediate" ? "立即推送" : "定时推送"}
</div>
{task.scheduledTime && (
<div>{task.scheduledTime}</div>
)}
</div>
<div>
<TeamOutlined /> <b></b>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{task.targetGroups.map((group) => (
<Badge
key={group}
color="blue"
text={group}
style={{ background: "#f0f5ff", marginRight: 4 }}
/>
))}
</div>
</div>
<div>
<MessageOutlined /> <b></b>
<div>{getMessageTypeText(task.messageType)}</div>
<div
style={{
background: "#f5f5f5",
padding: 8,
borderRadius: 4,
marginTop: 4,
}}
>
{task.messageContent}
</div>
</div>
<div>
<CalendarOutlined /> <b></b>
<div>
{task.pushCount} / {task.maxPushPerDay}
</div>
<Progress
percent={Math.round(
(task.pushCount / task.maxPushPerDay) * 100
)}
size="small"
/>
{task.targetTags.length > 0 && (
<div style={{ marginTop: 8 }}>
<div></div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{task.targetTags.map((tag) => (
<Badge
key={tag}
color="purple"
text={tag}
style={{ background: "#f9f0ff", marginRight: 4 }}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
</Card>
</div>
</Layout>
);
};
export default Detail;

View File

@@ -0,0 +1,237 @@
import React, { useState } from "react";
import { Input, Button, Card, Switch } from "antd";
import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
interface BasicSettingsProps {
defaultValues?: {
name: string;
pushTimeStart: string;
pushTimeEnd: string;
dailyPushCount: number;
pushOrder: "earliest" | "latest";
isLoopPush: boolean;
isImmediatePush: boolean;
isEnabled: boolean;
};
onNext: (values: any) => void;
onSave: (values: any) => void;
onCancel: () => void;
loading?: boolean;
}
const BasicSettings: React.FC<BasicSettingsProps> = ({
defaultValues = {
name: "",
pushTimeStart: "06:00",
pushTimeEnd: "23:59",
dailyPushCount: 20,
pushOrder: "latest",
isLoopPush: false,
isImmediatePush: false,
isEnabled: false,
},
onNext,
onSave,
onCancel,
loading = false,
}) => {
const [values, setValues] = useState(defaultValues);
const handleChange = (field: string, value: any) => {
setValues((prev) => ({ ...prev, [field]: value }));
};
const handleCountChange = (increment: boolean) => {
setValues((prev) => ({
...prev,
dailyPushCount: increment
? prev.dailyPushCount + 1
: Math.max(1, prev.dailyPushCount - 1),
}));
};
return (
<div style={{ marginBottom: 24 }}>
<Card>
<div style={{ padding: 16 }}>
{/* 任务名称 */}
<div style={{ marginBottom: 16 }}>
<span style={{ color: "red", marginRight: 4 }}>*</span>:
<Input
value={values.name}
onChange={(e) => handleChange("name", e.target.value)}
placeholder="请输入任务名称"
style={{ marginTop: 4 }}
/>
</div>
{/* 允许推送的时间段 */}
<div style={{ marginBottom: 16 }}>
<span>:</span>
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
<Input
type="time"
value={values.pushTimeStart}
onChange={(e) => handleChange("pushTimeStart", e.target.value)}
style={{ width: 120 }}
/>
<span style={{ color: "#888" }}></span>
<Input
type="time"
value={values.pushTimeEnd}
onChange={(e) => handleChange("pushTimeEnd", e.target.value)}
style={{ width: 120 }}
/>
</div>
</div>
{/* 每日推送 */}
<div style={{ marginBottom: 16 }}>
<span>:</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 4,
}}
>
<Button
icon={<MinusOutlined />}
onClick={() => handleCountChange(false)}
disabled={loading}
/>
<Input
type="number"
value={values.dailyPushCount}
onChange={(e) =>
handleChange(
"dailyPushCount",
Number.parseInt(e.target.value) || 1
)
}
style={{ width: 80, textAlign: "center" }}
min={1}
disabled={loading}
/>
<Button
icon={<PlusOutlined />}
onClick={() => handleCountChange(true)}
disabled={loading}
/>
<span style={{ color: "#888" }}></span>
</div>
</div>
{/* 推送顺序 */}
<div style={{ marginBottom: 16 }}>
<span>:</span>
<Button.Group style={{ marginLeft: 8 }}>
<Button
type={values.pushOrder === "earliest" ? "primary" : "default"}
onClick={() => handleChange("pushOrder", "earliest")}
disabled={loading}
>
</Button>
<Button
type={values.pushOrder === "latest" ? "primary" : "default"}
onClick={() => handleChange("pushOrder", "latest")}
disabled={loading}
>
</Button>
</Button.Group>
</div>
{/* 是否循环推送 */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span>
<span style={{ color: "red", marginRight: 4 }}>*</span>
:
</span>
<Switch
checked={values.isLoopPush}
onChange={(checked) => handleChange("isLoopPush", checked)}
disabled={loading}
/>
</div>
{/* 是否立即推送 */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span>
<span style={{ color: "red", marginRight: 4 }}>*</span>
:
</span>
<Switch
checked={values.isImmediatePush}
onChange={(checked) => handleChange("isImmediatePush", checked)}
disabled={loading}
/>
</div>
{values.isImmediatePush && (
<div
style={{
background: "#fffbe6",
border: "1px solid #ffe58f",
borderRadius: 4,
padding: 8,
color: "#ad8b00",
marginBottom: 16,
}}
>
</div>
)}
{/* 是否启用 */}
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span>
<span style={{ color: "red", marginRight: 4 }}>*</span>:
</span>
<Switch
checked={values.isEnabled}
onChange={(checked) => handleChange("isEnabled", checked)}
disabled={loading}
/>
</div>
</div>
</Card>
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
marginTop: 16,
}}
>
<Button onClick={() => onNext(values)} disabled={loading}>
</Button>
<Button onClick={() => onSave(values)} disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
<Button onClick={onCancel} disabled={loading}>
</Button>
</div>
</div>
);
};
export default BasicSettings;

View File

@@ -0,0 +1,247 @@
import React, { useState } from "react";
import { Button, Card, Input, Checkbox, Avatar } from "antd";
import { FileTextOutlined, SearchOutlined } from "@ant-design/icons";
interface ContentLibrary {
id: string;
name: string;
targets: Array<{
id: string;
avatar: string;
}>;
}
interface ContentSelectorProps {
selectedLibraries: ContentLibrary[];
onLibrariesChange: (libraries: ContentLibrary[]) => void;
onPrevious: () => void;
onNext: () => void;
onSave: () => void;
onCancel: () => void;
loading?: boolean;
}
const mockLibraries: ContentLibrary[] = [
{
id: "1",
name: "产品推广内容库",
targets: [
{ id: "1", avatar: "https://via.placeholder.com/32" },
{ id: "2", avatar: "https://via.placeholder.com/32" },
{ id: "3", avatar: "https://via.placeholder.com/32" },
],
},
{
id: "2",
name: "活动宣传内容库",
targets: [
{ id: "4", avatar: "https://via.placeholder.com/32" },
{ id: "5", avatar: "https://via.placeholder.com/32" },
],
},
{
id: "3",
name: "客户服务内容库",
targets: [
{ id: "6", avatar: "https://via.placeholder.com/32" },
{ id: "7", avatar: "https://via.placeholder.com/32" },
{ id: "8", avatar: "https://via.placeholder.com/32" },
{ id: "9", avatar: "https://via.placeholder.com/32" },
],
},
{
id: "4",
name: "节日问候内容库",
targets: [
{ id: "10", avatar: "https://via.placeholder.com/32" },
{ id: "11", avatar: "https://via.placeholder.com/32" },
],
},
{
id: "5",
name: "新品发布内容库",
targets: [
{ id: "12", avatar: "https://via.placeholder.com/32" },
{ id: "13", avatar: "https://via.placeholder.com/32" },
{ id: "14", avatar: "https://via.placeholder.com/32" },
],
},
];
const ContentSelector: React.FC<ContentSelectorProps> = ({
selectedLibraries,
onLibrariesChange,
onPrevious,
onNext,
onSave,
onCancel,
loading = false,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [libraries] = useState<ContentLibrary[]>(mockLibraries);
const filteredLibraries = libraries.filter((library) =>
library.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleLibraryToggle = (library: ContentLibrary, checked: boolean) => {
if (checked) {
onLibrariesChange([...selectedLibraries, library]);
} else {
onLibrariesChange(selectedLibraries.filter((l) => l.id !== library.id));
}
};
const handleSelectAll = () => {
if (selectedLibraries.length === filteredLibraries.length) {
onLibrariesChange([]);
} else {
onLibrariesChange(filteredLibraries);
}
};
const isLibrarySelected = (libraryId: string) => {
return selectedLibraries.some((library) => library.id === libraryId);
};
return (
<div style={{ marginBottom: 24 }}>
<Card>
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 16 }}>
<span>:</span>
<Input
prefix={<SearchOutlined />}
placeholder="搜索内容库名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={loading}
style={{ marginTop: 4 }}
/>
</div>
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Checkbox
checked={
selectedLibraries.length === filteredLibraries.length &&
filteredLibraries.length > 0
}
onChange={handleSelectAll}
disabled={loading}
>
({selectedLibraries.length}/{filteredLibraries.length})
</Checkbox>
</div>
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{filteredLibraries.map((library) => (
<div
key={library.id}
style={{
display: "flex",
alignItems: "center",
padding: 8,
border: "1px solid #f0f0f0",
borderRadius: 6,
marginBottom: 8,
background: isLibrarySelected(library.id)
? "#e6f7ff"
: "#fff",
}}
>
<Checkbox
checked={isLibrarySelected(library.id)}
onChange={(e) =>
handleLibraryToggle(library, e.target.checked)
}
disabled={loading}
style={{ marginRight: 8 }}
/>
<Avatar
icon={<FileTextOutlined />}
size={40}
style={{
marginRight: 8,
background: "#e6f7ff",
color: "#1890ff",
}}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{library.name}</div>
<div style={{ fontSize: 12, color: "#888", marginTop: 2 }}>
{library.targets.length}
</div>
</div>
<div style={{ display: "flex", gap: 2 }}>
{library.targets.slice(0, 3).map((target) => (
<Avatar
key={target.id}
src={target.avatar}
size={24}
style={{ border: "1px solid #fff" }}
/>
))}
{library.targets.length > 3 && (
<div
style={{
width: 24,
height: 24,
borderRadius: 12,
background: "#eee",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
color: "#888",
border: "1px solid #fff",
}}
>
+{library.targets.length - 3}
</div>
)}
</div>
</div>
))}
{filteredLibraries.length === 0 && (
<div style={{ textAlign: "center", color: "#bbb", padding: 32 }}>
<FileTextOutlined style={{ fontSize: 32, marginBottom: 8 }} />
</div>
)}
</div>
</div>
</Card>
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
marginTop: 16,
}}
>
<Button onClick={onPrevious} disabled={loading}>
</Button>
<Button
onClick={onNext}
disabled={loading || selectedLibraries.length === 0}
>
</Button>
<Button onClick={onSave} disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
<Button onClick={onCancel} disabled={loading}>
</Button>
</div>
</div>
);
};
export default ContentSelector;

View File

@@ -0,0 +1,245 @@
import React, { useState } from "react";
import { Button, Card, Input, Checkbox, Avatar } from "antd";
import { TeamOutlined, SearchOutlined } from "@ant-design/icons";
interface WechatGroup {
id: string;
name: string;
avatar: string;
serviceAccount: {
id: string;
name: string;
avatar: string;
};
}
interface GroupSelectorProps {
selectedGroups: WechatGroup[];
onGroupsChange: (groups: WechatGroup[]) => void;
onPrevious: () => void;
onNext: () => void;
onSave: () => void;
onCancel: () => void;
loading?: boolean;
}
const mockGroups: WechatGroup[] = [
{
id: "1",
name: "VIP客户群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "1",
name: "客服小美",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "2",
name: "潜在客户群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "1",
name: "客服小美",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "3",
name: "活动群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "2",
name: "推广专员",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "4",
name: "推广群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "2",
name: "推广专员",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "5",
name: "新客户群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "3",
name: "销售小王",
avatar: "https://via.placeholder.com/32",
},
},
{
id: "6",
name: "体验群",
avatar: "https://via.placeholder.com/40",
serviceAccount: {
id: "3",
name: "销售小王",
avatar: "https://via.placeholder.com/32",
},
},
];
const GroupSelector: React.FC<GroupSelectorProps> = ({
selectedGroups,
onGroupsChange,
onPrevious,
onNext,
onSave,
onCancel,
loading = false,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [groups] = useState<WechatGroup[]>(mockGroups);
const filteredGroups = groups.filter(
(group) =>
group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
group.serviceAccount.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleGroupToggle = (group: WechatGroup, checked: boolean) => {
if (checked) {
onGroupsChange([...selectedGroups, group]);
} else {
onGroupsChange(selectedGroups.filter((g) => g.id !== group.id));
}
};
const handleSelectAll = () => {
if (selectedGroups.length === filteredGroups.length) {
onGroupsChange([]);
} else {
onGroupsChange(filteredGroups);
}
};
const isGroupSelected = (groupId: string) => {
return selectedGroups.some((group) => group.id === groupId);
};
return (
<div style={{ marginBottom: 24 }}>
<Card>
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 16 }}>
<span>:</span>
<Input
prefix={<SearchOutlined />}
placeholder="搜索群组名称或客服名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={loading}
style={{ marginTop: 4 }}
/>
</div>
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<Checkbox
checked={
selectedGroups.length === filteredGroups.length &&
filteredGroups.length > 0
}
onChange={handleSelectAll}
disabled={loading}
>
({selectedGroups.length}/{filteredGroups.length})
</Checkbox>
</div>
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{filteredGroups.map((group) => (
<div
key={group.id}
style={{
display: "flex",
alignItems: "center",
padding: 8,
border: "1px solid #f0f0f0",
borderRadius: 6,
marginBottom: 8,
background: isGroupSelected(group.id) ? "#e6f7ff" : "#fff",
}}
>
<Checkbox
checked={isGroupSelected(group.id)}
onChange={(e) => handleGroupToggle(group, e.target.checked)}
disabled={loading}
style={{ marginRight: 8 }}
/>
<Avatar
src={group.avatar}
size={40}
icon={<TeamOutlined />}
style={{ marginRight: 8 }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{group.name}</div>
<div
style={{
fontSize: 12,
color: "#888",
display: "flex",
alignItems: "center",
marginTop: 2,
}}
>
<Avatar
src={group.serviceAccount.avatar}
size={16}
style={{ marginRight: 4 }}
/>
{group.serviceAccount.name}
</div>
</div>
</div>
))}
{filteredGroups.length === 0 && (
<div style={{ textAlign: "center", color: "#bbb", padding: 32 }}>
<TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
</div>
)}
</div>
</div>
</Card>
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
marginTop: 16,
}}
>
<Button onClick={onPrevious} disabled={loading}>
</Button>
<Button
onClick={onNext}
disabled={loading || selectedGroups.length === 0}
>
</Button>
<Button onClick={onSave} disabled={loading}>
{loading ? "保存中..." : "保存"}
</Button>
<Button onClick={onCancel} disabled={loading}>
</Button>
</div>
</div>
);
};
export default GroupSelector;

View File

@@ -0,0 +1,43 @@
import React from "react";
import { Steps } from "antd-mobile";
interface StepIndicatorProps {
currentStep: number;
steps: { id: number; title: string; subtitle: string }[];
}
const StepIndicator: React.FC<StepIndicatorProps> = ({
currentStep,
steps,
}) => {
return (
<div style={{ marginBottom: 24, overflowX: "auto" }}>
<Steps current={currentStep - 1}>
{steps.map((step, idx) => (
<Steps.Step
key={step.id}
title={step.subtitle}
icon={
<div
style={{
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: idx < currentStep ? "#1677ff" : "#cccccc",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{step.id}
</div>
}
/>
))}
</Steps>
</div>
);
};
export default StepIndicator;

View File

@@ -0,0 +1,32 @@
export interface WechatGroup {
id: string;
name: string;
avatar: string;
serviceAccount: {
id: string;
name: string;
avatar: string;
};
}
export interface ContentLibrary {
id: string;
name: string;
targets: Array<{
id: string;
avatar: string;
}>;
}
export interface FormData {
name: string;
pushTimeStart: string;
pushTimeEnd: string;
dailyPushCount: number;
pushOrder: "earliest" | "latest";
isLoopPush: boolean;
isImmediatePush: boolean;
isEnabled: boolean;
groups: WechatGroup[];
contentLibraries: ContentLibrary[];
}

View File

@@ -0,0 +1,191 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "antd-mobile";
import { NavBar } from "antd-mobile";
import { LeftOutline } from "antd-mobile-icons";
import { createGroupPushTask } from "@/pages/workspace/group-push/detail/groupPush";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import StepIndicator from "./components/StepIndicator";
import BasicSettings from "./components/BasicSettings";
import GroupSelector from "./components/GroupSelector";
import ContentSelector from "./components/ContentSelector";
import type { WechatGroup, ContentLibrary, FormData } from "./index.data";
const steps = [
{ id: 1, title: "步骤 1", subtitle: "基础设置" },
{ id: 2, title: "步骤 2", subtitle: "选择社群" },
{ id: 3, title: "步骤 3", subtitle: "选择内容库" },
{ id: 4, title: "步骤 4", subtitle: "京东联盟" },
];
const NewGroupPush: React.FC = () => {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<FormData>({
name: "",
pushTimeStart: "06:00",
pushTimeEnd: "23:59",
dailyPushCount: 20,
pushOrder: "latest",
isLoopPush: false,
isImmediatePush: false,
isEnabled: false,
groups: [],
contentLibraries: [],
});
const handleBasicSettingsNext = (values: Partial<FormData>) => {
setFormData((prev) => ({ ...prev, ...values }));
setCurrentStep(2);
};
const handleGroupsChange = (groups: WechatGroup[]) => {
setFormData((prev) => ({ ...prev, groups }));
};
const handleLibrariesChange = (contentLibraries: ContentLibrary[]) => {
setFormData((prev) => ({ ...prev, contentLibraries }));
};
const handleSave = async () => {
if (!formData.name.trim()) {
window.alert("请输入任务名称");
return;
}
if (formData.groups.length === 0) {
window.alert("请选择至少一个社群");
return;
}
if (formData.contentLibraries.length === 0) {
window.alert("请选择至少一个内容库");
return;
}
setLoading(true);
try {
const apiData = {
name: formData.name,
timeRange: {
start: formData.pushTimeStart,
end: formData.pushTimeEnd,
},
maxPushPerDay: formData.dailyPushCount,
pushOrder: formData.pushOrder,
isLoopPush: formData.isLoopPush,
isImmediatePush: formData.isImmediatePush,
isEnabled: formData.isEnabled,
targetGroups: formData.groups.map((g) => g.name),
contentLibraries: formData.contentLibraries.map((c) => c.name),
pushMode: formData.isImmediatePush
? ("immediate" as const)
: ("scheduled" as const),
messageType: "text" as const,
messageContent: "",
targetTags: [],
pushInterval: 60,
};
const response = await createGroupPushTask(apiData);
if (response.code === 200) {
window.alert("保存成功");
navigate("/workspace/group-push");
} else {
window.alert("保存失败,请稍后重试");
}
} catch (error) {
window.alert("保存失败,请稍后重试");
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate("/workspace/group-push");
};
return (
<Layout
header={
<NavBar
onBack={() => navigate(-1)}
style={{ background: "#fff" }}
right={null}
>
<span style={{ fontWeight: 600, fontSize: 18 }}></span>
</NavBar>
}
footer={<MeauMobile />}
>
<div style={{ maxWidth: 600, margin: "0 auto", padding: 16 }}>
<StepIndicator currentStep={currentStep} steps={steps} />
<div style={{ marginTop: 32 }}>
{currentStep === 1 && (
<BasicSettings
defaultValues={{
name: formData.name,
pushTimeStart: formData.pushTimeStart,
pushTimeEnd: formData.pushTimeEnd,
dailyPushCount: formData.dailyPushCount,
pushOrder: formData.pushOrder,
isLoopPush: formData.isLoopPush,
isImmediatePush: formData.isImmediatePush,
isEnabled: formData.isEnabled,
}}
onNext={handleBasicSettingsNext}
onSave={handleSave}
onCancel={handleCancel}
loading={loading}
/>
)}
{currentStep === 2 && (
<GroupSelector
selectedGroups={formData.groups}
onGroupsChange={handleGroupsChange}
onPrevious={() => setCurrentStep(1)}
onNext={() => setCurrentStep(3)}
onSave={handleSave}
onCancel={handleCancel}
loading={loading}
/>
)}
{currentStep === 3 && (
<ContentSelector
selectedLibraries={formData.contentLibraries}
onLibrariesChange={handleLibrariesChange}
onPrevious={() => setCurrentStep(2)}
onNext={() => setCurrentStep(4)}
onSave={handleSave}
onCancel={handleCancel}
loading={loading}
/>
)}
{currentStep === 4 && (
<div style={{ padding: 32, textAlign: "center", color: "#888" }}>
<div
style={{
marginTop: 24,
display: "flex",
justifyContent: "center",
gap: 8,
}}
>
<Button onClick={() => setCurrentStep(3)} disabled={loading}>
</Button>
<Button color="primary" onClick={handleSave} loading={loading}>
</Button>
<Button onClick={handleCancel} disabled={loading}>
</Button>
</div>
</div>
)}
</div>
</div>
</Layout>
);
};
export default NewGroupPush;

View File

@@ -0,0 +1,111 @@
.nav-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.searchBar {
display: flex;
gap: 8px;
padding: 16px;
}
.refresh-btn {
height: 38px;
width: 40px;
padding: 0;
border-radius: 8px;
}
.taskList {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 16px;
}
.emptyCard {
text-align: center;
padding: 48px 0;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.taskCard {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 20px 16px 12px 16px;
}
.taskHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.taskTitle {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
}
.taskActions {
display: flex;
align-items: center;
gap: 8px;
}
.taskInfoGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
font-size: 13px;
color: #666;
margin-bottom: 12px;
}
.progressBlock {
margin-bottom: 12px;
}
.progressLabel {
font-size: 13px;
color: #888;
margin-bottom: 4px;
}
.taskFooter {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #888;
border-top: 1px dashed #eee;
padding-top: 8px;
margin-top: 8px;
}
.expandedPanel {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #eee;
}
.expandedGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
@media (max-width: 600px) {
.taskCard {
padding: 12px 6px 8px 6px;
}
.expandedGrid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,407 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { NavBar } from "antd-mobile";
import { LeftOutline } from "antd-mobile-icons";
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
MoreOutlined,
ClockCircleOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
CopyOutlined,
DownOutlined,
UpOutlined,
SettingOutlined,
CalendarOutlined,
TeamOutlined,
MessageOutlined,
SendOutlined,
} from "@ant-design/icons";
import {
Card,
Button,
Input,
Badge,
Switch,
Progress,
Dropdown,
Menu,
} from "antd";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
import {
fetchGroupPushTasks,
deleteGroupPushTask,
toggleGroupPushTask,
copyGroupPushTask,
GroupPushTask,
} from "@/pages/workspace/group-push/detail/groupPush";
import styles from "./index.module.scss";
const { Search } = Input;
const GroupPush: React.FC = () => {
const navigate = useNavigate();
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [tasks, setTasks] = useState<GroupPushTask[]>([]);
const [loading, setLoading] = useState(false);
const fetchTasks = async () => {
setLoading(true);
try {
const list = await fetchGroupPushTasks();
setTasks(list);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
const toggleExpand = (taskId: string) => {
setExpandedTaskId(expandedTaskId === taskId ? null : taskId);
};
const handleDelete = async (taskId: string) => {
if (!window.confirm("确定要删除该任务吗?")) return;
await deleteGroupPushTask(taskId);
fetchTasks();
};
const handleEdit = (taskId: string) => {
navigate(`/workspace/group-push/${taskId}/edit`);
};
const handleView = (taskId: string) => {
navigate(`/workspace/group-push/${taskId}`);
};
const handleCopy = async (taskId: string) => {
await copyGroupPushTask(taskId);
fetchTasks();
};
const toggleTaskStatus = async (taskId: string) => {
const task = tasks.find((t) => t.id === taskId);
if (!task) return;
const newStatus = task.status === 1 ? 2 : 1;
await toggleGroupPushTask(taskId, String(newStatus));
fetchTasks();
};
const handleCreateNew = () => {
navigate("/workspace/group-push/new");
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const getStatusColor = (status: number) => {
switch (status) {
case 1:
return "green";
case 2:
return "gray";
default:
return "gray";
}
};
const getStatusText = (status: number) => {
switch (status) {
case 1:
return "进行中";
case 2:
return "已暂停";
default:
return "未知";
}
};
const getMessageTypeText = (type: string) => {
switch (type) {
case "text":
return "文字";
case "image":
return "图片";
case "video":
return "视频";
case "link":
return "链接";
default:
return "未知";
}
};
const getSuccessRate = (pushCount: number, successCount: number) => {
if (pushCount === 0) return 0;
return Math.round((successCount / pushCount) * 100);
};
return (
<Layout
header={
<NavBar
back={null}
left={
<div className={styles["nav-title"]}>
<span style={{ verticalAlign: "middle" }}>
<LeftOutline onClick={() => navigate(-1)} fontSize={24} />
</span>
</div>
}
style={{ background: "#fff" }}
right={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreateNew}
>
</Button>
}
></NavBar>
}
footer={<MeauMobile />}
>
<div className={styles.bg}>
<div className={styles.searchBar}>
<Input
placeholder="搜索计划名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
<Button
className={styles["refresh-btn"]}
size="small"
onClick={fetchTasks}
loading={loading}
>
<ReloadOutlined />
</Button>
</div>
<div className={styles.taskList}>
{filteredTasks.length === 0 ? (
<Card className={styles.emptyCard}>
<SendOutlined
style={{ fontSize: 48, color: "#ccc", marginBottom: 12 }}
/>
<div style={{ color: "#888", fontSize: 16, marginBottom: 8 }}>
</div>
<div style={{ color: "#bbb", fontSize: 13, marginBottom: 16 }}>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreateNew}
>
</Button>
</Card>
) : (
filteredTasks.map((task) => (
<Card key={task.id} className={styles.taskCard}>
<div className={styles.taskHeader}>
<div className={styles.taskTitle}>
<span>{task.name}</span>
<Badge
color={getStatusColor(task.status)}
text={getStatusText(task.status)}
style={{ marginLeft: 8 }}
/>
</div>
<div className={styles.taskActions}>
<Switch
checked={task.status === 1}
onChange={() => toggleTaskStatus(task.id)}
/>
<Dropdown
overlay={
<Menu>
<Menu.Item
key="view"
icon={<EyeOutlined />}
onClick={() => handleView(task.id)}
>
</Menu.Item>
<Menu.Item
key="edit"
icon={<EditOutlined />}
onClick={() => handleEdit(task.id)}
>
</Menu.Item>
<Menu.Item
key="copy"
icon={<CopyOutlined />}
onClick={() => handleCopy(task.id)}
>
</Menu.Item>
<Menu.Item
key="delete"
icon={<DeleteOutlined />}
onClick={() => handleDelete(task.id)}
danger
>
</Menu.Item>
</Menu>
}
trigger={["click"]}
>
<Button icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
<div className={styles.taskInfoGrid}>
<div>{task.deviceCount} </div>
<div>{task.targetGroups.length} </div>
<div>
{task.successCount}/{task.pushCount}
</div>
<div>{task.creator}</div>
</div>
<div className={styles.progressBlock}>
<div className={styles.progressLabel}></div>
<Progress
percent={getSuccessRate(task.pushCount, task.successCount)}
size="small"
/>
</div>
<div className={styles.taskFooter}>
<div>
<ClockCircleOutlined /> {task.lastPushTime}
</div>
<div>
{task.createTime}
<Button
type="link"
size="small"
icon={
expandedTaskId === task.id ? (
<UpOutlined />
) : (
<DownOutlined />
)
}
onClick={() => toggleExpand(task.id)}
/>
</div>
</div>
{expandedTaskId === task.id && (
<div className={styles.expandedPanel}>
<div className={styles.expandedGrid}>
<div>
<SettingOutlined /> <b></b>
<div>{task.pushInterval} </div>
<div>{task.maxPushPerDay} </div>
<div>
{task.timeRange.start} -{" "}
{task.timeRange.end}
</div>
<div>
{task.pushMode === "immediate"
? "立即推送"
: "定时推送"}
</div>
{task.scheduledTime && (
<div>{task.scheduledTime}</div>
)}
</div>
<div>
<TeamOutlined /> <b></b>
<div
style={{ display: "flex", flexWrap: "wrap", gap: 4 }}
>
{task.targetGroups.map((group) => (
<Badge
key={group}
color="blue"
text={group}
style={{ background: "#f0f5ff", marginRight: 4 }}
/>
))}
</div>
</div>
<div>
<MessageOutlined /> <b></b>
<div>
{getMessageTypeText(task.messageType)}
</div>
<div
style={{
background: "#f5f5f5",
padding: 8,
borderRadius: 4,
marginTop: 4,
}}
>
{task.messageContent}
</div>
</div>
<div>
<CalendarOutlined /> <b></b>
<div>
{task.pushCount} / {task.maxPushPerDay}
</div>
<Progress
percent={Math.round(
(task.pushCount / task.maxPushPerDay) * 100
)}
size="small"
/>
{task.targetTags.length > 0 && (
<div style={{ marginTop: 8 }}>
<div></div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
}}
>
{task.targetTags.map((tag) => (
<Badge
key={tag}
color="purple"
text={tag}
style={{
background: "#f9f0ff",
marginRight: 4,
}}
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</Card>
))
)}
</div>
</div>
</Layout>
);
};
export default GroupPush;

View File

@@ -1,8 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const NewGroupPush: React.FC = () => {
return <PlaceholderPage title="新建群发推送" />;
};
export default NewGroupPush;

View File

@@ -140,7 +140,7 @@ const Workspace: React.FC = () => {
</div>
</NavBar>
}
footer={<MeauMobile />}
footer={<MeauMobile activeKey="workspace" />}
>
<div className={styles.workspace}>
{/* 常用功能 */}

View File

@@ -1,8 +1,186 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
import React, { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Button, Switch, message, Spin, Badge } from "antd";
import {
ArrowLeftOutlined,
EditOutlined,
ClockCircleOutlined,
DatabaseOutlined,
MobileOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import request from "@/api/request";
interface MomentsSyncTask {
id: string;
name: string;
status: 1 | 2;
deviceCount: number;
syncCount: number;
lastSyncTime: string;
createTime: string;
creatorName: string;
updateTime?: string;
maxSyncPerDay?: number;
syncInterval?: number;
timeRange?: { start: string; end: string };
contentTypes?: string[];
targetTags?: string[];
todaySyncCount?: number;
totalSyncCount?: number;
syncMode?: string;
config?: {
devices?: string[];
contentLibraryNames?: string[];
syncCount?: number;
};
}
const getStatusText = (status: number) => {
switch (status) {
case 1:
return "进行中";
case 2:
return "已暂停";
default:
return "未知";
}
};
const MomentsSyncDetail: React.FC = () => {
return <PlaceholderPage title="朋友圈同步详情" />;
const { id } = useParams();
const navigate = useNavigate();
const [task, setTask] = useState<MomentsSyncTask | null>(null);
const [loading, setLoading] = useState(false);
const fetchTaskDetail = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const res = await request("/v1/workbench/detail", { id }, "GET");
if (res) setTask(res);
} catch {
message.error("获取任务详情失败");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
if (id) fetchTaskDetail();
}, [id, fetchTaskDetail]);
const handleToggleStatus = async () => {
if (!task || !id) return;
try {
const newStatus = task.status === 1 ? 2 : 1;
await request(
"/v1/workbench/update-status",
{ id, status: newStatus },
"POST"
);
setTask({ ...task, status: newStatus });
message.success(newStatus === 1 ? "任务已开启" : "任务已暂停");
} catch {
message.error("操作失败");
}
};
const handleEdit = () => {
if (id) navigate(`/workspace/moments-sync/edit/${id}`);
};
if (loading) {
return (
<Layout>
<div className={style.detailLoading}>
<Spin size="large" />
</div>
</Layout>
);
}
if (!task) {
return (
<Layout>
<div className={style.detailLoading}>
<div></div>
<Button onClick={() => navigate("/workspace/moments-sync")}>
</Button>
</div>
</Layout>
);
}
return (
<Layout
header={
<div className={style.headerBar}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate("/workspace/moments-sync")}
className={style.backBtn}
/>
<span className={style.title}></span>
<Button
icon={<EditOutlined />}
onClick={handleEdit}
className={style.editBtn}
>
</Button>
</div>
}
>
<div className={style.detailBg}>
<div className={style.detailCard}>
<div className={style.detailTop}>
<div className={style.detailTitle}>{task.name}</div>
<span
className={
task.status === 1
? style.statusPill + " " + style.statusActive
: style.statusPill + " " + style.statusPaused
}
>
{getStatusText(task.status)}
</span>
<Switch
checked={task.status === 1}
onChange={handleToggleStatus}
className={style.switchBtn}
size="small"
/>
</div>
<div className={style.detailInfoRow}>
<div className={style.infoCol}>
{task.config?.devices?.length || 0}
</div>
<div className={style.infoCol}>
{task.config?.contentLibraryNames?.join(",") || "-"}
</div>
</div>
<div className={style.detailInfoRow}>
<div className={style.infoCol}>
{task.syncCount || 0}
</div>
<div className={style.infoCol}>{task.creatorName}</div>
</div>
<div className={style.detailBottom}>
<div className={style.bottomLeft}>
<ClockCircleOutlined className={style.clockIcon} />
{task.lastSyncTime || "无"}
</div>
<div className={style.bottomRight}>{task.createTime}</div>
</div>
</div>
{/* 可继续补充更多详情卡片,如同步设置、同步记录等 */}
</div>
</Layout>
);
};
export default MomentsSyncDetail;

View File

@@ -1,13 +1,285 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Switch, Input, message, Dropdown, Menu } from "antd";
import { NavBar, Button } from "antd-mobile";
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
CopyOutlined,
MoreOutlined,
ClockCircleOutlined,
ArrowLeftOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import request from "@/api/request";
interface MomentsSyncTask {
id: string;
name: string;
status: 1 | 2;
deviceCount: number;
syncCount: number;
lastSyncTime: string;
createTime: string;
creatorName: string;
contentLib?: string;
config?: { devices?: string[]; contentLibraryNames?: string[] };
}
const getStatusText = (status: number) => {
switch (status) {
case 1:
return "进行中";
case 2:
return "已暂停";
default:
return "未知";
}
};
const MomentsSync: React.FC = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(false);
const [tasks, setTasks] = useState<MomentsSyncTask[]>([]);
const fetchTasks = async () => {
setLoading(true);
try {
const res = await request(
"/v1/workbench/list",
{ type: 2, page: 1, limit: 100 },
"GET"
);
setTasks(res.list || []);
} catch (e) {
message.error("获取任务失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, []);
const handleDelete = async (id: string) => {
if (!window.confirm("确定要删除该任务吗?")) return;
try {
await request("/v1/workbench/delete", { id }, "DELETE");
message.success("删除成功");
fetchTasks();
} catch {
message.error("删除失败");
}
};
const handleCopy = async (id: string) => {
try {
await request("/v1/workbench/copy", { id }, "POST");
message.success("复制成功");
fetchTasks();
} catch {
message.error("复制失败");
}
};
const handleToggle = async (id: string, status: number) => {
const newStatus = status === 1 ? 2 : 1;
try {
await request(
"/v1/workbench/update-status",
{ id, status: newStatus },
"POST"
);
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t))
);
message.success("操作成功");
} catch {
message.error("操作失败");
}
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// 菜单
const getMenu = (task: MomentsSyncTask) => (
<Menu>
<Menu.Item
key="view"
icon={<EyeOutlined />}
onClick={() => navigate(`/workspace/moments-sync/${task.id}`)}
>
</Menu.Item>
<Menu.Item
key="edit"
icon={<EditOutlined />}
onClick={() => navigate(`/workspace/moments-sync/edit/${task.id}`)}
>
</Menu.Item>
<Menu.Item
key="copy"
icon={<CopyOutlined />}
onClick={() => handleCopy(task.id)}
>
</Menu.Item>
<Menu.Item
key="delete"
icon={<DeleteOutlined />}
onClick={() => handleDelete(task.id)}
danger
>
</Menu.Item>
</Menu>
);
return (
<PlaceholderPage
title="朋友圈同步"
showAddButton
addButtonText="新建同步"
/>
<Layout
header={
<>
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate("/workspace")}
/>
</div>
}
right={
<Button
size="small"
color="primary"
onClick={() => navigate("/workspace/moments-sync/new")}
>
<PlusOutlined />
</Button>
}
>
<span className="nav-title"></span>
</NavBar>
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索任务名称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
</div>
<Button
size="small"
onClick={fetchTasks}
loading={loading}
className="refresh-btn"
>
<ReloadOutlined />
</Button>
</div>
</>
}
>
<div className={style.pageBg}>
<div className={style.taskList}>
{filteredTasks.length === 0 ? (
<div className={style.emptyBox}>
<span style={{ fontSize: 40, color: "#ddd" }}>
<ClockCircleOutlined />
</span>
<div className={style.emptyText}></div>
<Button
type="primary"
onClick={() => navigate("/workspace/moments-sync/new")}
>
</Button>
</div>
) : (
filteredTasks.map((task) => (
<div key={task.id} className={style.itemCard}>
<div className={style.itemTop}>
<div className={style.itemTitle}>
<span className={style.itemName}>{task.name}</span>
<span
className={
task.status === 1
? style.statusPill + " " + style.statusActive
: style.statusPill + " " + style.statusPaused
}
>
{getStatusText(task.status)}
</span>
</div>
<div className={style.itemActions}>
<Switch
checked={task.status === 1}
onChange={() => handleToggle(task.id, task.status)}
className={style.switchBtn}
size="small"
/>
<Dropdown
overlay={getMenu(task)}
trigger={["click"]}
placement="bottomRight"
>
<Button
type="text"
icon={<MoreOutlined />}
className={style.moreBtn}
/>
</Dropdown>
</div>
</div>
<div className={style.itemInfoRow}>
<div className={style.infoCol}>
{task.config?.devices?.length || 0}
</div>
<div className={style.infoCol}>
{task.syncCount || 0}
</div>
</div>
<div className={style.itemInfoRow}>
<div className={style.infoCol}>
{task.config?.contentLibraryNames?.join(",") ||
task.contentLib ||
"默认内容库"}
</div>
<div className={style.infoCol}>
{task.creatorName}
</div>
</div>
<div className={style.itemBottom}>
<div className={style.bottomLeft}>
<ClockCircleOutlined className={style.clockIcon} />
{task.lastSyncTime || "无"}
</div>
<div className={style.bottomRight}>
{task.createTime}
</div>
</div>
</div>
))
)}
</div>
</div>
</Layout>
);
};

View File

@@ -0,0 +1,366 @@
.pageBg {
}
.title {
font-size: 18px;
font-weight: bold;
color: #188eee;
}
.backBtn {
border: none;
background: none;
box-shadow: none;
color: #666;
font-size: 18px;
margin-right: 8px;
}
.addBtn {
margin-left: 8px;
}
.taskList {
padding:0 16px;
}
.taskCard {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
margin-bottom: 16px;
padding: 16px;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 16px rgba(24,142,238,0.10);
}
}
.taskCardTop {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.taskName {
font-size: 16px;
font-weight: 500;
color: #222;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.switchBtn {
margin-left: 8px;
}
.actionBtn {
margin-left: 4px;
}
.taskCardInfo {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
font-size: 13px;
color: #666;
margin-top: 8px;
}
.emptyBox {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 40px 0 32px 0;
text-align: center;
color: #bbb;
margin-top: 40px;
}
.emptyText {
font-size: 16px;
color: #888;
margin: 16px 0 20px 0;
}
.itemCard {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
margin-bottom: 16px;
padding: 16px 16px 12px 16px;
transition: box-shadow 0.2s;
position: relative;
&:hover {
box-shadow: 0 4px 16px rgba(24,142,238,0.10);
}
}
.itemTop {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.itemTitle {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 500;
color: #222;
}
.itemName {
font-size: 15px;
font-weight: 500;
color: #222;
margin-right: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.statusBadge {
margin-left: 2px;
.ant-badge-status-dot {
width: 8px;
height: 8px;
}
.ant-badge-status-success {
background: #19c37d;
}
.ant-badge-status-default {
background: #bdbdbd;
}
.ant-badge-status-text {
font-size: 12px;
font-weight: 400;
padding: 0 6px;
border-radius: 8px;
background: #f5f5f5;
color: #222;
}
}
.itemActions {
display: flex;
align-items: center;
gap: 6px;
}
.switchBtn {
margin-right: 2px;
}
.moreBtn {
margin-left: 2px;
color: #888;
font-size: 18px;
background: none;
border: none;
box-shadow: none;
}
.itemInfoRow {
display: flex;
font-size: 13px;
color: #666;
margin-bottom: 2px;
}
.infoCol {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemBottom {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #888;
border-top: 1px solid #f3f3f3;
margin-top: 8px;
padding-top: 6px;
}
.bottomLeft {
display: flex;
align-items: center;
gap: 4px;
}
.clockIcon {
color: #bdbdbd;
font-size: 14px;
margin-right: 2px;
}
.bottomRight {
text-align: right;
}
// 覆盖Antd Dropdown菜单样式
.ant-dropdown-menu {
border-radius: 10px !important;
box-shadow: 0 4px 16px rgba(0,0,0,0.12) !important;
min-width: 110px !important;
padding: 6px 0 !important;
}
.ant-dropdown-menu-item {
font-size: 14px !important;
padding: 7px 16px !important;
border-radius: 6px !important;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.2s;
}
.ant-dropdown-menu-item:hover {
background: #f5f5f5 !important;
}
.ant-dropdown-menu-item-danger {
color: #e53e3e !important;
}
.detailBg {
background: #f8f6f3;
min-height: 100vh;
padding: 24px 0 32px 0;
display: flex;
flex-direction: column;
align-items: center;
}
.detailCard {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 20px 20px 12px 20px;
width: 100%;
max-width: 480px;
margin-bottom: 24px;
}
.detailTop {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.detailTitle {
font-size: 18px;
font-weight: 600;
color: #222;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusBadge {
margin-left: 2px;
.ant-badge-status-dot {
width: 8px;
height: 8px;
}
.ant-badge-status-success {
background: #19c37d;
}
.ant-badge-status-default {
background: #bdbdbd;
}
.ant-badge-status-text {
font-size: 12px;
font-weight: 400;
padding: 0 6px;
border-radius: 8px;
background: #f5f5f5;
color: #222;
}
}
.switchBtn {
margin-left: 8px;
}
.detailInfoRow {
display: flex;
font-size: 14px;
color: #666;
margin-bottom: 2px;
}
.infoCol {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detailBottom {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #888;
border-top: 1px solid #f3f3f3;
margin-top: 10px;
padding-top: 6px;
}
.bottomLeft {
display: flex;
align-items: center;
gap: 4px;
}
.clockIcon {
color: #bdbdbd;
font-size: 14px;
margin-right: 2px;
}
.bottomRight {
text-align: right;
}
.detailLoading {
min-height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #888;
font-size: 16px;
gap: 16px;
}
.statusPill {
display: inline-block;
min-width: 48px;
height: 20px;
line-height: 20px;
font-size: 10px;
border-radius: 12px;
text-align: center;
margin-left: 6px;
box-sizing: border-box;
}
.statusActive {
background: #19c37d;
color: #fff;
}
.statusPaused {
background: #e5e7eb;
color: #888;
}

View File

@@ -1,8 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const NewMomentsSync: React.FC = () => {
return <PlaceholderPage title="新建朋友圈同步" />;
};
export default NewMomentsSync;

View File

@@ -0,0 +1,243 @@
.formBg {
padding: 16px;
}
.formSteps {
display: flex;
justify-content: center;
margin-bottom: 32px;
gap: 32px;
}
.formStepIndicator {
display: flex;
flex-direction: column;
align-items: center;
color: #bbb;
font-size: 13px;
font-weight: 400;
transition: color 0.2s;
}
.formStepActive {
color: #188eee;
font-weight: 600;
}
.formStepDone {
color: #19c37d;
}
.formStepNum {
width: 28px;
height: 28px;
border-radius: 50%;
background: #e5e7eb;
color: #888;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
margin-bottom: 4px;
}
.formStepActive .formStepNum {
background: #188eee;
color: #fff;
}
.formStepDone .formStepNum {
background: #19c37d;
color: #fff;
}
.formStep {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 32px 24px 24px 24px;
width: 100%;
max-width: 420px;
margin: 0 auto 24px auto;
}
.formItem {
margin-bottom: 24px;
}
.formLabel {
font-size: 15px;
color: #222;
font-weight: 500;
margin-bottom: 10px;
}
.input {
height: 44px;
border-radius: 8px;
font-size: 15px;
}
.timeRow {
display: flex;
align-items: center;
}
.inputTime {
width: 90px;
height: 40px;
border-radius: 8px;
font-size: 15px;
}
.timeTo {
margin: 0 8px;
color: #888;
}
.counterRow {
display: flex;
align-items: center;
gap: 0;
}
.counterBtn {
width: 40px;
height: 40px;
border-radius: 8px;
background: #fff;
border: 1px solid #e5e7eb;
font-size: 20px;
color: #188eee;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border 0.2s;
}
.counterBtn:hover {
border: 1px solid #188eee;
}
.counterValue {
width: 48px;
text-align: center;
font-size: 18px;
font-weight: 600;
color: #222;
}
.counterUnit {
margin-left: 8px;
color: #888;
font-size: 14px;
}
.accountTypeRow {
display: flex;
gap: 12px;
}
.accountTypeBtn {
flex: 1;
height: 44px;
border-radius: 8px;
background: #fff;
border: 1px solid #e5e7eb;
font-size: 15px;
color: #666;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.accountTypeBtn:hover {
border: 1px solid #188eee;
}
.accountTypeActive {
background: #f0f8ff;
border: 1px solid #188eee;
color: #188eee;
}
.questionIcon {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 14px;
color: #999;
}
.switchRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.switchLabel {
font-size: 15px;
color: #222;
font-weight: 500;
}
.switch {
margin-top: 0;
}
.searchInput {
height: 44px;
border-radius: 8px;
font-size: 15px;
}
.searchIcon {
color: #999;
font-size: 16px;
}
.selectedTip {
font-size: 13px;
color: #888;
margin-top: 8px;
}
.formStepBtnRow {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 32px;
}
.prevBtn {
height: 44px;
border-radius: 8px;
font-size: 15px;
min-width: 100px;
}
.nextBtn {
height: 44px;
border-radius: 8px;
font-size: 15px;
min-width: 100px;
}
.completeBtn {
height: 44px;
border-radius: 8px;
font-size: 15px;
min-width: 100px;
}
.formLoading {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,328 @@
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Input, Switch, message, Spin } from "antd";
import { ArrowLeftOutlined } from "@ant-design/icons";
import { NavBar } from "antd-mobile";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import request from "@/api/request";
const steps = ["基础设置", "设备选择", "内容库选择"];
const defaultForm = {
taskName: "",
startTime: "06:00",
endTime: "23:59",
syncCount: 5,
accountType: "business" as "business" | "personal",
enabled: true,
selectedDevices: [] as string[],
selectedLibraries: [] as string[],
};
const NewMomentsSync: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const isEditMode = !!id;
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({ ...defaultForm });
// 获取详情(编辑)
const fetchDetail = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const res = await request("/v1/workbench/detail", { id }, "GET");
if (res) {
setFormData({
taskName: res.name,
startTime: res.timeRange?.start || "06:00",
endTime: res.timeRange?.end || "23:59",
syncCount: res.config?.syncCount || res.syncCount || 5,
accountType: res.accountType === 1 ? "business" : "personal",
enabled: res.status === 1,
selectedDevices: res.config?.devices || [],
selectedLibraries: res.config?.contentLibraryNames || [],
});
}
} catch {
message.error("获取详情失败");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
if (isEditMode) fetchDetail();
}, [isEditMode, fetchDetail]);
// 步骤切换
const next = () => setCurrentStep((s) => Math.min(s + 1, steps.length - 1));
const prev = () => setCurrentStep((s) => Math.max(s - 1, 0));
// 表单数据更新
const updateForm = (data: Partial<typeof formData>) => {
setFormData((prev) => ({ ...prev, ...data }));
};
// 提交
const handleSubmit = async () => {
if (!formData.taskName.trim()) {
message.error("请输入任务名称");
return;
}
if (formData.selectedDevices.length === 0) {
message.error("请选择设备");
return;
}
if (formData.selectedLibraries.length === 0) {
message.error("请选择内容库");
return;
}
setLoading(true);
try {
const params = {
name: formData.taskName,
devices: formData.selectedDevices,
contentLibraries: formData.selectedLibraries,
syncCount: formData.syncCount,
startTime: formData.startTime,
endTime: formData.endTime,
accountType: formData.accountType === "business" ? 1 : 2,
status: formData.enabled ? 1 : 2,
type: 2,
};
if (isEditMode && id) {
await request("/v1/workbench/update", { id, ...params }, "POST");
message.success("更新成功");
navigate(`/workspace/moments-sync/${id}`);
} else {
await request("/v1/workbench/create", params, "POST");
message.success("创建成功");
navigate("/workspace/moments-sync");
}
} catch {
message.error(isEditMode ? "更新失败" : "创建失败");
} finally {
setLoading(false);
}
};
// 步骤内容
const renderStep = () => {
if (currentStep === 0) {
return (
<div className={style.formStep}>
<div className={style.formItem}>
<div className={style.formLabel}></div>
<Input
value={formData.taskName}
onChange={(e) => updateForm({ taskName: e.target.value })}
placeholder="请输入任务名称"
maxLength={30}
className={style.input}
/>
</div>
<div className={style.formItem}>
<div className={style.formLabel}></div>
<div className={style.timeRow}>
<Input
type="time"
value={formData.startTime}
onChange={(e) => updateForm({ startTime: e.target.value })}
className={style.inputTime}
/>
<span className={style.timeTo}></span>
<Input
type="time"
value={formData.endTime}
onChange={(e) => updateForm({ endTime: e.target.value })}
className={style.inputTime}
/>
</div>
</div>
<div className={style.formItem}>
<div className={style.formLabel}></div>
<div className={style.counterRow}>
<button
type="button"
className={style.counterBtn}
onClick={() =>
updateForm({ syncCount: Math.max(1, formData.syncCount - 1) })
}
>
-
</button>
<span className={style.counterValue}>{formData.syncCount}</span>
<button
type="button"
className={style.counterBtn}
onClick={() =>
updateForm({ syncCount: formData.syncCount + 1 })
}
>
+
</button>
<span className={style.counterUnit}></span>
</div>
</div>
<div className={style.formItem}>
<div className={style.formLabel}></div>
<div className={style.accountTypeRow}>
<button
type="button"
className={`${style.accountTypeBtn} ${formData.accountType === "business" ? style.accountTypeActive : ""}`}
onClick={() => updateForm({ accountType: "business" })}
>
</button>
<button
type="button"
className={`${style.accountTypeBtn} ${formData.accountType === "personal" ? style.accountTypeActive : ""}`}
onClick={() => updateForm({ accountType: "personal" })}
>
</button>
</div>
</div>
<div className={style.formItem}>
<div className={style.switchRow}>
<span className={style.switchLabel}></span>
<Switch
checked={formData.enabled}
onChange={(checked) => updateForm({ enabled: checked })}
className={style.switch}
/>
</div>
</div>
<div className={style.formStepBtnRow}>
<Button type="primary" onClick={next} className={style.nextBtn}>
</Button>
</div>
</div>
);
}
if (currentStep === 1) {
return (
<div className={style.formStep}>
<div className={style.formItem}>
<Input
placeholder="选择设备"
prefix={<span className={style.searchIcon}>Q</span>}
className={style.searchInput}
onClick={() => message.info("这里应弹出设备选择器")}
readOnly
/>
{formData.selectedDevices.length > 0 && (
<div className={style.selectedTip}>
: {formData.selectedDevices.length}
</div>
)}
</div>
<div className={style.formStepBtnRow}>
<Button onClick={prev} className={style.prevBtn}>
</Button>
<Button type="primary" onClick={next} className={style.nextBtn}>
</Button>
</div>
</div>
);
}
if (currentStep === 2) {
return (
<div className={style.formStep}>
<div className={style.formItem}>
<Input
placeholder="选择内容库"
prefix={<span className={style.searchIcon}>Q</span>}
className={style.searchInput}
onClick={() => message.info("这里应弹出内容库选择器")}
readOnly
/>
{formData.selectedLibraries.length > 0 && (
<div className={style.selectedTip}>
: {formData.selectedLibraries.length}
</div>
)}
</div>
<div className={style.formStepBtnRow}>
<Button onClick={prev} className={style.prevBtn}>
</Button>
<Button
type="primary"
onClick={handleSubmit}
loading={loading}
className={style.completeBtn}
>
</Button>
</div>
</div>
);
}
return null;
};
return (
<Layout
header={
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
>
{isEditMode ? "编辑朋友圈同步" : "新建朋友圈同步"}
</NavBar>
}
>
<div className={style.formBg}>
<div className={style.formSteps}>
{steps.map((s, i) => (
<div
key={s}
className={
style.formStepIndicator +
" " +
(i === currentStep
? style.formStepActive
: i < currentStep
? style.formStepDone
: "")
}
>
<span className={style.formStepNum}>{i + 1}</span>
<span>{s}</span>
</div>
))}
</div>
{loading ? (
<div className={style.formLoading}>
<Spin />
</div>
) : (
renderStep()
)}
</div>
</Layout>
);
};
export default NewMomentsSync;

View File

@@ -3,6 +3,7 @@ import Plans from "@/pages/plans/Plans";
import PlanDetail from "@/pages/plans/PlanDetail";
import Orders from "@/pages/orders/Orders";
import ContactImport from "@/pages/contact-import/ContactImport";
import SelectionTest from "@/components/SelectionTest";
const otherRoutes = [
{
@@ -35,6 +36,11 @@ const otherRoutes = [
element: <ContactImport />,
auth: true,
},
{
path: "/selection-test",
element: <SelectionTest />,
auth: false,
},
];
export default otherRoutes;

View File

@@ -1,14 +1,16 @@
import Workspace from "@/pages/workspace/main";
import AutoLike from "@/pages/workspace/auto-like/AutoLike";
import NewAutoLike from "@/pages/workspace/auto-like/NewAutoLike";
import AutoLikeDetail from "@/pages/workspace/auto-like/AutoLikeDetail";
import AutoGroup from "@/pages/workspace/auto-group/AutoGroup";
import AutoGroupDetail from "@/pages/workspace/auto-group/Detail";
import GroupPush from "@/pages/workspace/group-push/GroupPush";
import NewGroupPush from "@/pages/workspace/group-push/new";
import ListAutoLike from "@/pages/workspace/auto-like/list";
import NewAutoLike from "@/pages/workspace/auto-like/new";
import RecordAutoLike from "@/pages/workspace/auto-like/record";
import AutoGroupList from "@/pages/workspace/auto-group/list";
import AutoGroupDetail from "@/pages/workspace/auto-group/detail";
import AutoGroupForm from "@/pages/workspace/auto-group/form";
import GroupPush from "@/pages/workspace/group-push/list";
import FormGroupPush from "@/pages/workspace/group-push/form";
import DetailGroupPush from "@/pages/workspace/group-push/detail";
import MomentsSync from "@/pages/workspace/moments-sync/MomentsSync";
import MomentsSyncDetail from "@/pages/workspace/moments-sync/Detail";
import NewMomentsSync from "@/pages/workspace/moments-sync/new";
import NewMomentsSync from "@/pages/workspace/moments-sync/new/index";
import AIAssistant from "@/pages/workspace/ai-assistant/AIAssistant";
import TrafficDistribution from "@/pages/workspace/traffic-distribution/TrafficDistribution";
import TrafficDistributionDetail from "@/pages/workspace/traffic-distribution/Detail";
@@ -24,7 +26,7 @@ const workspaceRoutes = [
// 自动点赞
{
path: "/workspace/auto-like",
element: <AutoLike />,
element: <ListAutoLike />,
auth: true,
},
{
@@ -34,7 +36,7 @@ const workspaceRoutes = [
},
{
path: "/workspace/auto-like/:id",
element: <AutoLikeDetail />,
element: <RecordAutoLike />,
auth: true,
},
{
@@ -42,10 +44,15 @@ const workspaceRoutes = [
element: <NewAutoLike />,
auth: true,
},
// 自动分组
// 自动建群
{
path: "/workspace/auto-group",
element: <AutoGroup />,
element: <AutoGroupList />,
auth: true,
},
{
path: "/workspace/auto-group/new",
element: <AutoGroupForm />,
auth: true,
},
{
@@ -53,6 +60,11 @@ const workspaceRoutes = [
element: <AutoGroupDetail />,
auth: true,
},
{
path: "/workspace/auto-group/:id/edit",
element: <AutoGroupForm />,
auth: true,
},
// 群发推送
{
path: "/workspace/group-push",
@@ -60,18 +72,18 @@ const workspaceRoutes = [
auth: true,
},
{
path: "/workspace/group-push/new",
element: <NewGroupPush />,
path: "/workspace/group-push/:id",
element: <DetailGroupPush />,
auth: true,
},
{
path: "/workspace/group-push/:id",
element: <PlaceholderPage title="群发推送详情" />,
path: "/workspace/group-push/new",
element: <FormGroupPush />,
auth: true,
},
{
path: "/workspace/group-push/:id/edit",
element: <PlaceholderPage title="编辑群发推送" />,
element: <FormGroupPush />,
auth: true,
},
// 朋友圈同步

View File

@@ -123,12 +123,44 @@ input, textarea {
body, input, textarea, select, button {
touch-action: manipulation;
}
//导航左右结构的样式
.nav-left {
// 导航栏样式
.nav-title {
font-size: 18px;
font-weight: 600;
color: var(--primary-color);
font-weight: 700;
font-size: 16px;
}
.nav-right {
font-size: 12px;
}
// 搜索相关样式
.search-bar {
display: flex;
gap: 12px;
align-items: center;
padding: 12px;
}
.search-input-wrapper {
position: relative;
flex: 1;
.ant-input {
border-radius: 8px;
}
}
.refresh-btn {
height: 40px;
width: 40px;
padding: 0;
border-radius: 8px;
}
.new-task-btn {
height: 32px;
padding: 0 12px;
border-radius: 6px;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}

View File

@@ -0,0 +1,67 @@
export type DeviceStatus = "online" | "offline" | "busy" | "error";
export interface Device {
id: number | string;
imei: string;
memo?: string;
wechatId?: string;
totalFriend?: number;
alive?: number;
status?: DeviceStatus;
nickname?: string;
battery?: number;
lastActive?: string;
features?: {
autoAddFriend?: boolean;
autoReply?: boolean;
momentsSync?: boolean;
aiChat?: boolean;
};
}
export interface DeviceListResponse {
list: Device[];
total: number;
page: number;
limit: number;
}
export interface DeviceDetailResponse {
id: number | string;
imei: string;
memo?: string;
wechatId?: string;
alive?: number;
totalFriend?: number;
nickname?: string;
battery?: number;
lastActive?: string;
features?: {
autoAddFriend?: boolean;
autoReply?: boolean;
momentsSync?: boolean;
aiChat?: boolean;
};
}
export interface WechatAccount {
id: string;
avatar: string;
nickname: string;
wechatId: string;
gender: number;
status: number;
statusText: string;
wechatAlive: number;
wechatAliveText: string;
addFriendStatus: number;
totalFriend: number;
lastActive: string;
}
export interface HandleLog {
id: string | number;
content: string;
username: string;
createTime: string;
}

View File

@@ -0,0 +1,37 @@
import { Modal } from "antd-mobile";
/**
* 通用js调用弹窗Promise风格
* @param content 弹窗内容
* @param config 配置项title, cancelText, confirmText
* @returns Promise<void>
*/
export const comfirm = (
content: string,
config?: {
title?: string;
cancelText?: string;
confirmText?: string;
}
): Promise<void> => {
return new Promise((resolve, reject) => {
Modal.show({
title: config?.title || "提示",
content,
closeOnAction: true,
actions: [
{
key: "cancel",
text: config?.cancelText || "取消",
onClick: () => reject(),
},
{
key: "confirm",
text: config?.confirmText || "确认",
danger: true,
onClick: () => resolve(),
},
],
});
});
};

View File

@@ -1 +1,11 @@
/// <reference types="vite/client" />
declare module "*.scss" {
const content: { [className: string]: string };
export default content;
}
declare module "*.css" {
const content: { [className: string]: string };
export default content;
}