Merge branch 'yongpxu-dev' into yongpxu-dev4
# Conflicts: # nkebao/.env.development resolved by yongpxu-dev4 version
This commit is contained in:
@@ -51,6 +51,9 @@ class SyncWechatDataToCkbTask extends Command
|
||||
$this->syncWechatDeviceLoginLog($ChuKeBaoAdapter);
|
||||
$this->syncWechatDevice($ChuKeBaoAdapter);
|
||||
$this->syncWechatCustomer($ChuKeBaoAdapter);
|
||||
$this->syncWechatFriendToTrafficPoolBatch($ChuKeBaoAdapter);
|
||||
$this->syncTrafficSourceUser($ChuKeBaoAdapter);
|
||||
$this->syncTrafficSourceGroup($ChuKeBaoAdapter);
|
||||
|
||||
$output->writeln("同步任务 sync_wechat_to_ckb 已结束");
|
||||
return true;
|
||||
@@ -90,4 +93,20 @@ class SyncWechatDataToCkbTask extends Command
|
||||
{
|
||||
return $ChuKeBaoAdapter->syncWechatCustomer();
|
||||
}
|
||||
|
||||
protected function syncWechatFriendToTrafficPoolBatch(ChuKeBaoAdapter $ChuKeBaoAdapter)
|
||||
{
|
||||
return $ChuKeBaoAdapter->syncWechatFriendToTrafficPoolBatch();
|
||||
}
|
||||
protected function syncTrafficSourceUser(ChuKeBaoAdapter $ChuKeBaoAdapter)
|
||||
{
|
||||
return $ChuKeBaoAdapter->syncTrafficSourceUser();
|
||||
}
|
||||
|
||||
protected function syncTrafficSourceGroup(ChuKeBaoAdapter $ChuKeBaoAdapter)
|
||||
{
|
||||
return $ChuKeBaoAdapter->syncTrafficSourceGroup();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -787,6 +787,7 @@ class Adapter implements WeChatServiceInterface
|
||||
FROM s2_wechat_friend f
|
||||
LEFT JOIN s2_wechat_account a on a.id = f.wechatAccountId
|
||||
LEFT JOIN s2_company_account c on c.id = a.deviceAccountId
|
||||
ORDER BY f.id DESC
|
||||
LIMIT ?, ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
id=VALUES(id),
|
||||
@@ -1224,4 +1225,72 @@ class Adapter implements WeChatServiceInterface
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function syncTrafficSourceUser()
|
||||
{
|
||||
$sql = "insert into ck_traffic_source(`identifier`,companyId,`fromd`,`sourceId`,`createTime`)
|
||||
SELECT
|
||||
f.wechatId identifier,
|
||||
c.departmentId companyId,
|
||||
f.ownerNickname fromd,
|
||||
f.ownerWechatId sourceId,
|
||||
f.createTime createTime
|
||||
FROM
|
||||
s2_wechat_friend f
|
||||
LEFT JOIN s2_wechat_account a ON f.wechatAccountId = a.id
|
||||
LEFT JOIN s2_company_account c on c.id = a.deviceAccountId
|
||||
ORDER BY f.id DESC
|
||||
LIMIT ?, ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
identifier=VALUES(identifier),
|
||||
companyId=VALUES(companyId),
|
||||
sourceId=VALUES(sourceId)";
|
||||
|
||||
|
||||
$offset = 0;
|
||||
$limit = 2000;
|
||||
$usleepTime = 50000;
|
||||
do {
|
||||
$affected = Db::execute($sql, [$offset, $limit]);
|
||||
$offset += $limit;
|
||||
if ($affected > 0) {
|
||||
usleep($usleepTime);
|
||||
}
|
||||
} while ($affected > 0);
|
||||
}
|
||||
|
||||
public function syncTrafficSourceGroup()
|
||||
{
|
||||
$sql = "insert into ck_traffic_source(`identifier`,companyId,`fromd`,`sourceId`,`createTime`)
|
||||
SELECT
|
||||
m.wechatId identifier,
|
||||
c.departmentId companyId,
|
||||
r.nickname fromd,
|
||||
m.chatroomId sourceId,
|
||||
m.createTime createTime
|
||||
FROM
|
||||
s2_wechat_chatroom_member m
|
||||
JOIN s2_wechat_chatroom r ON m.chatroomId = r.chatroomId
|
||||
LEFT JOIN s2_wechat_account a ON a.id = r.wechatAccountId
|
||||
LEFT JOIN s2_company_account c on c.id = a.deviceAccountId
|
||||
GROUP BY m.wechatId
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?, ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
identifier=VALUES(identifier),
|
||||
companyId=VALUES(companyId),
|
||||
sourceId=VALUES(sourceId)";
|
||||
|
||||
|
||||
$offset = 0;
|
||||
$limit = 2000;
|
||||
$usleepTime = 50000;
|
||||
do {
|
||||
$affected = Db::execute($sql, [$offset, $limit]);
|
||||
$offset += $limit;
|
||||
if ($affected > 0) {
|
||||
usleep($usleepTime);
|
||||
}
|
||||
} while ($affected > 0);
|
||||
}
|
||||
}
|
||||
|
||||
8
nkebao/.vite/deps/_metadata.json
Normal file
8
nkebao/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "efe0acf4",
|
||||
"configHash": "2bed34b3",
|
||||
"lockfileHash": "ef01d341",
|
||||
"browserHash": "91bd3b2c",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
nkebao/.vite/deps/package.json
Normal file
3
nkebao/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
33
nkebao/src/api/common.ts
Normal file
33
nkebao/src/api/common.ts
Normal 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
44
nkebao/src/api/devices.ts
Normal 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");
|
||||
@@ -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;
|
||||
|
||||
10
nkebao/src/components/DeviceSelection/api.ts
Normal file
10
nkebao/src/components/DeviceSelection/api.ts
Normal 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");
|
||||
}
|
||||
26
nkebao/src/components/DeviceSelection/data.ts
Normal file
26
nkebao/src/components/DeviceSelection/data.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// 设备选择项接口
|
||||
export interface DeviceSelectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
wxid?: string;
|
||||
nickname?: string;
|
||||
usedInPlans?: number;
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
export 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; // 新增
|
||||
}
|
||||
@@ -19,12 +19,7 @@
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
@@ -154,3 +149,35 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,208 +1,139 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Input, Button, Checkbox, Popup, Toast } from "antd-mobile";
|
||||
import request from "@/api/request";
|
||||
import style from "./module.scss";
|
||||
|
||||
// 设备选择项接口
|
||||
interface DeviceSelectionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wechatId: string;
|
||||
status: "online" | "offline";
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
interface DeviceSelectionProps {
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DeviceSelection({
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
placeholder = "选择设备",
|
||||
className = "",
|
||||
}: DeviceSelectionProps) {
|
||||
const [popupVisible, setPopupVisible] = useState(false);
|
||||
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取设备列表,支持keyword
|
||||
const fetchDevices = async (keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request(
|
||||
"/v1/device/list",
|
||||
{
|
||||
page: 1,
|
||||
limit: 100,
|
||||
keyword: keyword.trim() || undefined,
|
||||
},
|
||||
"GET"
|
||||
);
|
||||
|
||||
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",
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
Toast.show({ content: "获取设备列表失败", position: "top" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开弹窗时获取设备列表
|
||||
const openPopup = () => {
|
||||
setSearchQuery("");
|
||||
setPopupVisible(true);
|
||||
fetchDevices("");
|
||||
};
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!popupVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
fetchDevices(searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, popupVisible]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
// 处理设备选择
|
||||
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} 个设备`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<SearchOutlined className={style.inputIcon} />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className={style.input}
|
||||
readOnly
|
||||
onClick={openPopup}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 设备选择弹窗 */}
|
||||
<Popup
|
||||
visible={popupVisible}
|
||||
onMaskClick={() => setPopupVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "80vh" }}
|
||||
>
|
||||
<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>
|
||||
</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.popupFooter}>
|
||||
<div className={style.selectedCount}>
|
||||
已选择 {selectedDevices.length} 个设备
|
||||
</div>
|
||||
<div className={style.footerBtnGroup}>
|
||||
<Button fill="outline" onClick={() => setPopupVisible(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" onClick={() => setPopupVisible(false)}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import React, { useState } from "react";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { DeviceSelectionProps } from "./data";
|
||||
import SelectionPopup from "./selectionPopup";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
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 openPopup = () => {
|
||||
if (readonly) return;
|
||||
setRealVisible(true);
|
||||
};
|
||||
|
||||
// 获取显示文本
|
||||
const getDisplayText = () => {
|
||||
if (selectedDevices.length === 0) return "";
|
||||
return `已选择 ${selectedDevices.length} 个设备`;
|
||||
};
|
||||
|
||||
// 删除已选设备
|
||||
const handleRemoveDevice = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedDevices.filter((d) => d !== id));
|
||||
};
|
||||
|
||||
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 && selectedDevices.length > 0 && (
|
||||
<div
|
||||
className={style.selectedListWindow}
|
||||
style={{
|
||||
maxHeight: selectedListMaxHeight,
|
||||
overflowY: "auto",
|
||||
marginTop: 8,
|
||||
border: "1px solid #e5e6eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{selectedDevices.map((deviceId) => (
|
||||
<div
|
||||
key={deviceId}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{deviceId}
|
||||
</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(deviceId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<SelectionPopup
|
||||
visible={realVisible && !readonly}
|
||||
onClose={() => setRealVisible(false)}
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceSelection;
|
||||
|
||||
207
nkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
207
nkebao/src/components/DeviceSelection/selectionPopup.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Checkbox, Popup } from "antd-mobile";
|
||||
import { getDeviceList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const SelectionPopup: React.FC<SelectionPopupProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
}) => {
|
||||
// 设备数据
|
||||
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);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 打开弹窗时获取第一页
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchDevices("", 1);
|
||||
}
|
||||
}, [visible, fetchDevices]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchDevices(searchQuery, 1);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, visible, fetchDevices]);
|
||||
|
||||
// 翻页时重新请求
|
||||
useEffect(() => {
|
||||
if (!visible) 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]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
closeOnMaskClick={false}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择设备"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索设备IMEI/备注/微信号"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchDevices(searchQuery, currentPage)}
|
||||
showTabs={true}
|
||||
tabsConfig={{
|
||||
activeKey: statusFilter,
|
||||
onChange: setStatusFilter,
|
||||
tabs: [
|
||||
{ title: "全部", key: "all" },
|
||||
{ title: "在线", key: "online" },
|
||||
{ title: "离线", key: "offline" },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={total}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedDevices.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={onClose}
|
||||
onConfirm={onClose}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Layout>
|
||||
</Popup>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectionPopup;
|
||||
@@ -1,216 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Input, Button, Checkbox, Popup, Toast } from "antd-mobile";
|
||||
import request from "@/api/request";
|
||||
import style from "./module.scss";
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
imei: string;
|
||||
wxid: string;
|
||||
status: "online" | "offline";
|
||||
usedInPlans: number;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface DeviceSelectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedDevices: string[];
|
||||
onSelect: (devices: string[]) => void;
|
||||
}
|
||||
|
||||
export function DeviceSelectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedDevices,
|
||||
onSelect,
|
||||
}: DeviceSelectionDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
|
||||
// 获取设备列表,支持keyword
|
||||
const fetchDevices = useCallback(async (keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await request(
|
||||
"/v1/device/list",
|
||||
{
|
||||
page: 1,
|
||||
limit: 100,
|
||||
keyword: keyword.trim() || undefined,
|
||||
},
|
||||
"GET"
|
||||
);
|
||||
|
||||
if (response && Array.isArray(response.list)) {
|
||||
// 转换服务端数据格式为组件需要的格式
|
||||
const convertedDevices: Device[] = response.list.map(
|
||||
(serverDevice: any) => ({
|
||||
id: serverDevice.id.toString(),
|
||||
name: serverDevice.memo || `设备 ${serverDevice.id}`,
|
||||
imei: serverDevice.imei,
|
||||
wxid: serverDevice.wechatId || "",
|
||||
status: serverDevice.alive === 1 ? "online" : "offline",
|
||||
usedInPlans: 0, // 这个字段需要从其他API获取
|
||||
nickname: serverDevice.nickname || "",
|
||||
})
|
||||
);
|
||||
setDevices(convertedDevices);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败:", error);
|
||||
Toast.show({
|
||||
content: "获取设备列表失败,请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 打开弹窗时获取设备列表
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchDevices("");
|
||||
}
|
||||
}, [open, fetchDevices]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setTimeout(() => {
|
||||
fetchDevices(searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, open, fetchDevices]);
|
||||
|
||||
// 过滤设备(只保留状态过滤)
|
||||
const filteredDevices = devices.filter((device) => {
|
||||
const matchesStatus =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "online" && device.status === "online") ||
|
||||
(statusFilter === "offline" && device.status === "offline");
|
||||
return matchesStatus;
|
||||
});
|
||||
|
||||
const handleDeviceSelect = (deviceId: string) => {
|
||||
if (selectedDevices.includes(deviceId)) {
|
||||
onSelect(selectedDevices.filter((id) => id !== deviceId));
|
||||
} else {
|
||||
onSelect([...selectedDevices, deviceId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={open}
|
||||
onMaskClick={() => onOpenChange(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "80vh" }}
|
||||
>
|
||||
<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)}
|
||||
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>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div className={style.emptyBox}>
|
||||
<div className={style.emptyText}>暂无数据</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={style.deviceListInner}>
|
||||
{filteredDevices.map((device) => (
|
||||
<label key={device.id} className={style.deviceItem}>
|
||||
<Checkbox
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={() => handleDeviceSelect(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.wxid || "-"}</div>
|
||||
<div>昵称: {device.nickname || "-"}</div>
|
||||
</div>
|
||||
{device.usedInPlans > 0 && (
|
||||
<div className={style.usedInPlans}>
|
||||
已用于 {device.usedInPlans} 个计划
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.popupFooter}>
|
||||
<div className={style.selectedCount}>
|
||||
已选择 {selectedDevices.length} 个设备
|
||||
</div>
|
||||
<div className={style.footerBtnGroup}>
|
||||
<Button fill="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" onClick={() => onOpenChange(false)}>
|
||||
确定
|
||||
{selectedDevices.length > 0 ? ` (${selectedDevices.length})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.popupTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.popupSearchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 16px;
|
||||
}
|
||||
.popupSearchInput {
|
||||
padding-left: 36px !important;
|
||||
border-radius: 12px !important;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
border: 1px solid #e5e6eb !important;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.statusSelect {
|
||||
width: 128px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e6eb;
|
||||
font-size: 14px;
|
||||
padding: 0 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.refreshBtn {
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.loadingIcon {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 16px;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.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 {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.statusOffline {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
background: #e5e6eb;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.deviceInfoDetail {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.usedInPlans {
|
||||
font-size: 13px;
|
||||
color: #fa8c16;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
11
nkebao/src/components/FriendSelection/api.ts
Normal file
11
nkebao/src/components/FriendSelection/api.ts
Normal 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");
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
@@ -205,13 +205,21 @@
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
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;
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
SearchOutlined,
|
||||
CloseOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Input, Button, Popup, Toast } from "antd-mobile";
|
||||
import request from "@/api/request";
|
||||
import style from "./module.scss";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Popup } from "antd-mobile";
|
||||
import { Button, Input } from "antd";
|
||||
import { getFriendList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
|
||||
// 微信好友接口类型
|
||||
interface WechatFriend {
|
||||
@@ -27,6 +26,13 @@ interface FriendSelectionProps {
|
||||
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({
|
||||
@@ -37,6 +43,13 @@ export default function FriendSelection({
|
||||
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[]>([]);
|
||||
@@ -46,24 +59,32 @@ export default function FriendSelection({
|
||||
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("");
|
||||
setPopupVisible(true);
|
||||
setRealVisible(true);
|
||||
fetchFriends(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (popupVisible && currentPage !== 1) {
|
||||
if (realVisible && currentPage !== 1) {
|
||||
fetchFriends(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, popupVisible, searchQuery]);
|
||||
}, [currentPage, realVisible, searchQuery]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!popupVisible) return;
|
||||
if (!realVisible) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
@@ -71,7 +92,7 @@ export default function FriendSelection({
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, popupVisible]);
|
||||
}, [searchQuery, realVisible]);
|
||||
|
||||
// 获取好友列表API - 添加 keyword 参数
|
||||
const fetchFriends = async (page: number, keyword: string = "") => {
|
||||
@@ -86,35 +107,18 @@ export default function FriendSelection({
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
|
||||
if (enableDeviceFilter) {
|
||||
if (deviceIds.length === 0) {
|
||||
setFriends([]);
|
||||
setTotalFriends(0);
|
||||
setTotalPages(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
params.deviceIds = deviceIds.join(",");
|
||||
if (enableDeviceFilter && deviceIds.length > 0) {
|
||||
params.deviceIds = deviceIds;
|
||||
}
|
||||
|
||||
const res = await request("/v1/friend", params, "GET");
|
||||
|
||||
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));
|
||||
const response = await getFriendList(params);
|
||||
if (response && response.list) {
|
||||
setFriends(response.list);
|
||||
setTotalFriends(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取好友列表失败:", error);
|
||||
Toast.show({ content: "获取好友列表失败", position: "top" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -122,16 +126,20 @@ export default function FriendSelection({
|
||||
|
||||
// 处理好友选择
|
||||
const handleFriendToggle = (friendId: string) => {
|
||||
let newIds: string[];
|
||||
if (selectedFriends.includes(friendId)) {
|
||||
newIds = selectedFriends.filter((id) => id !== friendId);
|
||||
} else {
|
||||
newIds = [...selectedFriends, friendId];
|
||||
}
|
||||
onSelect(newIds);
|
||||
if (readonly) return;
|
||||
|
||||
const newSelectedFriends = selectedFriends.includes(friendId)
|
||||
? selectedFriends.filter((id) => id !== friendId)
|
||||
: [...selectedFriends, friendId];
|
||||
|
||||
onSelect(newSelectedFriends);
|
||||
|
||||
// 如果有 onSelectDetail 回调,传递完整的好友对象
|
||||
if (onSelectDetail) {
|
||||
const selectedObjs = friends.filter((f) => newIds.includes(f.id));
|
||||
onSelectDetail(selectedObjs);
|
||||
const selectedFriendObjs = friends.filter((friend) =>
|
||||
newSelectedFriends.includes(friend.id)
|
||||
);
|
||||
onSelectDetail(selectedFriendObjs);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,77 +149,135 @@ export default function FriendSelection({
|
||||
return `已选择 ${selectedFriends.length} 个好友`;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setPopupVisible(false);
|
||||
// 获取已选好友详细信息
|
||||
const selectedFriendObjs = friends.filter((friend) =>
|
||||
selectedFriends.includes(friend.id)
|
||||
);
|
||||
|
||||
// 删除已选好友
|
||||
const handleRemoveFriend = (id: string) => {
|
||||
if (readonly) return;
|
||||
onSelect(selectedFriends.filter((d) => d !== id));
|
||||
};
|
||||
|
||||
// 清空搜索
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchFriends(1, "");
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(selectedFriends, selectedFriendObjs);
|
||||
}
|
||||
setRealVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<span className={style.inputIcon}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className={style.input}
|
||||
readOnly
|
||||
onClick={openPopup}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 微信好友选择弹窗 */}
|
||||
<Popup
|
||||
visible={popupVisible}
|
||||
onMaskClick={() => setPopupVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "80vh" }}
|
||||
>
|
||||
<div className={style.popupContainer}>
|
||||
<div className={style.popupHeader}>
|
||||
<div className={style.popupTitle}>选择微信好友</div>
|
||||
<div className={style.searchWrapper}>
|
||||
<Input
|
||||
placeholder="搜索好友"
|
||||
value={searchQuery}
|
||||
onChange={(val) => setSearchQuery(val)}
|
||||
className={style.searchInput}
|
||||
/>
|
||||
<SearchOutlined className={style.searchIcon} />
|
||||
{searchQuery && (
|
||||
{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
|
||||
fill="none"
|
||||
size="mini"
|
||||
className={style.clearBtn}
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</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>
|
||||
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<Popup
|
||||
visible={realVisible && !readonly}
|
||||
onMaskClick={() => setRealVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择微信好友"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索好友"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchFriends(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalFriends}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedFriends.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => setRealVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.friendList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
@@ -223,7 +289,7 @@ export default function FriendSelection({
|
||||
<label
|
||||
key={friend.id}
|
||||
className={style.friendItem}
|
||||
onClick={() => handleFriendToggle(friend.id)}
|
||||
onClick={() => !readonly && handleFriendToggle(friend.id)}
|
||||
>
|
||||
<div className={style.radioWrapper}>
|
||||
<div
|
||||
@@ -279,53 +345,7 @@ export default function FriendSelection({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.paginationRow}>
|
||||
<div className={style.totalCount}>总计 {totalFriends} 个好友</div>
|
||||
<div className={style.paginationControls}>
|
||||
<Button
|
||||
fill="none"
|
||||
size="mini"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<LeftOutlined />
|
||||
</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}
|
||||
>
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.popupFooter}>
|
||||
<Button
|
||||
fill="outline"
|
||||
onClick={() => setPopupVisible(false)}
|
||||
className={style.cancelBtn}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleConfirm}
|
||||
className={style.confirmBtn}
|
||||
>
|
||||
确定 ({selectedFriends.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
|
||||
10
nkebao/src/components/GroupSelection/api.ts
Normal file
10
nkebao/src/components/GroupSelection/api.ts
Normal 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");
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
.popupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
.popupHeader {
|
||||
@@ -205,19 +205,18 @@
|
||||
}
|
||||
|
||||
.popupFooter {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
}
|
||||
.cancelBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e5e6eb;
|
||||
.selectedCount {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
.confirmBtn {
|
||||
padding: 0 24px;
|
||||
border-radius: 24px;
|
||||
.footerBtnGroup {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
SearchOutlined,
|
||||
CloseOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Input, Button, Popup, Toast } from "antd-mobile";
|
||||
import request from "@/api/request";
|
||||
import style from "./module.scss";
|
||||
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Input } from "antd";
|
||||
import { Popup } from "antd-mobile";
|
||||
import { getGroupList } from "./api";
|
||||
import style from "./index.module.scss";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import PopupHeader from "@/components/PopuLayout/header";
|
||||
import PopupFooter from "@/components/PopuLayout/footer";
|
||||
|
||||
// 群组接口类型
|
||||
interface WechatGroup {
|
||||
@@ -27,6 +26,13 @@ interface GroupSelectionProps {
|
||||
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({
|
||||
@@ -35,6 +41,13 @@ export default function GroupSelection({
|
||||
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[]>([]);
|
||||
@@ -44,80 +57,93 @@ export default function GroupSelection({
|
||||
const [totalGroups, setTotalGroups] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 打开弹窗并请求第一页群组
|
||||
// 获取已选群聊详细信息
|
||||
const selectedGroupObjs = groups.filter((group) =>
|
||||
selectedGroups.includes(group.id)
|
||||
);
|
||||
|
||||
// 删除已选群聊
|
||||
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("");
|
||||
setPopupVisible(true);
|
||||
setRealVisible(true);
|
||||
fetchGroups(1, "");
|
||||
};
|
||||
|
||||
// 当页码变化时,拉取对应页数据(弹窗已打开时)
|
||||
useEffect(() => {
|
||||
if (popupVisible && currentPage !== 1) {
|
||||
if (realVisible && currentPage !== 1) {
|
||||
fetchGroups(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, popupVisible, searchQuery]);
|
||||
}, [currentPage, realVisible, searchQuery]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
if (!popupVisible) return;
|
||||
if (!realVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, searchQuery);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, popupVisible]);
|
||||
|
||||
// 获取群组列表API - 支持keyword
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, realVisible]);
|
||||
|
||||
// 获取群聊列表API
|
||||
const fetchGroups = async (page: number, keyword: string = "") => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
let params: any = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
if (keyword.trim()) {
|
||||
params.keyword = keyword.trim();
|
||||
}
|
||||
|
||||
const res = await request("/v1/chatroom", params, "GET");
|
||||
|
||||
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));
|
||||
const response = await getGroupList(params);
|
||||
if (response && response.list) {
|
||||
setGroups(response.list);
|
||||
setTotalGroups(response.total || 0);
|
||||
setTotalPages(Math.ceil((response.total || 0) / 20));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取群组列表失败:", error);
|
||||
Toast.show({ content: "获取群组列表失败", position: "top" });
|
||||
console.error("获取群聊列表失败:", error);
|
||||
} 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 (readonly) return;
|
||||
|
||||
const newSelectedGroups = selectedGroups.includes(groupId)
|
||||
? selectedGroups.filter((id) => id !== groupId)
|
||||
: [...selectedGroups, groupId];
|
||||
|
||||
onSelect(newSelectedGroups);
|
||||
|
||||
// 如果有 onSelectDetail 回调,传递完整的群聊对象
|
||||
if (onSelectDetail) {
|
||||
const selectedObjs = groups.filter((g) => newIds.includes(g.id));
|
||||
onSelectDetail(selectedObjs);
|
||||
const selectedGroupObjs = groups.filter((group) =>
|
||||
newSelectedGroups.includes(group.id)
|
||||
);
|
||||
onSelectDetail(selectedGroupObjs);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,77 +153,124 @@ export default function GroupSelection({
|
||||
return `已选择 ${selectedGroups.length} 个群聊`;
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
setPopupVisible(false);
|
||||
};
|
||||
|
||||
// 清空搜索
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery("");
|
||||
setCurrentPage(1);
|
||||
fetchGroups(1, "");
|
||||
if (onConfirm) {
|
||||
onConfirm(selectedGroups, selectedGroupObjs);
|
||||
}
|
||||
setRealVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 输入框 */}
|
||||
<div className={`${style.inputWrapper} ${className}`}>
|
||||
<span className={style.inputIcon}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
className={style.input}
|
||||
readOnly
|
||||
onClick={openPopup}
|
||||
value={getDisplayText()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 群组选择弹窗 */}
|
||||
<Popup
|
||||
visible={popupVisible}
|
||||
onMaskClick={() => setPopupVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "80vh" }}
|
||||
>
|
||||
<div className={style.popupContainer}>
|
||||
<div className={style.popupHeader}>
|
||||
<div className={style.popupTitle}>选择群聊</div>
|
||||
<div className={style.searchWrapper}>
|
||||
<Input
|
||||
placeholder="搜索群聊"
|
||||
value={searchQuery}
|
||||
onChange={(val) => setSearchQuery(val)}
|
||||
className={style.searchInput}
|
||||
/>
|
||||
<SearchOutlined className={style.searchIcon} />
|
||||
{searchQuery && (
|
||||
{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 && 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 && (
|
||||
<Button
|
||||
fill="none"
|
||||
size="mini"
|
||||
className={style.clearBtn}
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</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={() => handleRemoveGroup(group.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 弹窗 */}
|
||||
<Popup
|
||||
visible={realVisible && !readonly}
|
||||
onMaskClick={() => setRealVisible(false)}
|
||||
position="bottom"
|
||||
bodyStyle={{ height: "100vh" }}
|
||||
>
|
||||
<Layout
|
||||
header={
|
||||
<PopupHeader
|
||||
title="选择群聊"
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
searchPlaceholder="搜索群聊"
|
||||
loading={loading}
|
||||
onRefresh={() => fetchGroups(currentPage, searchQuery)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<PopupFooter
|
||||
total={totalGroups}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
selectedCount={selectedGroups.length}
|
||||
onPageChange={setCurrentPage}
|
||||
onCancel={() => setRealVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={style.groupList}>
|
||||
{loading ? (
|
||||
<div className={style.loadingBox}>
|
||||
@@ -209,7 +282,7 @@ export default function GroupSelection({
|
||||
<label
|
||||
key={group.id}
|
||||
className={style.groupItem}
|
||||
onClick={() => handleGroupToggle(group.id)}
|
||||
onClick={() => !readonly && handleGroupToggle(group.id)}
|
||||
>
|
||||
<div className={style.radioWrapper}>
|
||||
<div
|
||||
@@ -261,56 +334,7 @@ export default function GroupSelection({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.paginationRow}>
|
||||
<div className={style.totalCount}>
|
||||
总计 {totalGroups} 个群聊
|
||||
{searchQuery && ` (搜索: "${searchQuery}")`}
|
||||
</div>
|
||||
<div className={style.paginationControls}>
|
||||
<Button
|
||||
fill="none"
|
||||
size="mini"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<LeftOutlined />
|
||||
</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}
|
||||
>
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.popupFooter}>
|
||||
<Button
|
||||
fill="outline"
|
||||
onClick={() => setPopupVisible(false)}
|
||||
className={style.cancelBtn}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleConfirm}
|
||||
className={style.confirmBtn}
|
||||
>
|
||||
确定 ({selectedGroups.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
|
||||
41
nkebao/src/components/NavCommon/index.tsx
Normal file
41
nkebao/src/components/NavCommon/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { NavBar } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface NavCommonProps {
|
||||
title: string;
|
||||
backFn?: () => void;
|
||||
right?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavCommon: React.FC<NavCommonProps> = ({ title, backFn, right }) => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<NavBar
|
||||
back={null}
|
||||
style={{ background: "#fff" }}
|
||||
left={
|
||||
<div className="nav-title">
|
||||
<ArrowLeftOutlined
|
||||
twoToneColor="#1677ff"
|
||||
onClick={() => {
|
||||
if (backFn) {
|
||||
backFn();
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
right={right}
|
||||
>
|
||||
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||
{title}
|
||||
</span>
|
||||
</NavBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavCommon;
|
||||
71
nkebao/src/components/PopuLayout/footer.module.scss
Normal file
71
nkebao/src/components/PopuLayout/footer.module.scss
Normal file
@@ -0,0 +1,71 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #f5f5f5;
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
margin: 0 8px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
67
nkebao/src/components/PopuLayout/footer.tsx
Normal file
67
nkebao/src/components/PopuLayout/footer.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { Button } from "antd";
|
||||
import style from "./footer.module.scss";
|
||||
import { ArrowLeftOutlined, ArrowRightOutlined } from "@ant-design/icons";
|
||||
|
||||
interface PopupFooterProps {
|
||||
total: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
loading: boolean;
|
||||
selectedCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const PopupFooter: React.FC<PopupFooterProps> = ({
|
||||
total,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
selectedCount,
|
||||
onPageChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 分页栏 */}
|
||||
<div className={style.paginationRow}>
|
||||
<div className={style.totalCount}>总计 {total} 个设备</div>
|
||||
<div className={style.paginationControls}>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
</Button>
|
||||
<span className={style.pageInfo}>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
className={style.pageBtn}
|
||||
>
|
||||
<ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.popupFooter}>
|
||||
<div className={style.selectedCount}>已选择 {selectedCount} 个设备</div>
|
||||
<div className={style.footerBtnGroup}>
|
||||
<Button color="primary" variant="filled" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" onClick={onConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupFooter;
|
||||
52
nkebao/src/components/PopuLayout/header.module.scss
Normal file
52
nkebao/src/components/PopuLayout/header.module.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
.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: 5px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.popupSearchInputWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #bdbdbd;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
|
||||
.refreshBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.loadingIcon {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
86
nkebao/src/components/PopuLayout/header.tsx
Normal file
86
nkebao/src/components/PopuLayout/header.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { Input, Button } from "antd";
|
||||
import { Tabs } from "antd-mobile";
|
||||
import style from "./header.module.scss";
|
||||
|
||||
interface PopupHeaderProps {
|
||||
title: string;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
loading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
showRefresh?: boolean;
|
||||
showSearch?: boolean;
|
||||
showTabs?: boolean;
|
||||
tabsConfig?: {
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
tabs: Array<{ title: string; key: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const PopupHeader: React.FC<PopupHeaderProps> = ({
|
||||
title,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchPlaceholder = "搜索...",
|
||||
loading = false,
|
||||
onRefresh,
|
||||
showRefresh = true,
|
||||
showSearch = true,
|
||||
showTabs = false,
|
||||
tabsConfig,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={style.popupHeader}>
|
||||
<div className={style.popupTitle}>{title}</div>
|
||||
</div>
|
||||
|
||||
{showSearch && (
|
||||
<div className={style.popupSearchRow}>
|
||||
<div className={style.popupSearchInputWrap}>
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showRefresh && onRefresh && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className={style.refreshBtn}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={style.loadingIcon}>⟳</div>
|
||||
) : (
|
||||
<ReloadOutlined />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTabs && tabsConfig && (
|
||||
<Tabs
|
||||
activeKey={tabsConfig.activeKey}
|
||||
onChange={tabsConfig.onChange}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{tabsConfig.tabs.map((tab) => (
|
||||
<Tabs.Tab key={tab.key} title={tab.title} />
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupHeader;
|
||||
67
nkebao/src/components/SelectionTest.tsx
Normal file
67
nkebao/src/components/SelectionTest.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
108
nkebao/src/pages/component-test/index.tsx
Normal file
108
nkebao/src/pages/component-test/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from "react";
|
||||
import { NavBar, Tabs } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
|
||||
const ComponentTest: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState("devices");
|
||||
|
||||
// 设备选择状态
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
// 好友选择状态
|
||||
const [selectedFriends, setSelectedFriends] = useState<string[]>([]);
|
||||
|
||||
// 群组选择状态
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="组件调试" />}>
|
||||
<div style={{ padding: 16 }}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.Tab title="设备选择" key="devices">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>DeviceSelection 组件测试</h3>
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={setSelectedDevices}
|
||||
placeholder="请选择设备"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选设备:</strong> {selectedDevices.length} 个
|
||||
<br />
|
||||
<strong>设备ID:</strong> {selectedDevices.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="好友选择" key="friends">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>FriendSelection 组件测试</h3>
|
||||
<FriendSelection
|
||||
selectedFriends={selectedFriends}
|
||||
onSelect={setSelectedFriends}
|
||||
placeholder="请选择微信好友"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选好友:</strong> {selectedFriends.length} 个
|
||||
<br />
|
||||
<strong>好友ID:</strong> {selectedFriends.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="群组选择" key="groups">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
<h3 style={{ marginBottom: 16 }}>GroupSelection 组件测试</h3>
|
||||
<GroupSelection
|
||||
selectedGroups={selectedGroups}
|
||||
onSelect={setSelectedGroups}
|
||||
placeholder="请选择微信群组"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={300}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: "#f5f5f5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<strong>已选群组:</strong> {selectedGroups.length} 个
|
||||
<br />
|
||||
<strong>群组ID:</strong> {selectedGroups.join(", ") || "无"}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentTest;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
|
||||
|
||||
53
nkebao/src/pages/scenarios/plan/new/index.api.ts
Normal file
53
nkebao/src/pages/scenarios/plan/new/index.api.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import request from "@/api/request";
|
||||
// 获取场景类型列表
|
||||
export function getScenarioTypes() {
|
||||
return request("/v1/plan/scenes", 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);
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
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 { message } from "antd";
|
||||
import { NavBar } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
|
||||
import BasicSettings from "./steps/BasicSettings";
|
||||
import FriendRequestSettings from "./steps/FriendRequestSettings";
|
||||
import MessageSettings from "./steps/MessageSettings";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import {
|
||||
getScenarioTypes,
|
||||
createPlan,
|
||||
updatePlan,
|
||||
getPlanDetail,
|
||||
} from "./page.api";
|
||||
import style from "./page.module.scss";
|
||||
updatePlan,
|
||||
} from "./index.api";
|
||||
|
||||
// 步骤定义
|
||||
// 步骤定义 - 只保留三个步骤
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
@@ -26,7 +26,7 @@ const steps = [
|
||||
interface FormData {
|
||||
name: string;
|
||||
scenario: number;
|
||||
posters: any[];
|
||||
posters: any[]; // 后续可替换为具体Poster类型
|
||||
device: string[];
|
||||
remarkType: string;
|
||||
greeting: string;
|
||||
@@ -39,8 +39,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 +64,50 @@ 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);
|
||||
}
|
||||
//获取场景类型
|
||||
getScenarioTypes()
|
||||
.then((data) => {
|
||||
setSceneList(data || []);
|
||||
})
|
||||
.catch((err) => {
|
||||
message.error(err.message || "获取场景类型失败");
|
||||
})
|
||||
.finally(() => setSceneLoading(false));
|
||||
if (planId) {
|
||||
setIsEdit(true);
|
||||
//获取计划详情
|
||||
|
||||
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) {
|
||||
const detail = await getPlanDetail(planId);
|
||||
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,
|
||||
tips: detail.tips ?? "",
|
||||
}));
|
||||
} else {
|
||||
if (scenarioId) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
scenario: Number(scenarioId) || 1,
|
||||
...{ scenario: Number(scenarioId) || 1 },
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
content: "加载数据失败",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setSceneLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,52 +118,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);
|
||||
} 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",
|
||||
});
|
||||
}
|
||||
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 +170,7 @@ const NewPlan: React.FC = () => {
|
||||
case 1:
|
||||
return (
|
||||
<BasicSettings
|
||||
isEdit={isEdit}
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleNext}
|
||||
@@ -215,9 +192,8 @@ const NewPlan: React.FC = () => {
|
||||
<MessageSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleNext}
|
||||
onNext={handleSave}
|
||||
onPrev={handlePrev}
|
||||
saving={saving}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@@ -225,25 +201,6 @@ const NewPlan: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (sceneLoading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavBar back={null} style={{ background: "#fff" }}>
|
||||
<div className={style["nav-title"]}>
|
||||
{isEdit ? "编辑计划" : "新建计划"}
|
||||
</div>
|
||||
</NavBar>
|
||||
}
|
||||
>
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
<div className={style["loading-text"]}>加载数据中...</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
@@ -255,37 +212,22 @@ const NewPlan: React.FC = () => {
|
||||
<div className="nav-title">
|
||||
<ArrowLeftOutlined
|
||||
twoToneColor="#1677ff"
|
||||
onClick={() => navigate(-1)}
|
||||
onClick={() => router(-1)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className="nav-title">
|
||||
{isEdit ? "编辑计划" : "新建计划"}
|
||||
{isEdit ? "编辑朋友圈同步" : "新建朋友圈同步"}
|
||||
</span>
|
||||
</NavBar>
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<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 className="px-4 py-6">
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style["new-plan-page"]}>
|
||||
{/* 步骤内容 */}
|
||||
<div className={style["step-content"]}>{renderStepContent()}</div>
|
||||
</div>
|
||||
{renderStepContent()}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewPlan;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
.new-plan-page {
|
||||
}
|
||||
|
||||
.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: #ffffff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
padding: 0 16px;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -178,7 +178,7 @@ const AutoLike: React.FC = () => {
|
||||
|
||||
// 查看任务
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/auto-like/detail/${taskId}`);
|
||||
navigate(`/workspace/auto-like/record/${taskId}`);
|
||||
};
|
||||
|
||||
// 复制任务
|
||||
|
||||
552
nkebao/src/pages/workspace/auto-like/new/NewAutoLike.tsx
Normal file
552
nkebao/src/pages/workspace/auto-like/new/NewAutoLike.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronLeft,Plus, Minus, Check, X, Tag as TagIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { createAutoLikeTask, updateAutoLikeTask, fetchAutoLikeTaskDetail } from '@/api/autoLike';
|
||||
import { ContentType } from '@/types/auto-like';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import DeviceSelection from '@/components/DeviceSelection';
|
||||
import FriendSelection from '@/components/FriendSelection';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 修改CreateLikeTaskData接口,确保friends字段不是可选的
|
||||
interface CreateLikeTaskDataLocal {
|
||||
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 default function NewAutoLike() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const { toast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||
const [formData, setFormData] = useState<CreateLikeTaskDataLocal>({
|
||||
name: '',
|
||||
interval: 5,
|
||||
maxLikes: 200,
|
||||
startTime: '08:00',
|
||||
endTime: '22:00',
|
||||
contentTypes: ['text', 'image', 'video'],
|
||||
devices: [],
|
||||
friends: [], // 确保初始化为空数组而不是undefined
|
||||
targetTags: [],
|
||||
friendMaxLikes: 10,
|
||||
enableFriendTags: false,
|
||||
friendTags: '',
|
||||
});
|
||||
// 新增自动开启的独立状态
|
||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||
|
||||
// 如果是编辑模式,获取任务详情
|
||||
useEffect(() => {
|
||||
if (isEditMode && id) {
|
||||
fetchTaskDetail();
|
||||
}
|
||||
}, [id, isEditMode]);
|
||||
|
||||
// 获取任务详情
|
||||
const fetchTaskDetail = async () => {
|
||||
try {
|
||||
const taskDetail = await fetchAutoLikeTaskDetail(id!);
|
||||
console.log('Task detail response:', taskDetail); // 添加日志用于调试
|
||||
|
||||
if (taskDetail) {
|
||||
// 使用类型断言处理可能的字段名称差异
|
||||
const taskAny = taskDetail as any;
|
||||
// 处理可能的嵌套结构
|
||||
const config = taskAny.config || taskAny;
|
||||
|
||||
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 || '',
|
||||
});
|
||||
|
||||
// 处理状态字段,使用双等号允许类型自动转换
|
||||
const status = taskAny.status;
|
||||
setAutoEnabled(status === 1 || status === 'running');
|
||||
} else {
|
||||
toast({
|
||||
title: '获取任务详情失败',
|
||||
description: '无法找到该任务',
|
||||
variant: 'destructive',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务详情出错:', error); // 添加错误日志
|
||||
toast({
|
||||
title: '获取任务详情失败',
|
||||
description: '请检查网络连接后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleUpdateFormData = (data: Partial<CreateLikeTaskDataLocal>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector('main');
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector('main');
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 转换为API需要的格式
|
||||
const apiFormData = {
|
||||
...formData,
|
||||
// 如果API需要其他转换,可以在这里添加
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEditMode) {
|
||||
// 编辑模式,调用更新API
|
||||
response = await updateAutoLikeTask({
|
||||
...apiFormData,
|
||||
id: id!
|
||||
});
|
||||
} else {
|
||||
// 新建模式,调用创建API
|
||||
response = await createAutoLikeTask(apiFormData);
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: isEditMode ? '更新成功' : '创建成功',
|
||||
description: isEditMode ? '自动点赞任务已更新' : '自动点赞任务已创建并开始执行',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
} else {
|
||||
toast({
|
||||
title: isEditMode ? '更新失败' : '创建失败',
|
||||
description: response.msg || '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: isEditMode ? '更新失败' : '创建失败',
|
||||
description: '请检查网络连接后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="sticky top-0 z-10 bg-white pb-4">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)} className="hover:bg-gray-50">
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">{isEditMode ? '编辑自动点赞' : '新建自动点赞'}</h1>
|
||||
</div>
|
||||
<StepIndicator currentStep={currentStep} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout header={header}>
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={header}>
|
||||
<div className="min-h-screen bg-[#F8F9FA]">
|
||||
<div className="pt-4">
|
||||
|
||||
<div className="mt-8">
|
||||
{currentStep === 1 && (
|
||||
<BasicSettings
|
||||
formData={formData}
|
||||
onChange={handleUpdateFormData}
|
||||
onNext={handleNext}
|
||||
autoEnabled={autoEnabled}
|
||||
setAutoEnabled={setAutoEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6 px-6">
|
||||
<DeviceSelection
|
||||
selectedDevices={formData.devices}
|
||||
onSelect={(devices) => handleUpdateFormData({ devices })}
|
||||
placeholder="选择设备"
|
||||
/>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="outline" className="flex-1 h-12 rounded-xl text-base" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm"
|
||||
onClick={handleNext}
|
||||
disabled={formData.devices.length === 0}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="px-6 space-y-6">
|
||||
<FriendSelection
|
||||
selectedFriends={formData.friends || []}
|
||||
onSelect={(friends) => handleUpdateFormData({ friends })}
|
||||
deviceIds={formData.devices}
|
||||
placeholder="选择微信好友"
|
||||
/>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="outline" className="flex-1 h-12 rounded-xl text-base" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm" onClick={handleComplete}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// 步骤指示器组件
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
const steps = [
|
||||
{ title: '基础设置', description: '设置点赞规则' },
|
||||
{ title: '设备选择', description: '选择执行设备' },
|
||||
{ title: '人群选择', description: '选择目标人群' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="px-6">
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="flex flex-col items-center relative z-10">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
index < currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: index === currentStep
|
||||
? 'border-2 border-blue-600 text-blue-600'
|
||||
: 'border-2 border-gray-300 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{index < currentStep ? <Check className="w-5 h-5" /> : index + 1}
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<div className={`text-sm font-medium ${index <= currentStep ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{step.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-4 left-0 w-full h-0.5 bg-gray-200 -translate-y-1/2 z-0">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 基础设置组件
|
||||
interface BasicSettingsProps {
|
||||
formData: CreateLikeTaskDataLocal;
|
||||
onChange: (data: Partial<CreateLikeTaskDataLocal>) => void;
|
||||
onNext: () => void;
|
||||
autoEnabled: boolean;
|
||||
setAutoEnabled: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function BasicSettings({ formData, onChange, onNext, autoEnabled, setAutoEnabled }: BasicSettingsProps) {
|
||||
const handleContentTypeChange = (type: ContentType) => {
|
||||
const currentTypes = [...formData.contentTypes];
|
||||
if (currentTypes.includes(type)) {
|
||||
onChange({ contentTypes: currentTypes.filter((t) => t !== type) });
|
||||
} else {
|
||||
onChange({ contentTypes: [...currentTypes, type] });
|
||||
}
|
||||
};
|
||||
|
||||
const incrementInterval = () => {
|
||||
onChange({ interval: Math.min(formData.interval + 5, 60) });
|
||||
};
|
||||
|
||||
const decrementInterval = () => {
|
||||
onChange({ interval: Math.max(formData.interval - 5, 5) });
|
||||
};
|
||||
|
||||
const incrementMaxLikes = () => {
|
||||
onChange({ maxLikes: Math.min(formData.maxLikes + 10, 500) });
|
||||
};
|
||||
|
||||
const decrementMaxLikes = () => {
|
||||
onChange({ maxLikes: Math.max(formData.maxLikes - 10, 10) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-name">任务名称</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="like-interval">点赞间隔</Label>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-l-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={decrementInterval}
|
||||
>
|
||||
<Minus className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="like-interval"
|
||||
type="number"
|
||||
min={5}
|
||||
max={60}
|
||||
value={formData.interval.toString()}
|
||||
onChange={(e) => onChange({ interval: Number.parseInt(e.target.value) || 5 })}
|
||||
className="h-12 rounded-none border-x-0 border-gray-200 text-center"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-gray-500">
|
||||
秒
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-r-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={incrementInterval}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置两次点赞之间的最小时间间隔</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-likes">每日最大点赞数</Label>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-l-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={decrementMaxLikes}
|
||||
>
|
||||
<Minus className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="max-likes"
|
||||
type="number"
|
||||
min={10}
|
||||
max={500}
|
||||
value={formData.maxLikes.toString()}
|
||||
onChange={(e) => onChange({ maxLikes: Number.parseInt(e.target.value) || 10 })}
|
||||
className="h-12 rounded-none border-x-0 border-gray-200 text-center"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-gray-500">
|
||||
次/天
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-r-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={incrementMaxLikes}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置每天最多点赞的次数</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>点赞时间范围</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => onChange({ startTime: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => onChange({ endTime: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置每天可以点赞的时间段</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>点赞内容类型</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ id: 'text' as ContentType, label: '文字' },
|
||||
{ id: 'image' as ContentType, label: '图片' },
|
||||
{ id: 'video' as ContentType, label: '视频' },
|
||||
].map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`flex items-center justify-center h-12 rounded-xl border cursor-pointer ${
|
||||
formData.contentTypes.includes(type.id)
|
||||
? 'border-blue-500 bg-blue-50 text-blue-600'
|
||||
: 'border-gray-200 text-gray-600'
|
||||
}`}
|
||||
onClick={() => handleContentTypeChange(type.id)}
|
||||
>
|
||||
{type.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">选择要点赞的内容类型</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-friend-tags" className="cursor-pointer">
|
||||
启用好友标签
|
||||
</Label>
|
||||
<Switch
|
||||
id="enable-friend-tags"
|
||||
checked={formData.enableFriendTags}
|
||||
onCheckedChange={(checked) => onChange({ enableFriendTags: checked })}
|
||||
/>
|
||||
</div>
|
||||
{formData.enableFriendTags && (
|
||||
<>
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label htmlFor="friend-tags">好友标签</Label>
|
||||
<Input
|
||||
id="friend-tags"
|
||||
placeholder="请输入标签"
|
||||
value={formData.friendTags || ''}
|
||||
onChange={e => onChange({ friendTags: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">只给有此标签的好友点赞</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<Label htmlFor="auto-enabled" className="cursor-pointer">
|
||||
自动开启
|
||||
</Label>
|
||||
<Switch
|
||||
id="auto-enabled"
|
||||
checked={autoEnabled}
|
||||
onCheckedChange={setAutoEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} className="w-full h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm">
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,344 +1,462 @@
|
||||
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;
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
PlusOutlined,
|
||||
MinusOutlined,
|
||||
ArrowLeftOutlined,
|
||||
CheckOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Switch, Spin, message } from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import {
|
||||
createAutoLikeTask,
|
||||
updateAutoLikeTask,
|
||||
fetchAutoLikeTaskDetail,
|
||||
} from "./api";
|
||||
import {
|
||||
CreateLikeTaskData,
|
||||
ContentType,
|
||||
} from "@/pages/workspace/auto-like/record/api";
|
||||
import style from "./new.module.scss";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
|
||||
const contentTypeLabels: Record<ContentType, string> = {
|
||||
text: "文字",
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ title: "基础设置", desc: "设置点赞规则" },
|
||||
{ title: "设备选择", desc: "选择执行设备" },
|
||||
{ title: "人群选择", desc: "选择目标人群" },
|
||||
];
|
||||
|
||||
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 mainElement = document.querySelector("main");
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector("main");
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 步骤器
|
||||
const renderStepIndicator = () => (
|
||||
<div className={style.stepIndicatorWrapper}>
|
||||
<div className={style.stepIndicator}>
|
||||
{steps.map((s, i) => (
|
||||
<div
|
||||
key={s.title}
|
||||
className={
|
||||
style.stepItem +
|
||||
" " +
|
||||
(currentStep === i + 1
|
||||
? style.stepActive
|
||||
: i + 1 < currentStep
|
||||
? style.stepDone
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<span className={style.stepNum}>
|
||||
{i + 1 < currentStep ? <CheckOutlined /> : i + 1}
|
||||
</span>
|
||||
<span className={style.stepTitle}>{s.title}</span>
|
||||
<span className={style.stepDesc}>{s.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.stepProgressBarBg}>
|
||||
<div
|
||||
className={style.stepProgressBar}
|
||||
style={{
|
||||
width: `${((currentStep - 1) / (steps.length - 1)) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤1:基础设置
|
||||
const renderBasicSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>任务名称</div>
|
||||
<Input
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleUpdateFormData({ name: e.target.value })}
|
||||
className={style.input}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞间隔</div>
|
||||
<div className={style.counterRow}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
interval: Math.max(1, formData.interval - 1),
|
||||
})
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<div className={style.counterInputWrapper}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={formData.interval}
|
||||
onChange={(e) =>
|
||||
handleUpdateFormData({
|
||||
interval: Number.parseInt(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
className={style.counterInput}
|
||||
/>
|
||||
<span className={style.counterUnit}>秒</span>
|
||||
</div>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ interval: formData.interval + 1 })
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置两次点赞之间的最小时间间隔</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>每日最大点赞数</div>
|
||||
<div className={style.counterRow}>
|
||||
<Button
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({
|
||||
maxLikes: Math.max(1, formData.maxLikes - 10),
|
||||
})
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
<div className={style.counterInputWrapper}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={formData.maxLikes}
|
||||
onChange={(e) =>
|
||||
handleUpdateFormData({
|
||||
maxLikes: Number.parseInt(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
className={style.counterInput}
|
||||
/>
|
||||
<span className={style.counterUnit}>次/天</span>
|
||||
</div>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() =>
|
||||
handleUpdateFormData({ maxLikes: formData.maxLikes + 10 })
|
||||
}
|
||||
className={style.counterBtn}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天最多点赞的次数</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞时间范围</div>
|
||||
<div className={style.timeRow}>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) =>
|
||||
handleUpdateFormData({ startTime: e.target.value })
|
||||
}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
<span className={style.timeSeparator}>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => handleUpdateFormData({ endTime: e.target.value })}
|
||||
className={style.inputTime}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.counterTip}>设置每天可以点赞的时间段</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.formLabel}>点赞内容类型</div>
|
||||
<div className={style.contentTypes}>
|
||||
{(["text", "image", "video"] as ContentType[]).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
type={
|
||||
formData.contentTypes.includes(type) ? "primary" : "default"
|
||||
}
|
||||
ghost={!formData.contentTypes.includes(type)}
|
||||
className={style.contentTypeBtn}
|
||||
onClick={() => {
|
||||
const newTypes = formData.contentTypes.includes(type)
|
||||
? formData.contentTypes.filter((t) => t !== type)
|
||||
: [...formData.contentTypes, type];
|
||||
handleUpdateFormData({ contentTypes: newTypes });
|
||||
}}
|
||||
>
|
||||
{contentTypeLabels[type]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className={style.counterTip}>选择要点赞的内容类型</div>
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>启用好友标签</span>
|
||||
<Switch
|
||||
checked={formData.enableFriendTags}
|
||||
onChange={(checked) =>
|
||||
handleUpdateFormData({ enableFriendTags: checked })
|
||||
}
|
||||
className={style.switch}
|
||||
/>
|
||||
</div>
|
||||
{formData.enableFriendTags && (
|
||||
<div className={style.formItem}>
|
||||
<Input
|
||||
placeholder="请输入标签"
|
||||
value={formData.friendTags}
|
||||
onChange={(e) =>
|
||||
handleUpdateFormData({ friendTags: e.target.value })
|
||||
}
|
||||
className={style.input}
|
||||
/>
|
||||
<div className={style.counterTip}>只给有此标签的好友点赞</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.formItem}>
|
||||
<div className={style.switchRow}>
|
||||
<span className={style.switchLabel}>自动开启</span>
|
||||
<Switch
|
||||
checked={autoEnabled}
|
||||
onChange={setAutoEnabled}
|
||||
className={style.switch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
onClick={handleNext}
|
||||
size="large"
|
||||
className={style.mainBtn}
|
||||
disabled={!formData.name.trim()}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤2:设备选择
|
||||
const renderDeviceSelection = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<DeviceSelection
|
||||
selectedDevices={formData.devices}
|
||||
onSelect={(devices) => handleUpdateFormData({ devices })}
|
||||
mode="dialog"
|
||||
showInput={true}
|
||||
showSelectedList={true}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
className={style.nextBtn}
|
||||
size="large"
|
||||
disabled={formData.devices.length === 0}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 步骤3:好友设置
|
||||
const renderFriendSettings = () => (
|
||||
<div className={style.basicSection}>
|
||||
<div className={style.formItem}>
|
||||
<FriendSelection
|
||||
selectedFriends={formData.friends}
|
||||
onSelect={(friends) => handleUpdateFormData({ friends })}
|
||||
deviceIds={formData.devices}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePrev}
|
||||
className={style.prevBtn}
|
||||
size="large"
|
||||
style={{ marginRight: 16 }}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleComplete}
|
||||
className={style.completeBtn}
|
||||
size="large"
|
||||
loading={isSubmitting}
|
||||
disabled={formData.friends.length === 0}
|
||||
>
|
||||
{isEditMode ? "更新任务" : "创建任务"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<div className={style.headerBar}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate(-1)}
|
||||
className={style.backBtn}
|
||||
/>
|
||||
<span className={style.title}>
|
||||
{isEditMode ? "编辑自动点赞" : "新建自动点赞"}
|
||||
</span>
|
||||
</div>
|
||||
{renderStepIndicator()}
|
||||
</>
|
||||
}
|
||||
footer={<MeauMobile activeKey="workspace" />}
|
||||
>
|
||||
<div className={style.formBg}>
|
||||
{isLoading ? (
|
||||
<div className={style.formLoading}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentStep === 1 && renderBasicSettings()}
|
||||
{currentStep === 2 && renderDeviceSelection()}
|
||||
{currentStep === 3 && renderFriendSettings()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewAutoLike;
|
||||
|
||||
@@ -1,250 +1,321 @@
|
||||
.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;
|
||||
}
|
||||
.formBg {
|
||||
background: #f8f6f3;
|
||||
min-height: 100vh;
|
||||
padding: 0 0 80px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stepIndicatorWrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: #f8f6f3;
|
||||
padding: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.stepIndicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.stepItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #bbb;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
transition: color 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.stepActive {
|
||||
color: #188eee;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stepDone {
|
||||
color: #19c37d;
|
||||
}
|
||||
|
||||
.stepNum {
|
||||
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;
|
||||
}
|
||||
|
||||
.stepActive .stepNum {
|
||||
background: #188eee;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stepDone .stepNum {
|
||||
background: #19c37d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: 14px;
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stepDesc {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stepProgressBarBg {
|
||||
position: relative;
|
||||
width: 80%;
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
margin: 12px auto 0 auto;
|
||||
}
|
||||
|
||||
.stepProgressBar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: #188eee;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.basicSection {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 24px 16px 0 16px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 0 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: 16px;
|
||||
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;
|
||||
}
|
||||
|
||||
.counterInputWrapper {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.counterInput {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.counterUnit {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeSeparator {
|
||||
margin: 0 8px;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.counterTip {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.contentTypes {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contentTypeTag {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.contentTypeTag:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.contentTypeTagActive {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
background: #e6f7ff;
|
||||
color: #188eee;
|
||||
border: 1px solid #91d5ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.switchRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.switchLabel {
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.footerBtnBar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
|
||||
padding: 16px 24px 24px 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prevBtn, .nextBtn, .completeBtn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
min-width: 120px;
|
||||
}
|
||||
281
nkebao/src/pages/workspace/auto-like/record/AutoLikeDetail.tsx
Normal file
281
nkebao/src/pages/workspace/auto-like/record/AutoLikeDetail.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ThumbsUp,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Card, } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import '@/components/Layout.css';
|
||||
import {
|
||||
fetchLikeRecords,
|
||||
LikeRecord,
|
||||
} from '@/api/autoLike';
|
||||
|
||||
// 格式化日期
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AutoLikeDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { toast } = useToast();
|
||||
const [records, setRecords] = useState<LikeRecord[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setRecordsLoading(true);
|
||||
fetchLikeRecords(id, 1, pageSize)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
})
|
||||
.finally(() => setRecordsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchLikeRecords(id!, 1, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLikeRecords(id!, currentPage, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchLikeRecords(id!, newPage, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(newPage);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<PageHeader
|
||||
title="点赞记录"
|
||||
defaultBackPath="/workspace/auto-like"
|
||||
/>
|
||||
<div className="flex items-center space-x-2 px-4 py-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索好友昵称或内容"
|
||||
className="pl-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh} disabled={recordsLoading}>
|
||||
<RefreshCw className={`h-4 w-4 ${recordsLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
{records.length > 0 && total > pageSize && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
className="mx-1"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="mx-4 py-2 text-sm text-gray-500">
|
||||
第 {currentPage} 页,共 {Math.ceil(total / pageSize)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= Math.ceil(total / pageSize)}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
className="mx-1"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4 space-y-4">
|
||||
|
||||
{recordsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<div className="flex space-x-2 mt-3">
|
||||
<Skeleton className="h-20 w-20" />
|
||||
<Skeleton className="h-20 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<ThumbsUp className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无点赞记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="p-4 mb-4 bg-white rounded-2xl shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 max-w-[65%]">
|
||||
<Avatar>
|
||||
<img
|
||||
src={record.friendAvatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback"}
|
||||
alt={record.friendName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate" title={record.friendName}>
|
||||
{record.friendName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">内容发布者</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-blue-50 whitespace-nowrap shrink-0">
|
||||
{formatDate(record.momentTime || record.likeTime)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="mb-3">
|
||||
{record.content && (
|
||||
<p className="text-gray-700 mb-3 whitespace-pre-line">
|
||||
{record.content}
|
||||
</p>
|
||||
)}
|
||||
{Array.isArray(record.resUrls) && record.resUrls.length > 0 && (
|
||||
<div className={`grid gap-2 ${
|
||||
record.resUrls.length === 1 ? "grid-cols-1" :
|
||||
record.resUrls.length === 2 ? "grid-cols-2" :
|
||||
record.resUrls.length <= 3 ? "grid-cols-3" :
|
||||
record.resUrls.length <= 6 ? "grid-cols-3 grid-rows-2" :
|
||||
"grid-cols-3 grid-rows-3"
|
||||
}`}>
|
||||
{record.resUrls.slice(0, 9).map((image: string, idx: number) => (
|
||||
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={`内容图片 ${idx + 1}`}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center mt-4 p-2 bg-gray-50 rounded-md">
|
||||
<Avatar className="h-8 w-8 mr-2 shrink-0">
|
||||
<img
|
||||
src={record.operatorAvatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=operator"}
|
||||
alt={record.operatorName}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
<div className="text-sm min-w-0">
|
||||
<span className="font-medium truncate inline-block max-w-full" title={record.operatorName}>
|
||||
{record.operatorName}
|
||||
</span>
|
||||
<span className="text-gray-500 ml-2">点赞了这条内容</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -125,9 +125,8 @@ const AutoLikeDetail: React.FC = () => {
|
||||
"https://api.dicebear.com/7.x/avataaars/svg?seed=fallback"
|
||||
}
|
||||
className={style["user-avatar"]}
|
||||
>
|
||||
<UserOutlined />
|
||||
</Avatar>
|
||||
fallback={<UserOutlined />}
|
||||
/>
|
||||
<div className={style["user-details"]}>
|
||||
<div className={style["user-name"]} title={record.friendName}>
|
||||
{record.friendName}
|
||||
@@ -167,9 +166,8 @@ const AutoLikeDetail: React.FC = () => {
|
||||
"https://api.dicebear.com/7.x/avataaars/svg?seed=operator"
|
||||
}
|
||||
className={style["operator-avatar"]}
|
||||
>
|
||||
<UserOutlined />
|
||||
</Avatar>
|
||||
fallback={<UserOutlined />}
|
||||
/>
|
||||
<div className={style["like-text"]}>
|
||||
<span className={style["operator-name"]} title={record.operatorName}>
|
||||
{record.operatorName}
|
||||
@@ -194,7 +192,7 @@ const AutoLikeDetail: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
footer={<MeauMobile activeKey="workspace" />}
|
||||
>
|
||||
<div className={style["detail-page"]}>
|
||||
{/* 任务信息卡片 */}
|
||||
@@ -284,7 +282,7 @@ const AutoLikeDetail: React.FC = () => {
|
||||
data={records}
|
||||
renderItem={renderRecordItem}
|
||||
hasMore={hasMore}
|
||||
loadMore={handleLoadMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
className={style["records-list"]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,191 +1,199 @@
|
||||
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;
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "antd-mobile";
|
||||
import { NavBar } from "antd-mobile";
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { createGroupPushTask } from "@/pages/workspace/group-push/detail/groupPush";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
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 [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
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
|
||||
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 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;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { NavBar } from "antd-mobile";
|
||||
import { LeftOutline } from "antd-mobile-icons";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
Menu,
|
||||
} from "antd";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import MeauMobile from "@/components/MeauMobile/MeauMoible";
|
||||
import {
|
||||
fetchGroupPushTasks,
|
||||
deleteGroupPushTask,
|
||||
@@ -41,8 +40,6 @@ import {
|
||||
} 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);
|
||||
@@ -151,11 +148,11 @@ const GroupPush: React.FC = () => {
|
||||
<NavBar
|
||||
back={null}
|
||||
left={
|
||||
<div className={styles["nav-title"]}>
|
||||
<span style={{ verticalAlign: "middle" }}>
|
||||
<LeftOutline onClick={() => navigate(-1)} fontSize={24} />
|
||||
</span>
|
||||
群消息推送
|
||||
<div className="nav-title">
|
||||
<ArrowLeftOutlined
|
||||
twoToneColor="#1677ff"
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
style={{ background: "#fff" }}
|
||||
@@ -168,9 +165,10 @@ const GroupPush: React.FC = () => {
|
||||
创建任务
|
||||
</Button>
|
||||
}
|
||||
></NavBar>
|
||||
>
|
||||
<span className="nav-title">群消息推送</span>
|
||||
</NavBar>
|
||||
}
|
||||
footer={<MeauMobile />}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<div className={styles.searchBar}>
|
||||
|
||||
32
nkebao/src/pages/workspace/moments-sync/new/api.ts
Normal file
32
nkebao/src/pages/workspace/moments-sync/new/api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 创建朋友圈同步任务
|
||||
export const createMomentsSync = (params: {
|
||||
name: string;
|
||||
devices: string[];
|
||||
contentLibraries: string[];
|
||||
syncCount: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
accountType: number;
|
||||
status: number;
|
||||
type: number;
|
||||
}) => request("/v1/workbench/create", params, "POST");
|
||||
|
||||
// 更新朋友圈同步任务
|
||||
export const updateMomentsSync = (params: {
|
||||
id: string;
|
||||
name: string;
|
||||
devices: string[];
|
||||
contentLibraries: string[];
|
||||
syncCount: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
accountType: number;
|
||||
status: number;
|
||||
type: number;
|
||||
}) => request("/v1/workbench/update", params, "POST");
|
||||
|
||||
// 获取朋友圈同步任务详情
|
||||
export const getMomentsSyncDetail = (id: string) =>
|
||||
request("/v1/workbench/detail", { id }, "GET");
|
||||
@@ -84,7 +84,7 @@
|
||||
}
|
||||
|
||||
.inputTime {
|
||||
width: 90px;
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -7,8 +7,19 @@ import { NavBar } from "antd-mobile";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
import request from "@/api/request";
|
||||
import StepIndicator from "@/components/StepIndicator";
|
||||
import {
|
||||
createMomentsSync,
|
||||
updateMomentsSync,
|
||||
getMomentsSyncDetail,
|
||||
} from "./api";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
|
||||
const steps = ["基础设置", "设备选择", "内容库选择"];
|
||||
const steps = [
|
||||
{ id: 1, title: "基础设置", subtitle: "基础设置" },
|
||||
{ id: 2, title: "设备选择", subtitle: "设备选择" },
|
||||
{ id: 3, title: "内容库选择", subtitle: "内容库选择" },
|
||||
];
|
||||
|
||||
const defaultForm = {
|
||||
taskName: "",
|
||||
@@ -34,7 +45,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request("/v1/workbench/detail", { id }, "GET");
|
||||
const res = await getMomentsSyncDetail(id);
|
||||
if (res) {
|
||||
setFormData({
|
||||
taskName: res.name,
|
||||
@@ -95,11 +106,11 @@ const NewMomentsSync: React.FC = () => {
|
||||
type: 2,
|
||||
};
|
||||
if (isEditMode && id) {
|
||||
await request("/v1/workbench/update", { id, ...params }, "POST");
|
||||
await updateMomentsSync({ id, ...params });
|
||||
message.success("更新成功");
|
||||
navigate(`/workspace/moments-sync/${id}`);
|
||||
} else {
|
||||
await request("/v1/workbench/create", params, "POST");
|
||||
await createMomentsSync(params);
|
||||
message.success("创建成功");
|
||||
navigate("/workspace/moments-sync");
|
||||
}
|
||||
@@ -214,18 +225,14 @@ const NewMomentsSync: React.FC = () => {
|
||||
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
|
||||
<div className={style.formLabel}>选择设备</div>
|
||||
<DeviceSelection
|
||||
selectedDevices={formData.selectedDevices}
|
||||
onSelect={(devices) => updateForm({ selectedDevices: devices })}
|
||||
placeholder="请选择设备"
|
||||
showSelectedList={true}
|
||||
selectedListMaxHeight={200}
|
||||
/>
|
||||
{formData.selectedDevices.length > 0 && (
|
||||
<div className={style.selectedTip}>
|
||||
已选设备: {formData.selectedDevices.length}个
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.formStepBtnRow}>
|
||||
<Button onClick={prev} className={style.prevBtn}>
|
||||
@@ -294,25 +301,7 @@ const NewMomentsSync: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<StepIndicator currentStep={currentStep + 1} steps={steps} />
|
||||
{loading ? (
|
||||
<div className={style.formLoading}>
|
||||
<Spin />
|
||||
|
||||
11
nkebao/src/router/module/component-test.tsx
Normal file
11
nkebao/src/router/module/component-test.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import ComponentTest from "@/pages/component-test";
|
||||
|
||||
const componentTestRoutes = [
|
||||
{
|
||||
path: "/component-test",
|
||||
element: <ComponentTest />,
|
||||
auth: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default componentTestRoutes;
|
||||
@@ -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;
|
||||
|
||||
@@ -35,7 +35,7 @@ const workspaceRoutes = [
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/workspace/auto-like/:id",
|
||||
path: "/workspace/auto-like/record/:id",
|
||||
element: <RecordAutoLike />,
|
||||
auth: true,
|
||||
},
|
||||
|
||||
67
nkebao/src/types/device.ts
Normal file
67
nkebao/src/types/device.ts
Normal 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;
|
||||
}
|
||||
37
nkebao/src/utils/common.ts
Normal file
37
nkebao/src/utils/common.ts
Normal 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(),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
};
|
||||
10
nkebao/src/vite-env.d.ts
vendored
10
nkebao/src/vite-env.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user