Merge branch 'yongpxu-dev' into yongpxu-dev2

This commit is contained in:
笔记本里的永平
2025-07-23 17:14:09 +08:00
33 changed files with 3492 additions and 1180 deletions

View File

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

View 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; // 新增
}

View File

@@ -19,12 +19,7 @@
background: #f8f9fa; background: #f8f9fa;
} }
.popupContainer {
display: flex;
flex-direction: column;
height: 100vh;
background: #fff;
}
.popupHeader { .popupHeader {
padding: 16px; padding: 16px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;

View File

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

View File

@@ -1,7 +1,10 @@
import React from "react"; import React, { useState, useEffect, useCallback } from "react";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"; import { Checkbox, Popup } from "antd-mobile";
import { Input, Button, Checkbox, Popup } from "antd-mobile"; import { getDeviceList } from "./api";
import style from "./index.module.scss"; 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 { interface DeviceSelectionItem {
id: string; id: string;
@@ -19,42 +22,95 @@ interface SelectionPopupProps {
onClose: () => void; onClose: () => void;
selectedDevices: string[]; selectedDevices: string[];
onSelect: (devices: string[]) => void; onSelect: (devices: string[]) => void;
devices: DeviceSelectionItem[];
loading: boolean;
searchQuery: string;
setSearchQuery: (v: string) => void;
statusFilter: string;
setStatusFilter: (v: string) => void;
onRefresh: () => void;
filteredDevices: DeviceSelectionItem[];
total: number;
currentPage: number;
totalPages: number;
setCurrentPage: (v: number) => void;
onCancel: () => void;
onConfirm: () => void;
} }
const PAGE_SIZE = 20;
const SelectionPopup: React.FC<SelectionPopupProps> = ({ const SelectionPopup: React.FC<SelectionPopupProps> = ({
visible, visible,
onClose, onClose,
selectedDevices, selectedDevices,
onSelect, onSelect,
devices,
loading,
searchQuery,
setSearchQuery,
statusFilter,
setStatusFilter,
onRefresh,
filteredDevices,
total,
currentPage,
totalPages,
setCurrentPage,
onCancel,
onConfirm,
}) => { }) => {
// 设备数据
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) => { const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) { if (selectedDevices.includes(deviceId)) {
@@ -67,49 +123,45 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
return ( return (
<Popup <Popup
visible={visible} visible={visible}
// 禁止点击遮罩关闭 onMaskClick={onClose}
onMaskClick={() => {}}
position="bottom" position="bottom"
bodyStyle={{ height: "100vh" }} bodyStyle={{ height: "100vh" }}
closeOnMaskClick={false} closeOnMaskClick={false}
> >
<div className={style.popupContainer}> <Layout
<div className={style.popupHeader}> header={
<div className={style.popupTitle}></div> <PopupHeader
</div> title="选择设备"
<div className={style.popupSearchRow}> searchQuery={searchQuery}
<div className={style.popupSearchInputWrap}> setSearchQuery={setSearchQuery}
<SearchOutlined className={style.inputIcon} /> searchPlaceholder="搜索设备IMEI/备注/微信号"
<Input loading={loading}
placeholder="搜索设备IMEI/备注/微信号" onRefresh={() => fetchDevices(searchQuery, currentPage)}
value={searchQuery} showTabs={true}
onChange={setSearchQuery} tabsConfig={{
className={style.popupSearchInput} activeKey: statusFilter,
/> onChange: setStatusFilter,
</div> tabs: [
<select { title: "全部", key: "all" },
value={statusFilter} { title: "在线", key: "online" },
onChange={(e) => setStatusFilter(e.target.value)} { title: "离线", key: "offline" },
className={style.statusSelect} ],
> }}
<option value="all"></option> />
<option value="online">线</option> }
<option value="offline">线</option> footer={
</select> <PopupFooter
<Button total={total}
fill="outline" currentPage={currentPage}
size="mini" totalPages={totalPages}
onClick={onRefresh} loading={loading}
disabled={loading} selectedCount={selectedDevices.length}
className={style.refreshBtn} onPageChange={setCurrentPage}
> onCancel={onClose}
{loading ? ( onConfirm={onClose}
<div className={style.loadingIcon}></div> />
) : ( }
<ReloadOutlined /> >
)}
</Button>
</div>
<div className={style.deviceList}> <div className={style.deviceList}>
{loading ? ( {loading ? (
<div className={style.loadingBox}> <div className={style.loadingBox}>
@@ -147,49 +199,7 @@ const SelectionPopup: React.FC<SelectionPopupProps> = ({
</div> </div>
)} )}
</div> </div>
{/* 分页栏 */} </Layout>
<div className={style.paginationRow}>
<div className={style.totalCount}> {total} </div>
<div className={style.paginationControls}>
<Button
fill="none"
size="mini"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
>
&lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
fill="none"
size="mini"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
>
&gt;
</Button>
</div>
</div>
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedDevices.length}
</div>
<div className={style.footerBtnGroup}>
<Button fill="outline" onClick={onCancel}>
</Button>
<Button color="primary" onClick={onConfirm}>
</Button>
</div>
</div>
</div>
</Popup> </Popup>
); );
}; };

View File

@@ -1,9 +1,12 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons"; import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Popup, Toast } from "antd-mobile"; import { Popup } from "antd-mobile";
import { Button, Input } from "antd"; import { Button, Input } from "antd";
import { getFriendList } from "./api"; import { getFriendList } from "./api";
import style from "./index.module.scss"; 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 { interface WechatFriend {
@@ -104,35 +107,18 @@ export default function FriendSelection({
params.keyword = keyword.trim(); params.keyword = keyword.trim();
} }
if (enableDeviceFilter) { if (enableDeviceFilter && deviceIds.length > 0) {
if (deviceIds.length === 0) { params.deviceIds = deviceIds;
setFriends([]);
setTotalFriends(0);
setTotalPages(1);
setLoading(false);
return;
}
params.deviceIds = deviceIds.join(",");
} }
const res = await getFriendList(params); const response = await getFriendList(params);
if (response && response.list) {
if (res && Array.isArray(res.list)) { setFriends(response.list);
setFriends( setTotalFriends(response.total || 0);
res.list.map((friend: any) => ({ setTotalPages(Math.ceil((response.total || 0) / 20));
id: friend.id?.toString() || "",
nickname: friend.nickname || "",
wechatId: friend.wechatId || "",
avatar: friend.avatar || "",
customer: friend.customer || "",
}))
);
setTotalFriends(res.total || 0);
setTotalPages(Math.ceil((res.total || 0) / 20));
} }
} catch (error) { } catch (error) {
console.error("获取好友列表失败:", error); console.error("获取好友列表失败:", error);
Toast.show({ content: "获取好友列表失败", position: "top" });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -140,16 +126,20 @@ export default function FriendSelection({
// 处理好友选择 // 处理好友选择
const handleFriendToggle = (friendId: string) => { const handleFriendToggle = (friendId: string) => {
let newIds: string[]; if (readonly) return;
if (selectedFriends.includes(friendId)) {
newIds = selectedFriends.filter((id) => id !== friendId); const newSelectedFriends = selectedFriends.includes(friendId)
} else { ? selectedFriends.filter((id) => id !== friendId)
newIds = [...selectedFriends, friendId]; : [...selectedFriends, friendId];
}
onSelect(newIds); onSelect(newSelectedFriends);
// 如果有 onSelectDetail 回调,传递完整的好友对象
if (onSelectDetail) { if (onSelectDetail) {
const selectedObjs = friends.filter((f) => newIds.includes(f.id)); const selectedFriendObjs = friends.filter((friend) =>
onSelectDetail(selectedObjs); newSelectedFriends.includes(friend.id)
);
onSelectDetail(selectedFriendObjs);
} }
}; };
@@ -160,29 +150,22 @@ export default function FriendSelection({
}; };
// 获取已选好友详细信息 // 获取已选好友详细信息
const selectedFriendObjs = selectedFriends const selectedFriendObjs = friends.filter((friend) =>
.map((id) => friends.find((f) => f.id === id)) selectedFriends.includes(friend.id)
.filter(Boolean) as WechatFriend[]; );
// 删除已选好友 // 删除已选好友
const handleRemoveFriend = (id: string) => { const handleRemoveFriend = (id: string) => {
if (readonly) return; if (readonly) return;
onSelect(selectedFriends.filter((f) => f !== id)); onSelect(selectedFriends.filter((d) => d !== id));
}; };
// 确认按钮逻辑 // 确认选择
const handleConfirm = () => { const handleConfirm = () => {
setRealVisible(false);
if (onConfirm) { if (onConfirm) {
onConfirm(selectedFriends, selectedFriendObjs); onConfirm(selectedFriends, selectedFriendObjs);
} }
}; setRealVisible(false);
// 清空搜索
const handleClearSearch = () => {
setSearchQuery("");
setCurrentPage(1);
fetchFriends(1, "");
}; };
return ( return (
@@ -271,40 +254,30 @@ export default function FriendSelection({
position="bottom" position="bottom"
bodyStyle={{ height: "100vh" }} bodyStyle={{ height: "100vh" }}
> >
<div className={style.popupContainer}> <Layout
<div className={style.popupHeader}> header={
<div className={style.popupTitle}></div> <PopupHeader
<div className={style.searchWrapper}> title="选择微信好友"
<Input searchQuery={searchQuery}
placeholder="搜索好友" setSearchQuery={setSearchQuery}
value={searchQuery} searchPlaceholder="搜索好友"
onChange={(e) => setSearchQuery(e.target.value)} loading={loading}
disabled={readonly} onRefresh={() => fetchFriends(currentPage, searchQuery)}
prefix={<SearchOutlined />} />
allowClear }
size="large" footer={
/> <PopupFooter
{searchQuery && !readonly && ( total={totalFriends}
<Button currentPage={currentPage}
type="text" totalPages={totalPages}
icon={<DeleteOutlined />} loading={loading}
size="small" selectedCount={selectedFriends.length}
className={style.clearBtn} onPageChange={setCurrentPage}
onClick={handleClearSearch} onCancel={() => setRealVisible(false)}
style={{ onConfirm={handleConfirm}
color: "#ff4d4f", />
border: "none", }
background: "none", >
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
)}
</div>
</div>
<div className={style.friendList}> <div className={style.friendList}>
{loading ? ( {loading ? (
<div className={style.loadingBox}> <div className={style.loadingBox}>
@@ -372,50 +345,7 @@ export default function FriendSelection({
</div> </div>
)} )}
</div> </div>
{/* 分页栏 */} </Layout>
<div className={style.paginationRow}>
<div className={style.totalCount}> {totalFriends} </div>
<div className={style.paginationControls}>
<Button
type="text"
size="small"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
&lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
type="text"
size="small"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
&gt;
</Button>
</div>
</div>
{/* 底部按钮栏 */}
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedFriends.length}
</div>
<div className={style.footerBtnGroup}>
<Button onClick={() => setRealVisible(false)}></Button>
<Button type="primary" onClick={handleConfirm}>
</Button>
</div>
</div>
</div>
</Popup> </Popup>
</> </>
); );

View File

@@ -1,15 +1,12 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
SearchOutlined, import { Button, Input } from "antd";
CloseOutlined, import { Popup } from "antd-mobile";
LeftOutlined,
RightOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { Button as AntdButton, Input as AntdInput } from "antd";
import { Popup, Toast } from "antd-mobile";
import { getGroupList } from "./api"; import { getGroupList } from "./api";
import style from "./index.module.scss"; 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 { interface WechatGroup {
@@ -61,9 +58,9 @@ export default function GroupSelection({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// 获取已选群聊详细信息 // 获取已选群聊详细信息
const selectedGroupObjs = selectedGroups const selectedGroupObjs = groups.filter((group) =>
.map((id) => groups.find((g) => g.id === id)) selectedGroups.includes(group.id)
.filter(Boolean) as WechatGroup[]; );
// 删除已选群聊 // 删除已选群聊
const handleRemoveGroup = (id: string) => { const handleRemoveGroup = (id: string) => {
@@ -101,58 +98,52 @@ export default function GroupSelection({
setCurrentPage(1); setCurrentPage(1);
fetchGroups(1, searchQuery); fetchGroups(1, searchQuery);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery, realVisible]); }, [searchQuery, realVisible]);
// 获取群列表API - 支持keyword // 获取群列表API
const fetchGroups = async (page: number, keyword: string = "") => { const fetchGroups = async (page: number, keyword: string = "") => {
setLoading(true); setLoading(true);
try { try {
const params: any = { let params: any = {
page, page,
limit: 20, limit: 20,
}; };
if (keyword.trim()) { if (keyword.trim()) {
params.keyword = keyword.trim(); params.keyword = keyword.trim();
} }
const res = await getGroupList(params); const response = await getGroupList(params);
if (response && response.list) {
if (res && Array.isArray(res.list)) { setGroups(response.list);
setGroups( setTotalGroups(response.total || 0);
res.list.map((group: any) => ({ setTotalPages(Math.ceil((response.total || 0) / 20));
id: group.id?.toString() || "",
chatroomId: group.chatroomId || "",
name: group.name || "",
avatar: group.avatar || "",
ownerWechatId: group.ownerWechatId || "",
ownerNickname: group.ownerNickname || "",
ownerAvatar: group.ownerAvatar || "",
}))
);
setTotalGroups(res.total || 0);
setTotalPages(Math.ceil((res.total || 0) / 20));
} }
} catch (error) { } catch (error) {
console.error("获取群列表失败:", error); console.error("获取群列表失败:", error);
Toast.show({ content: "获取群组列表失败", position: "top" });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 处理群选择 // 处理群选择
const handleGroupToggle = (groupId: string) => { const handleGroupToggle = (groupId: string) => {
let newIds: string[]; if (readonly) return;
if (selectedGroups.includes(groupId)) {
newIds = selectedGroups.filter((id) => id !== groupId); const newSelectedGroups = selectedGroups.includes(groupId)
} else { ? selectedGroups.filter((id) => id !== groupId)
newIds = [...selectedGroups, groupId]; : [...selectedGroups, groupId];
}
onSelect(newIds); onSelect(newSelectedGroups);
// 如果有 onSelectDetail 回调,传递完整的群聊对象
if (onSelectDetail) { if (onSelectDetail) {
const selectedObjs = groups.filter((g) => newIds.includes(g.id)); const selectedGroupObjs = groups.filter((group) =>
onSelectDetail(selectedObjs); newSelectedGroups.includes(group.id)
);
onSelectDetail(selectedGroupObjs);
} }
}; };
@@ -162,19 +153,12 @@ export default function GroupSelection({
return `已选择 ${selectedGroups.length} 个群聊`; return `已选择 ${selectedGroups.length} 个群聊`;
}; };
// 确认按钮逻辑 // 确认选择
const handleConfirm = () => { const handleConfirm = () => {
setRealVisible(false);
if (onConfirm) { if (onConfirm) {
onConfirm(selectedGroups, selectedGroupObjs); onConfirm(selectedGroups, selectedGroupObjs);
} }
}; setRealVisible(false);
// 清空搜索
const handleClearSearch = () => {
setSearchQuery("");
setCurrentPage(1);
fetchGroups(1, "");
}; };
return ( return (
@@ -182,7 +166,7 @@ export default function GroupSelection({
{/* 输入框 */} {/* 输入框 */}
{showInput && ( {showInput && (
<div className={`${style.inputWrapper} ${className}`}> <div className={`${style.inputWrapper} ${className}`}>
<AntdInput <Input
placeholder={placeholder} placeholder={placeholder}
value={getDisplayText()} value={getDisplayText()}
onClick={openPopup} onClick={openPopup}
@@ -234,7 +218,7 @@ export default function GroupSelection({
{group.name || group.chatroomId || group.id} {group.name || group.chatroomId || group.id}
</div> </div>
{!readonly && ( {!readonly && (
<AntdButton <Button
type="text" type="text"
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
size="small" size="small"
@@ -263,41 +247,30 @@ export default function GroupSelection({
position="bottom" position="bottom"
bodyStyle={{ height: "100vh" }} bodyStyle={{ height: "100vh" }}
> >
<div className={style.popupContainer}> <Layout
<div className={style.popupHeader}> header={
<div className={style.popupTitle}></div> <PopupHeader
<div className={style.searchWrapper}> title="选择群聊"
<AntdInput searchQuery={searchQuery}
placeholder="搜索群聊" setSearchQuery={setSearchQuery}
value={searchQuery} searchPlaceholder="搜索群聊"
onChange={(e) => setSearchQuery(e.target.value)} loading={loading}
disabled={readonly} onRefresh={() => fetchGroups(currentPage, searchQuery)}
prefix={<SearchOutlined />} />
allowClear }
size="large" footer={
/> <PopupFooter
<SearchOutlined className={style.searchIcon} /> total={totalGroups}
{searchQuery && !readonly && ( currentPage={currentPage}
<AntdButton totalPages={totalPages}
type="text" loading={loading}
icon={<CloseOutlined />} selectedCount={selectedGroups.length}
size="small" onPageChange={setCurrentPage}
className={style.clearBtn} onCancel={() => setRealVisible(false)}
onClick={handleClearSearch} onConfirm={handleConfirm}
style={{ />
color: "#ff4d4f", }
border: "none", >
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
)}
</div>
</div>
<div className={style.groupList}> <div className={style.groupList}>
{loading ? ( {loading ? (
<div className={style.loadingBox}> <div className={style.loadingBox}>
@@ -361,52 +334,7 @@ export default function GroupSelection({
</div> </div>
)} )}
</div> </div>
{/* 分页栏 */} </Layout>
<div className={style.paginationRow}>
<div className={style.totalCount}> {totalGroups} </div>
<div className={style.paginationControls}>
<AntdButton
type="text"
size="small"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<LeftOutlined />
</AntdButton>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<AntdButton
type="text"
size="small"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<RightOutlined />
</AntdButton>
</div>
</div>
{/* 底部按钮栏 */}
<div className={style.popupFooter}>
<div className={style.selectedCount}>
{selectedGroups.length}
</div>
<div className={style.footerBtnGroup}>
<AntdButton type="default" onClick={() => setRealVisible(false)}>
</AntdButton>
<AntdButton type="primary" onClick={handleConfirm}>
</AntdButton>
</div>
</div>
</div>
</Popup> </Popup>
</> </>
); );

View 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;

View 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;
}

View 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;

View 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);
}
}

View 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;

View File

@@ -11,7 +11,7 @@ const StepIndicator: React.FC<StepIndicatorProps> = ({
steps, steps,
}) => { }) => {
return ( return (
<div style={{ marginBottom: 24, overflowX: "auto" }}> <div style={{ overflowX: "auto", padding: "30px 0px", background: "#fff" }}>
<Steps current={currentStep - 1}> <Steps current={currentStep - 1}>
{steps.map((step, idx) => ( {steps.map((step, idx) => (
<Steps.Step <Steps.Step

View 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;

View File

@@ -1,8 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { message } from "antd"; import { message } from "antd";
import { NavBar } from "antd-mobile"; import NavCommon from "@/components/NavCommon";
import { ArrowLeftOutlined } from "@ant-design/icons";
import BasicSettings from "./steps/BasicSettings"; import BasicSettings from "./steps/BasicSettings";
import FriendRequestSettings from "./steps/FriendRequestSettings"; import FriendRequestSettings from "./steps/FriendRequestSettings";
import MessageSettings from "./steps/MessageSettings"; import MessageSettings from "./steps/MessageSettings";
@@ -205,25 +204,8 @@ export default function NewPlan() {
<Layout <Layout
header={ header={
<> <>
<NavBar <NavCommon title={isEdit ? "编辑朋友圈同步" : "新建朋友圈同步"} />
back={null} <StepIndicator currentStep={currentStep} steps={steps} />
style={{ background: "#fff" }}
left={
<div className="nav-title">
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => router(-1)}
/>
</div>
}
>
<span className="nav-title">
{isEdit ? "编辑朋友圈同步" : "新建朋友圈同步"}
</span>
</NavBar>
<div className="px-4 py-6">
<StepIndicator currentStep={currentStep} steps={steps} />
</div>
</> </>
} }
> >

View File

@@ -1,17 +1,6 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { import { Form, Input, Button, Tag, Switch, Modal } from "antd";
Form, import { Button as ButtonMobile } from "antd-mobile";
Input,
Button,
Tag,
Switch,
// Upload,
Modal,
Alert,
Row,
Col,
message,
} from "antd";
import { import {
PlusOutlined, PlusOutlined,
EyeOutlined, EyeOutlined,
@@ -21,6 +10,8 @@ import {
CheckOutlined, CheckOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { uploadFile } from "@/api/common"; import { uploadFile } from "@/api/common";
import Layout from "@/components/Layout/Layout";
import styles from "./base.module.scss";
interface BasicSettingsProps { interface BasicSettingsProps {
isEdit: boolean; isEdit: boolean;
@@ -424,31 +415,31 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
formData.scenario !== 1 ? { display: "none" } : { display: "block" }; formData.scenario !== 1 ? { display: "none" } : { display: "block" };
return ( return (
<div> <div className={styles["basic-container"]}>
{/* 场景选择区块 */} {/* 场景选择区块 */}
{sceneLoading ? ( <div className={styles["basic-scene-select"]}>
<div style={{ padding: 16, textAlign: "center" }}>...</div> <div className={styles["basic-scene-grid"]}>
) : ( {sceneList.map((scene) => {
<Row gutter={20} className="grid grid-cols-3 gap-4"> const selected = formData.scenario === scene.id;
{sceneList.map((scene) => ( return (
<Col span={8} key={scene.id}> <button
<Button key={scene.id}
type="primary"
onClick={() => handleScenarioSelect(scene.id)} onClick={() => handleScenarioSelect(scene.id)}
className={`w-full ${formData.scenario === scene.id ? "bg-blue-500" : "bg-gray-200"}`} className={
styles["basic-scene-btn"] +
(selected ? " " + styles.selected : "")
}
> >
{scene.name.replace("获客", "")} {scene.name.replace("获客", "")}
</Button> </button>
</Col> );
))} })}
</Row> </div>
)} </div>
{/* 计划名称输入区 */} {/* 计划名称输入区 */}
<div className="mb-4"></div> <div className={styles["basic-label"]}></div>
<div className="border p-2 mb-4"> <div className={styles["basic-input-block"]}>
<Input <Input
className="w-full"
value={formData.name} value={formData.name}
onChange={(e) => onChange={(e) =>
onChange({ ...formData, name: String(e.target.value) }) onChange({ ...formData, name: String(e.target.value) })
@@ -456,17 +447,16 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
placeholder="请输入计划名称" placeholder="请输入计划名称"
/> />
</div> </div>
<div className={styles["basic-label"]}></div>
<div className="mb-4"></div>
{/* 标签选择区块 */} {/* 标签选择区块 */}
{formData.scenario && ( {formData.scenario && (
<div className="flex pb-4" style={{ flexWrap: "wrap", gap: 8 }}> <div className={styles["basic-tag-list"]}>
{(currentScene?.scenarioTags || []).map((tag: string) => ( {(currentScene?.scenarioTags || []).map((tag: string) => (
<Tag <Tag
key={tag} key={tag}
color={selectedScenarioTags.includes(tag) ? "blue" : "default"} color={selectedScenarioTags.includes(tag) ? "blue" : "default"}
onClick={() => handleScenarioTagToggle(tag)} onClick={() => handleScenarioTagToggle(tag)}
style={{ marginBottom: 4 }} className={styles["basic-tag-item"]}
> >
{tag} {tag}
</Tag> </Tag>
@@ -477,9 +467,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
key={tag.id} key={tag.id}
color={selectedScenarioTags.includes(tag.id) ? "blue" : "default"} color={selectedScenarioTags.includes(tag.id) ? "blue" : "default"}
onClick={() => handleScenarioTagToggle(tag.id)} onClick={() => handleScenarioTagToggle(tag.id)}
style={{ marginBottom: 4 }}
closable closable
onClose={() => handleRemoveCustomTag(tag.id)} onClose={() => handleRemoveCustomTag(tag.id)}
className={styles["basic-tag-item"]}
> >
{tag.name} {tag.name}
</Tag> </Tag>
@@ -487,49 +477,33 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
</div> </div>
)} )}
{/* 自定义标签输入区 */} {/* 自定义标签输入区 */}
<div className="flex p" style={{ gap: 8 }}> <div className={styles["basic-custom-tag-input"]}>
<div className="border p-2 flex-1"> <Input
<Input type="text"
type="text" value={customTagInput}
value={customTagInput} onChange={(e) => setCustomTagInput(e.target.value)}
onChange={(e) => setCustomTagInput(e.target.value)} placeholder="添加自定义标签"
placeholder="添加自定义标签" />
className="w-full" <Button type="primary" onClick={handleAddCustomTag}>
/>
</div> </Button>
<div className="pt-1">
<Button type="primary" onClick={handleAddCustomTag}>
</Button>
</div>
</div> </div>
{/* 输入获客成功提示 */} {/* 输入获客成功提示 */}
<div className="flex pt-4"> <div className={styles["basic-success-tip"]}>
<div className="border p-2 flex-1"> <Input
<Input type="text"
type="text" value={tips}
value={tips} onChange={(e) => {
onChange={(e) => { setTips(e.target.value);
setTips(e.target.value); onChange({ ...formData, tips: e.target.value });
onChange({ ...formData, tips: e.target.value });
}}
placeholder="请输入获客成功提示"
className="w-full"
/>
</div>
</div>
{/* 选素材 */}
<div className="my-4" style={openPoster}>
<div className="mb-4"></div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12,
}} }}
> placeholder="请输入获客成功提示"
/>
</div>
{/* 选素材 */}
<div className={styles["basic-materials"]} style={openPoster}>
<div className={styles["basic-label"]}></div>
<div className={styles["basic-materials-grid"]}>
{[...materials, ...customPosters].map((material) => { {[...materials, ...customPosters].map((material) => {
const isSelected = selectedMaterials.some( const isSelected = selectedMaterials.some(
(m) => m.id === material.id (m) => m.id === material.id
@@ -538,35 +512,15 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
return ( return (
<div <div
key={material.id} key={material.id}
style={{ className={
border: isSelected ? "2px solid #1890ff" : "2px solid #eee", styles["basic-material-card"] +
borderRadius: 8, (isSelected ? " " + styles.selected : "")
padding: 6, }
cursor: "pointer",
background: isSelected ? "#e6f7ff" : "#fff",
transition: "border 0.2s",
textAlign: "center",
position: "relative",
height: 180 + 12, // 图片高度180+上下padding
overflow: "hidden",
minHeight: 192,
}}
onClick={() => handleMaterialSelect(material)} onClick={() => handleMaterialSelect(material)}
> >
{/* 预览按钮:自定义海报在左上,内置海报在右上 */} {/* 预览按钮:自定义海报在左上,内置海报在右上 */}
<Button <span
style={{ className={styles["basic-material-preview"]}
position: "absolute",
top: 8,
left: isCustom ? 8 : "auto",
right: isCustom ? "auto" : 8,
background: "rgba(0,0,0,0.5)",
border: "none",
borderRadius: "50%",
padding: 2,
zIndex: 2,
cursor: "pointer",
}}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePreviewImage(material.preview); handlePreviewImage(material.preview);
@@ -575,7 +529,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
<EyeOutlined <EyeOutlined
style={{ color: "#fff", width: 18, height: 18 }} style={{ color: "#fff", width: 18, height: 18 }}
/> />
</Button> </span>
{/* 删除自定义海报按钮 */} {/* 删除自定义海报按钮 */}
{isCustom && ( {isCustom && (
<Button <Button
@@ -607,31 +561,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
<img <img
src={material.preview} src={material.preview}
alt={material.name} alt={material.name}
style={{ className={styles["basic-material-img"]}
width: 100,
height: 180,
objectFit: "cover",
borderRadius: 4,
marginBottom: 0,
display: "block",
}}
/> />
<div <div className={styles["basic-material-name"]}>
style={{
position: "absolute",
left: 0,
bottom: 0,
width: "100%",
background: "rgba(0,0,0,0.5)",
color: "#fff",
fontSize: 14,
padding: "4px 0",
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
textAlign: "center",
zIndex: 3,
}}
>
{material.name} {material.name}
</div> </div>
</div> </div>
@@ -639,19 +571,7 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
})} })}
{/* 添加海报卡片 */} {/* 添加海报卡片 */}
<div <div
style={{ className={styles["basic-add-material"]}
border: "2px dashed #bbb",
borderRadius: 8,
padding: 6,
cursor: "pointer",
background: "#fafbfc",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 190,
}}
onClick={() => uploadInputRef.current?.click()} onClick={() => uploadInputRef.current?.click()}
> >
<span style={{ fontSize: 36, color: "#bbb", marginBottom: 8 }}> <span style={{ fontSize: 36, color: "#bbb", marginBottom: 8 }}>
@@ -705,11 +625,10 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
</Modal> </Modal>
</div> </div>
{/* 订单导入区块优化 */} {/* 订单导入区块优化 */}
<div style={openOrder} className="my-4"> <div className={styles["basic-order-upload"]} style={openOrder}>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div> <div className={styles["basic-order-upload-label"]}></div>
<div style={{ display: "flex", gap: 12, marginBottom: 4 }}> <div className={styles["basic-order-upload-actions"]}>
<Button <Button
type="button"
style={{ display: "flex", alignItems: "center", gap: 4 }} style={{ display: "flex", alignItems: "center", gap: 4 }}
onClick={handleDownloadTemplate} onClick={handleDownloadTemplate}
> >
@@ -741,78 +660,68 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
/> />
</Button> </Button>
</div> </div>
<div style={{ color: "#888", fontSize: 13, marginBottom: 8 }}> <div className={styles["basic-order-upload-tip"]}>
CSVExcel CSVExcel
</div> </div>
</div> </div>
{/* 电话获客设置区块,仅在选择电话获客场景时显示 */} {/* 电话获客设置区块,仅在选择电话获客场景时显示 */}
{formData.scenario === 5 && ( {formData.scenario === 5 && (
<div style={{ margin: "16px 0" }}> <div className={styles["basic-phone-settings"]}>
<div <div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16 }}>
style={{
background: "#f7f8fa", </div>
borderRadius: 10, <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
padding: 20, <div
boxShadow: "0 2px 8px rgba(0,0,0,0.03)", style={{
marginBottom: 12, display: "flex",
}} alignItems: "center",
> justifyContent: "space-between",
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16 }}> }}
>
<span></span>
<Switch
checked={phoneSettings.autoAdd}
onChange={(v) =>
setPhoneSettings((s) => ({ ...s, autoAdd: v }))
}
/>
</div> </div>
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}> <div
<div style={{
style={{ display: "flex",
display: "flex", alignItems: "center",
alignItems: "center", justifyContent: "space-between",
justifyContent: "space-between", }}
}} >
> <span></span>
<span></span> <Switch
<Switch checked={phoneSettings.speechToText}
checked={phoneSettings.autoAdd} onChange={(v) =>
onChange={(v) => setPhoneSettings((s) => ({ ...s, speechToText: v }))
setPhoneSettings((s) => ({ ...s, autoAdd: v })) }
} />
/> </div>
</div> <div
<div style={{
style={{ display: "flex",
display: "flex", alignItems: "center",
alignItems: "center", justifyContent: "space-between",
justifyContent: "space-between", }}
}} >
> <span></span>
<span></span> <Switch
<Switch checked={phoneSettings.questionExtraction}
checked={phoneSettings.speechToText} onChange={(v) =>
onChange={(v) => setPhoneSettings((s) => ({ ...s, questionExtraction: v }))
setPhoneSettings((s) => ({ ...s, speechToText: v })) }
} />
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span></span>
<Switch
checked={phoneSettings.questionExtraction}
onChange={(v) =>
setPhoneSettings((s) => ({ ...s, questionExtraction: v }))
}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
)} )}
{/* 微信群设置区块,仅在选择微信群场景时显示 */} {/* 微信群设置区块,仅在选择微信群场景时显示 */}
{formData.scenario === 7 && ( {formData.scenario === 7 && (
<div style={{ margin: "16px 0" }}> <div className={styles["basic-wechat-group"]}>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Input <Input
value={weixinqunName} value={weixinqunName}
@@ -833,24 +742,19 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({
</div> </div>
</div> </div>
)} )}
<div className={styles["basic-footer-switch"]}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
margin: "16px 0",
}}
>
<span></span> <span></span>
<Switch <Switch
checked={formData.enabled} checked={formData.enabled}
onChange={(value) => onChange({ ...formData, enabled: value })} onChange={(value) => onChange({ ...formData, enabled: value })}
/> />
</div> </div>
<Button className="mt-4" type="primary" onClick={onNext}>
<div className={styles["basic-footer-switch"]}>
</Button> <ButtonMobile block color="primary" onClick={onNext}>
</ButtonMobile>
</div>
</div> </div>
); );
}; };

View File

@@ -13,6 +13,7 @@ import {
} from "antd"; } from "antd";
import { QuestionCircleOutlined, MessageOutlined } from "@ant-design/icons"; import { QuestionCircleOutlined, MessageOutlined } from "@ant-design/icons";
import DeviceSelection from "@/components/DeviceSelection"; import DeviceSelection from "@/components/DeviceSelection";
import Layout from "@/components/Layout/Layout";
interface FriendRequestSettingsProps { interface FriendRequestSettingsProps {
formData: any; formData: any;
@@ -97,143 +98,151 @@ const FriendRequestSettings: React.FC<FriendRequestSettingsProps> = ({
return ( return (
<> <>
<div className="space-y-6"> <Layout
<div> footer={
<span className="font-medium text-base"></span> <div className="p-4 border-t bg-white">
<div className="mt-2"> <div className="flex justify-between">
<DeviceSelection <Button onClick={onPrev}></Button>
selectedDevices={selectedDevices.map((d) => d.id)} <Button type="primary" onClick={handleNext}>
onSelect={(deviceIds) => {
const newSelectedDevices = deviceIds.map((id) => ({ </Button>
id, </div>
name: `设备 ${id}`, </div>
status: "online", }
})); >
setSelectedDevices(newSelectedDevices); <div className="p-4 space-y-6">
onChange({ ...formData, device: deviceIds }); <div>
}} <span className="font-medium text-base"></span>
placeholder="选择设备" <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>
</div>
<div className="mb-4"> <div className="mb-4">
<div className="flex items-center space-x-2 mb-1 relative"> <div className="flex items-center space-x-2 mb-1 relative">
<span className="font-medium text-base"></span> <span className="font-medium text-base"></span>
<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" 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)} onMouseEnter={() => setShowRemarkTip(true)}
onMouseLeave={() => setShowRemarkTip(false)} onMouseLeave={() => setShowRemarkTip(false)}
onClick={() => setShowRemarkTip((v) => !v)} onClick={() => setShowRemarkTip((v) => !v)}
> >
? ?
</span> </span>
{showRemarkTip && ( {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 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></div>
<div className="mt-2 text-xs text-gray-500"></div> <div className="mt-2 text-xs text-gray-500">
<div className="mt-1 text-blue-600">
{formData.remarkType === "phone" && </div>
`138****1234+${getScenarioTitle()}`} <div className="mt-1 text-blue-600">
{formData.remarkType === "nickname" && {formData.remarkType === "phone" &&
`小红书用户2851+${getScenarioTitle()}`} `138****1234+${getScenarioTitle()}`}
{formData.remarkType === "source" && {formData.remarkType === "nickname" &&
`抖音直播+${getScenarioTitle()}`} `小红书用户2851+${getScenarioTitle()}`}
{formData.remarkType === "source" &&
`抖音直播+${getScenarioTitle()}`}
</div>
</div> </div>
</div> )}
)} </div>
</div> <Select
<Select value={formData.remarkType || "phone"}
value={formData.remarkType || "phone"} onChange={(value) => onChange({ ...formData, remarkType: value })}
onChange={(value) => onChange({ ...formData, remarkType: value })} className="w-full mt-2"
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" /> {remarkTypes.map((type) => (
<Select.Option key={type.value} value={type.value}>
</Button> {type.label}
</Select.Option>
))}
</Select>
</div> </div>
<Input
value={formData.greeting}
onChange={(e) =>
onChange({ ...formData, greeting: e.target.value })
}
placeholder="请输入招呼语"
className="mt-2"
/>
</div>
<div> <div>
<span className="font-medium text-base"></span> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 mt-2"> <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 <Input
type="number" value={formData.greeting}
value={formData.addFriendInterval || 1}
onChange={(e) => onChange={(e) =>
onChange({ onChange({ ...formData, greeting: e.target.value })
...formData,
addFriendInterval: Number(e.target.value),
})
} }
/> placeholder="请输入招呼语"
<div className="w-10"></div> className="mt-2"
</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>
</div>
{hasWarnings && ( <div>
<Alert <span className="font-medium text-base"></span>
message="警告" <div className="flex items-center space-x-2 mt-2">
description="您有未完成的设置项,建议完善后再进入下一步。" <Input
type="warning" type="number"
showIcon value={formData.addFriendInterval || 1}
className="bg-amber-50 border-amber-200" onChange={(e) =>
/> onChange({
)} ...formData,
addFriendInterval: Number(e.target.value),
})
}
/>
<div className="w-10"></div>
</div>
</div>
<div className="flex justify-between pt-4"> <div>
<Button onClick={onPrev}></Button> <span className="font-medium text-base"></span>
<Button type="primary" onClick={handleNext}> <div className="flex items-center space-x-2 mt-2">
<Input
</Button> 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> </div>
</div> </Layout>
<Modal <Modal
open={isTemplateDialogOpen} open={isTemplateDialogOpen}

View File

@@ -13,6 +13,7 @@ import {
LinkOutlined, LinkOutlined,
TeamOutlined, TeamOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
interface MessageContent { interface MessageContent {
id: string; id: string;
@@ -533,23 +534,29 @@ const MessageSettings: React.FC<MessageSettingsProps> = ({
return ( return (
<> <>
<div className="space-y-6"> <Layout
<div className="flex items-center justify-between"> footer={
<h2 className="text-lg font-semibold"></h2> <div className="p-4 border-t bg-white">
<Button onClick={() => setIsAddDayPlanOpen(true)}> <div className="flex justify-between">
<PlusOutlined className="h-4 w-4" /> <Button onClick={onPrev}></Button>
</Button> <Button type="primary" onClick={onNext}>
</div>
</Button>
</div>
</div>
}
>
<div className="p-4 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} /> <Tabs defaultActiveKey="0" items={items} />
<div className="flex justify-between pt-4">
<Button onClick={onPrev}></Button>
<Button type="primary" onClick={onNext}>
</Button>
</div> </div>
</div> </Layout>
{/* 添加天数计划弹窗 */} {/* 添加天数计划弹窗 */}
<Modal <Modal

View File

@@ -0,0 +1,164 @@
.basic-container {
padding: 12px;
}
.basic-scene-select {
background: #fff;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.basic-scene-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.basic-scene-btn {
height: 40px;
border: none;
border-radius: 10px;
font-weight: 500;
font-size: 16px;
outline: none;
cursor: pointer;
background: rgba(#1677ff,0.1);
color: #1677ff;
transition: all 0.2s;
}
.basic-scene-btn.selected {
background: #1677ff;
color: #fff;
box-shadow: 0 2px 8px rgba(22,119,255,0.08);
}
.basic-label {
margin-bottom: 12px;
font-weight: 500;
}
.basic-input-block {
border: 1px solid #eee;
margin-bottom: 16px;
}
.basic-tag-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding-bottom: 16px;
}
.basic-tag-item{
margin-bottom: 6px;
}
.basic-custom-tag-input {
display: flex;
gap: 8px;
}
.basic-success-tip {
display: flex;
padding-top: 16px;
}
.basic-materials {
margin: 16px 0;
}
.basic-materials-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.basic-material-preview{
position: absolute;
top: 8px;
padding-left: 2px;
right: 8px;
background:rgba(0,0,0,0.5);
border-radius: 50%;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
cursor:pointer;
}
.basic-material-card {
border: 2px solid #eee;
border-radius: 8px;
padding: 6px;
cursor: pointer;
background: #fff;
text-align: center;
position: relative;
min-height: 192px;
transition: border 0.2s;
}
.basic-material-card.selected {
border: 2px solid #1890ff;
background: #e6f7ff;
}
.basic-material-img {
width: 100px;
height: 180px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 0;
display: block;
}
.basic-material-name {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
background: rgba(0,0,0,0.5);
color: #fff;
font-size: 14px;
padding: 4px 0;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
text-align: center;
z-index: 3;
}
.basic-add-material {
border: 2px dashed #bbb;
border-radius: 8px;
padding: 6px;
cursor: pointer;
background: #fafbfc;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 190px;
}
.basic-order-upload {
margin: 16px 0;
}
.basic-order-upload-label {
font-weight: 500;
margin-bottom: 8px;
}
.basic-order-upload-actions {
display: flex;
gap: 12px;
margin-bottom: 4px;
}
.basic-order-upload-tip {
color: #888;
font-size: 13px;
margin-bottom: 8px;
}
.basic-phone-settings {
margin: 16px 0;
background: #f7f8fa;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
margin-bottom: 12px;
}
.basic-wechat-group {
margin: 16px 0;
}
.basic-footer-switch {
display: flex;
align-items: center;
justify-content: space-between;
margin: 16px 0;
}

View File

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

View File

@@ -1,37 +0,0 @@
import React from "react";
import { NavBar, Button } from "antd-mobile";
import { PlusOutlined } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible";
const WechatAccounts: 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">
<PlusOutlined />
<span style={{ marginLeft: 4, fontSize: 12 }}></span>
</Button>
}
/>
}
footer={<MeauMobile />}
>
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
<h3></h3>
<p>...</p>
</div>
</Layout>
);
};
export default WechatAccounts;

View File

@@ -0,0 +1,26 @@
import request from "@/api/request";
// 获取微信号详情
export function getWechatAccountDetail(id: string) {
return request("/WechatAccount/detail", { id }, "GET");
}
// 获取微信号summary
export function getWechatAccountSummary(id: string) {
return request(`/v1/wechats/${id}/summary`, {}, "GET");
}
// 获取微信号好友列表
export function getWechatFriends(params: {
wechatAccountKeyword: string;
pageIndex: number;
pageSize: number;
friendKeyword?: string;
}) {
return request("/WechatFriend/friendlistData", params, "POST");
}
// 获取微信好友详情
export function getWechatFriendDetail(id: string) {
return request("/v1/WechatFriend/detail", { id }, "GET");
}

View File

@@ -0,0 +1,720 @@
.wechat-account-detail-page {
padding: 16px;
background: linear-gradient(to bottom, #f0f8ff, #ffffff);
min-height: 100vh;
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
}
.account-card {
margin-bottom: 16px;
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e8f4fd;
.account-info {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px;
.avatar-section {
position: relative;
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
border: 4px solid #e8f4fd;
}
.status-dot {
position: absolute;
bottom: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #fff;
&.status-normal {
background: #52c41a;
}
&.status-abnormal {
background: #ff4d4f;
}
}
}
.info-section {
flex: 1;
min-width: 0;
.name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.nickname {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin: 0;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
}
}
.wechat-id {
font-size: 14px;
color: #666;
margin: 0 0 12px 0;
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
.action-btn {
font-size: 12px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #d9d9d9;
background: #fff;
color: #666;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #bfbfbf;
}
}
}
}
}
}
.tabs-card {
border-radius: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e8f4fd;
.tabs {
.adm-tabs-header {
border-bottom: 1px solid #e8e8e8;
background: #fff;
border-radius: 16px 16px 0 0;
.adm-tabs-tab {
font-size: 14px;
font-weight: 500;
color: #666;
transition: all 0.2s;
&.adm-tabs-tab-active {
color: #1677ff;
font-weight: 600;
}
}
.adm-tabs-tab-line {
background: #1677ff;
height: 2px;
}
}
.adm-tabs-content {
padding: 16px;
}
}
}
.overview-content {
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
.info-card {
background: linear-gradient(135deg, #e6f7ff, #f0f8ff);
padding: 16px;
border-radius: 12px;
border: 1px solid #bae7ff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.info-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.info-icon {
font-size: 16px;
color: #1677ff;
padding: 6px;
background: #e6f7ff;
border-radius: 8px;
}
.info-title {
flex: 1;
.title-text {
font-size: 12px;
font-weight: 600;
color: #1677ff;
margin-bottom: 2px;
}
.title-sub {
font-size: 10px;
color: #666;
}
}
}
.info-value {
text-align: right;
font-size: 18px;
font-weight: 700;
color: #1677ff;
.value-unit {
font-size: 12px;
color: #666;
margin-left: 4px;
}
}
}
}
.weight-card {
background: linear-gradient(135deg, #fff7e6, #fff2d9);
padding: 20px;
border-radius: 12px;
border: 1px solid #ffd591;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 16px;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.weight-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.weight-icon {
font-size: 16px;
color: #fa8c16;
margin-right: 8px;
}
.weight-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: #fa8c16;
}
.weight-score {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 16px;
font-weight: 600;
&.text-green-600 {
background: #f6ffed;
color: #52c41a;
}
&.text-yellow-600 {
background: #fffbe6;
color: #fa8c16;
}
&.text-red-600 {
background: #fff2f0;
color: #ff4d4f;
}
.score-value {
font-size: 20px;
font-weight: 700;
}
.score-unit {
font-size: 12px;
}
}
}
.weight-description {
font-size: 12px;
color: #fa8c16;
background: #fff7e6;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #ffd591;
margin-bottom: 16px;
}
.weight-items {
.weight-item {
display: flex;
align-items: center;
margin-bottom: 12px;
.item-label {
flex-shrink: 0;
width: 64px;
font-size: 12px;
font-weight: 500;
color: #fa8c16;
}
.progress-bar {
flex: 1;
margin: 0 12px;
height: 8px;
background: #ffd591;
border-radius: 4px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #fa8c16, #ffa940);
border-radius: 4px;
transition: width 0.5s ease;
}
}
.item-value {
flex-shrink: 0;
width: 40px;
font-size: 12px;
font-weight: 500;
color: #fa8c16;
text-align: right;
}
}
}
}
.restrictions-card {
background: linear-gradient(135deg, #fff2f0, #fff1f0);
padding: 16px;
border-radius: 12px;
border: 1px solid #ffccc7;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
.restrictions-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.restrictions-icon {
font-size: 16px;
color: #ff4d4f;
margin-right: 8px;
}
.restrictions-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: #ff4d4f;
}
.restrictions-btn {
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
}
}
.restrictions-list {
.restriction-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #ffccc7;
&:last-child {
border-bottom: none;
}
.restriction-info {
flex: 1;
.restriction-reason {
display: block;
font-size: 12px;
color: #333;
margin-bottom: 2px;
}
.restriction-date {
font-size: 10px;
color: #666;
}
}
.restriction-level {
font-size: 10px;
padding: 2px 6px;
border-radius: 8px;
font-weight: 500;
&.text-red-600 {
background: #fff2f0;
color: #ff4d4f;
}
&.text-yellow-600 {
background: #fffbe6;
color: #fa8c16;
}
&.text-gray-600 {
background: #f5f5f5;
color: #666;
}
}
}
}
}
}
.friends-content {
.search-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
.search-input-wrapper {
flex: 1;
.adm-input {
border-radius: 8px;
border: 1px solid #d9d9d9;
}
}
.search-btn {
padding: 8px 12px;
border-radius: 8px;
}
}
.friends-list {
.empty {
text-align: center;
color: #999;
padding: 40px 0;
font-size: 14px;
}
.error {
text-align: center;
color: #ff4d4f;
padding: 40px 0;
p {
margin-bottom: 12px;
}
}
.friend-item {
display: flex;
align-items: center;
padding: 12px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #d9d9d9;
}
.friend-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 12px;
}
.friend-info {
flex: 1;
min-width: 0;
.friend-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
.friend-name {
font-size: 14px;
font-weight: 500;
color: #333;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.friend-remark {
color: #666;
margin-left: 4px;
}
}
.friend-arrow {
font-size: 12px;
color: #ccc;
}
}
.friend-wechat-id {
font-size: 12px;
color: #666;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.friend-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
.friend-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 6px;
}
}
}
}
.loading-more {
display: flex;
justify-content: center;
padding: 16px 0;
}
}
}
}
.popup-content {
padding: 20px;
max-height: 80vh;
overflow-y: auto;
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
h3 {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
}
.popup-description {
font-size: 14px;
color: #666;
margin-bottom: 16px;
line-height: 1.5;
}
.popup-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 20px;
}
.restrictions-detail {
.restriction-detail-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.restriction-detail-info {
flex: 1;
.restriction-detail-reason {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.restriction-detail-date {
font-size: 12px;
color: #666;
}
}
.restriction-detail-level {
font-size: 12px;
padding: 4px 8px;
border-radius: 8px;
font-weight: 500;
&.text-red-600 {
background: #fff2f0;
color: #ff4d4f;
}
&.text-yellow-600 {
background: #fffbe6;
color: #fa8c16;
}
&.text-gray-600 {
background: #f5f5f5;
color: #666;
}
}
}
}
.loading-detail {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.error-detail {
text-align: center;
color: #ff4d4f;
padding: 40px 0;
p {
margin-bottom: 12px;
}
}
.friend-detail-content {
.friend-detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
.friend-detail-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
.friend-detail-info {
.friend-detail-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 4px 0;
}
.friend-detail-wechat-id {
font-size: 12px;
color: #666;
margin: 0;
}
}
}
.friend-detail-items {
.detail-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.detail-label {
font-size: 14px;
color: #666;
flex-shrink: 0;
width: 80px;
}
.detail-value {
font-size: 14px;
color: #333;
text-align: right;
flex: 1;
margin-left: 16px;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: flex-end;
flex: 1;
margin-left: 16px;
.detail-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 6px;
}
}
}
}
}
}

View File

@@ -0,0 +1,940 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
NavBar,
Card,
Tabs,
Button,
SpinLoading,
Popup,
Toast,
Input,
Avatar,
Tag,
} from "antd-mobile";
import NavCommon from "@/components/NavCommon";
import {
SearchOutlined,
ReloadOutlined,
UserOutlined,
ClockCircleOutlined,
MessageOutlined,
StarOutlined,
ExclamationCircleOutlined,
RightOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import style from "./detail.module.scss";
import {
getWechatAccountDetail,
getWechatAccountSummary,
getWechatFriends,
getWechatFriendDetail,
} from "./api";
interface WechatAccountSummary {
accountAge: string;
activityLevel: {
allTimes: number;
dayTimes: number;
};
accountWeight: {
scope: number;
ageWeight: number;
activityWeigth: number;
restrictWeight: number;
realNameWeight: number;
};
statistics: {
todayAdded: number;
addLimit: number;
};
restrictions: {
id: number;
level: string;
reason: string;
date: string;
}[];
}
interface Friend {
id: string;
avatar: string;
nickname: string;
wechatId: string;
remark: string;
addTime: string;
lastInteraction: string;
tags: Array<{
id: string;
name: string;
color: string;
}>;
region: string;
source: string;
notes: string;
}
interface WechatFriendDetail {
id: number;
avatar: string;
nickname: string;
region: string;
wechatId: string;
addDate: string;
tags: string[];
memo: string;
source: string;
}
const WechatAccountDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [accountSummary, setAccountSummary] =
useState<WechatAccountSummary | null>(null);
const [accountInfo, setAccountInfo] = useState<any>(null);
const [showRestrictions, setShowRestrictions] = useState(false);
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
const [showFriendDetail, setShowFriendDetail] = useState(false);
const [selectedFriend, setSelectedFriend] = useState<Friend | null>(null);
const [friendDetail, setFriendDetail] = useState<WechatFriendDetail | null>(
null
);
const [isLoadingFriendDetail, setIsLoadingFriendDetail] = useState(false);
const [friendDetailError, setFriendDetailError] = useState<string | null>(
null
);
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("overview");
const [isLoading, setIsLoading] = useState(false);
const [loadingInfo, setLoadingInfo] = useState(true);
const [loadingSummary, setLoadingSummary] = useState(true);
// 好友列表相关状态
const [friends, setFriends] = useState<Friend[]>([]);
const [friendsPage, setFriendsPage] = useState(1);
const [friendsTotal, setFriendsTotal] = useState(0);
const [hasMoreFriends, setHasMoreFriends] = useState(true);
const [isFetchingFriends, setIsFetchingFriends] = useState(false);
const [hasFriendLoadError, setHasFriendLoadError] = useState(false);
const [isFriendsEmpty, setIsFriendsEmpty] = useState(false);
const friendsObserver = useRef<IntersectionObserver | null>(null);
const friendsLoadingRef = useRef<HTMLDivElement | null>(null);
// 获取基础信息
const fetchAccountInfo = useCallback(async () => {
if (!id) return;
setLoadingInfo(true);
try {
const response = await getWechatAccountDetail(id);
if (response && response.data) {
setAccountInfo(response.data);
} else {
Toast.show({
content: response?.msg || "获取账号信息失败",
position: "top",
});
}
} catch (e) {
Toast.show({ content: "获取账号信息失败", position: "top" });
} finally {
setLoadingInfo(false);
}
}, [id]);
// 获取summary
const fetchAccountSummary = useCallback(async () => {
if (!id) return;
setLoadingSummary(true);
try {
const response = await getWechatAccountSummary(id);
if (response && response.data) {
setAccountSummary(response.data);
} else {
Toast.show({
content: response?.msg || "获取账号概览失败",
position: "top",
});
}
} catch (e) {
Toast.show({ content: "获取账号概览失败", position: "top" });
} finally {
setLoadingSummary(false);
}
}, [id]);
// 获取好友列表
const fetchFriends = useCallback(
async (page: number = 1, isNewSearch: boolean = false) => {
if (!id || isFetchingFriends) return;
try {
setIsFetchingFriends(true);
setHasFriendLoadError(false);
const response = await getWechatFriends({
wechatAccountKeyword: id,
pageIndex: page,
pageSize: 20,
friendKeyword: searchQuery,
});
if (response && response.data) {
const newFriends = response.data.list.map((friend: any) => ({
id: friend.id.toString(),
avatar: friend.avatar || "/placeholder.svg",
nickname: friend.nickname || "未知用户",
wechatId: friend.wechatId || "",
remark: friend.memo || "",
addTime:
friend.createTime || new Date().toISOString().split("T")[0],
lastInteraction:
friend.lastInteraction || new Date().toISOString().split("T")[0],
tags: friend.tags
? friend.tags.map((tag: string, index: number) => ({
id: `tag-${index}`,
name: tag,
color: getRandomTagColor(),
}))
: [],
region: friend.region || "未知",
source: friend.source || "未知",
notes: friend.notes || "",
}));
if (isNewSearch) {
setFriends(newFriends);
if (newFriends.length === 0) {
setIsFriendsEmpty(true);
setHasMoreFriends(false);
} else {
setIsFriendsEmpty(false);
setHasMoreFriends(newFriends.length === 20);
}
} else {
setFriends((prev) => [...prev, ...newFriends]);
setHasMoreFriends(newFriends.length === 20);
}
setFriendsTotal(response.data.total);
setFriendsPage(page);
} else {
setHasFriendLoadError(true);
if (isNewSearch) {
setFriends([]);
setIsFriendsEmpty(true);
setHasMoreFriends(false);
}
Toast.show({
content: response?.msg || "获取好友列表失败",
position: "top",
});
}
} catch (error) {
console.error("获取好友列表失败:", error);
setHasFriendLoadError(true);
if (isNewSearch) {
setFriends([]);
setIsFriendsEmpty(true);
setHasMoreFriends(false);
}
Toast.show({
content: "获取好友列表失败,请检查网络连接",
position: "top",
});
} finally {
setIsFetchingFriends(false);
}
},
[id, searchQuery, isFetchingFriends]
);
// 初始化数据
useEffect(() => {
if (id) {
fetchAccountInfo();
fetchAccountSummary();
if (activeTab === "friends") {
fetchFriends(1, true);
}
}
// eslint-disable-next-line
}, [id]);
// 监听标签切换
useEffect(() => {
if (activeTab === "friends" && id) {
setIsFriendsEmpty(false);
setHasFriendLoadError(false);
fetchFriends(1, true);
}
}, [activeTab, id, fetchFriends]);
// 无限滚动加载好友
useEffect(() => {
if (
!friendsLoadingRef.current ||
!hasMoreFriends ||
isFetchingFriends ||
isFriendsEmpty
)
return;
friendsObserver.current = new IntersectionObserver(
(entries) => {
if (
entries[0].isIntersecting &&
hasMoreFriends &&
!isFetchingFriends &&
!isFriendsEmpty
) {
fetchFriends(friendsPage + 1, false);
}
},
{ threshold: 0.1 }
);
friendsObserver.current.observe(friendsLoadingRef.current);
return () => {
if (friendsObserver.current) {
friendsObserver.current.disconnect();
}
};
}, [
hasMoreFriends,
isFetchingFriends,
friendsPage,
fetchFriends,
isFriendsEmpty,
]);
// 工具函数
const getRandomTagColor = (): string => {
const colors = [
"bg-blue-100 text-blue-800",
"bg-green-100 text-green-800",
"bg-red-100 text-red-800",
"bg-pink-100 text-pink-800",
"bg-emerald-100 text-emerald-800",
"bg-amber-100 text-amber-800",
];
return colors[Math.floor(Math.random() * colors.length)];
};
const calculateAccountAge = (registerTime: string) => {
const registerDate = new Date(registerTime);
const now = new Date();
const diffTime = Math.abs(now.getTime() - registerDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const years = Math.floor(diffDays / 365);
const months = Math.floor((diffDays % 365) / 30);
return { years, months };
};
const formatAccountAge = (age: { years: number; months: number }) => {
if (age.years > 0) {
return `${age.years}${age.months}个月`;
}
return `${age.months}个月`;
};
const getWeightColor = (weight: number) => {
if (weight >= 80) return "text-green-600";
if (weight >= 60) return "text-yellow-600";
return "text-red-600";
};
const getWeightDescription = (weight: number) => {
if (weight >= 80) return "账号质量优秀,可以正常使用";
if (weight >= 60) return "账号质量良好,需要注意使用频率";
return "账号质量较差,建议谨慎使用";
};
const handleTransferFriends = () => {
setShowTransferConfirm(true);
};
const confirmTransferFriends = () => {
Toast.show({
content: "好友转移计划已创建,请在场景获客中查看详情",
position: "top",
});
setShowTransferConfirm(false);
navigate("/scenarios");
};
const handleFriendClick = async (friend: Friend) => {
setSelectedFriend(friend);
setShowFriendDetail(true);
setIsLoadingFriendDetail(true);
setFriendDetailError(null);
try {
const response = await getWechatFriendDetail(friend.id);
if (response && response.data) {
setFriendDetail(response.data);
} else {
setFriendDetailError(response?.msg || "获取好友详情失败");
}
} catch (error) {
console.error("获取好友详情失败:", error);
setFriendDetailError("网络错误,请稍后重试");
} finally {
setIsLoadingFriendDetail(false);
}
};
const getRestrictionLevelColor = (level: string) => {
switch (level) {
case "high":
return "text-red-600";
case "medium":
return "text-yellow-600";
default:
return "text-gray-600";
}
};
const formatDateTime = (dateString: string) => {
const date = new Date(dateString);
return date
.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.replace(/\//g, "-");
};
const handleSearch = () => {
setIsFriendsEmpty(false);
setHasFriendLoadError(false);
fetchFriends(1, true);
};
const handleTabChange = (value: string) => {
setActiveTab(value);
};
if (loadingInfo || loadingSummary) {
return (
<Layout
header={
<NavBar back={null} style={{ background: "#fff" }}>
<span className={style["nav-title"]}></span>
</NavBar>
}
>
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
</Layout>
);
}
return (
<Layout header={<NavCommon title="微信号详情" />}>
<div className={style["wechat-account-detail-page"]}>
{/* 账号基本信息卡片 */}
<Card className={style["account-card"]}>
<div className={style["account-info"]}>
<div className={style["avatar-section"]}>
<Avatar
src={accountInfo?.avatar || "/placeholder.svg"}
className={style["avatar"]}
/>
<div
className={`${style["status-dot"]} ${accountInfo?.wechatStatus === 1 ? style["status-normal"] : style["status-abnormal"]}`}
/>
</div>
<div className={style["info-section"]}>
<div className={style["name-row"]}>
<h2 className={style["nickname"]}>
{accountInfo?.nickname || "未知昵称"}
</h2>
<Tag
color={accountInfo?.wechatStatus === 1 ? "success" : "danger"}
className={style["status-tag"]}
>
{accountInfo?.wechatStatus === 1 ? "正常" : "异常"}
</Tag>
</div>
<p className={style["wechat-id"]}>
{accountInfo?.wechatAccount || "未知"}
</p>
<div className={style["action-buttons"]}>
<Button
size="small"
fill="outline"
className={style["action-btn"]}
>
<UserOutlined /> {accountInfo?.deviceMemo || "未知设备"}
</Button>
<Button
size="small"
fill="outline"
className={style["action-btn"]}
onClick={handleTransferFriends}
>
<UserOutlined />
</Button>
</div>
</div>
</div>
</Card>
{/* 标签页 */}
<Card className={style["tabs-card"]}>
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
className={style["tabs"]}
>
<Tabs.Tab title="账号概览" key="overview">
<div className={style["overview-content"]}>
{/* 账号基础信息 */}
<div className={style["info-grid"]}>
<div className={style["info-card"]}>
<div className={style["info-header"]}>
<ClockCircleOutlined className={style["info-icon"]} />
<div className={style["info-title"]}>
<div className={style["title-text"]}></div>
{accountSummary && (
<div className={style["title-sub"]}>
{" "}
{new Date(
accountSummary.accountAge
).toLocaleDateString()}
</div>
)}
</div>
</div>
{accountSummary && (
<div className={style["info-value"]}>
{formatAccountAge(
calculateAccountAge(accountSummary.accountAge)
)}
</div>
)}
</div>
<div className={style["info-card"]}>
<div className={style["info-header"]}>
<MessageOutlined className={style["info-icon"]} />
<div className={style["info-title"]}>
<div className={style["title-text"]}></div>
{accountSummary && (
<div className={style["title-sub"]}>
{" "}
{accountSummary.activityLevel.allTimes.toLocaleString()}{" "}
</div>
)}
</div>
</div>
{accountSummary && (
<div className={style["info-value"]}>
{accountSummary.activityLevel.dayTimes.toLocaleString()}
<span className={style["value-unit"]}>/</span>
</div>
)}
</div>
</div>
{/* 账号权重评估 */}
{accountSummary && (
<div className={style["weight-card"]}>
<div className={style["weight-header"]}>
<StarOutlined className={style["weight-icon"]} />
<span className={style["weight-title"]}>
</span>
<div
className={`${style["weight-score"]} ${getWeightColor(accountSummary.accountWeight.scope)}`}
>
<span className={style["score-value"]}>
{accountSummary.accountWeight.scope}
</span>
<span className={style["score-unit"]}></span>
</div>
</div>
<p className={style["weight-description"]}>
{getWeightDescription(accountSummary.accountWeight.scope)}
</p>
<div className={style["weight-items"]}>
<div className={style["weight-item"]}>
<span className={style["item-label"]}></span>
<div className={style["progress-bar"]}>
<div
className={style["progress-fill"]}
style={{
width: `${accountSummary.accountWeight.ageWeight}%`,
}}
/>
</div>
<span className={style["item-value"]}>
{accountSummary.accountWeight.ageWeight}%
</span>
</div>
<div className={style["weight-item"]}>
<span className={style["item-label"]}></span>
<div className={style["progress-bar"]}>
<div
className={style["progress-fill"]}
style={{
width: `${accountSummary.accountWeight.activityWeigth}%`,
}}
/>
</div>
<span className={style["item-value"]}>
{accountSummary.accountWeight.activityWeigth}%
</span>
</div>
</div>
</div>
)}
{/* 限制记录 */}
{accountSummary &&
accountSummary.restrictions &&
accountSummary.restrictions.length > 0 && (
<div className={style["restrictions-card"]}>
<div className={style["restrictions-header"]}>
<ExclamationCircleOutlined
className={style["restrictions-icon"]}
/>
<span className={style["restrictions-title"]}>
</span>
<Button
size="small"
fill="outline"
onClick={() => setShowRestrictions(true)}
className={style["restrictions-btn"]}
>
</Button>
</div>
<div className={style["restrictions-list"]}>
{accountSummary.restrictions
.slice(0, 3)
.map((restriction) => (
<div
key={restriction.id}
className={style["restriction-item"]}
>
<div className={style["restriction-info"]}>
<span className={style["restriction-reason"]}>
{restriction.reason}
</span>
<span className={style["restriction-date"]}>
{formatDateTime(restriction.date)}
</span>
</div>
<span
className={`${style["restriction-level"]} ${getRestrictionLevelColor(restriction.level)}`}
>
{restriction.level === "high"
? "高风险"
: restriction.level === "medium"
? "中风险"
: "低风险"}
</span>
</div>
))}
</div>
</div>
)}
</div>
</Tabs.Tab>
<Tabs.Tab
title={`好友列表${activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ""}`}
key="friends"
>
<div className={style["friends-content"]}>
{/* 搜索栏 */}
<div className={style["search-bar"]}>
<div className={style["search-input-wrapper"]}>
<Input
placeholder="搜索好友昵称/微信号"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
onPressEnter={handleSearch}
/>
</div>
<Button
size="small"
onClick={handleSearch}
loading={isFetchingFriends}
className={style["search-btn"]}
>
<ReloadOutlined />
</Button>
</div>
{/* 好友列表 */}
<div className={style["friends-list"]}>
{isFriendsEmpty ? (
<div className={style["empty"]}></div>
) : hasFriendLoadError ? (
<div className={style["error"]}>
<p></p>
<Button
size="small"
onClick={() => fetchFriends(1, true)}
>
</Button>
</div>
) : (
<>
{friends.map((friend) => (
<div
key={friend.id}
className={style["friend-item"]}
onClick={() => handleFriendClick(friend)}
>
<Avatar
src={friend.avatar}
className={style["friend-avatar"]}
/>
<div className={style["friend-info"]}>
<div className={style["friend-header"]}>
<div className={style["friend-name"]}>
{friend.nickname}
{friend.remark && (
<span className={style["friend-remark"]}>
({friend.remark})
</span>
)}
</div>
<RightOutlined
className={style["friend-arrow"]}
/>
</div>
<div className={style["friend-wechat-id"]}>
{friend.wechatId}
</div>
<div className={style["friend-tags"]}>
{friend.tags?.map((tag, index) => (
<Tag
key={index}
size="small"
className={style["friend-tag"]}
>
{typeof tag === "string" ? tag : tag.name}
</Tag>
))}
</div>
</div>
</div>
))}
{hasMoreFriends && !isFriendsEmpty && (
<div
ref={friendsLoadingRef}
className={style["loading-more"]}
>
<SpinLoading
color="primary"
style={{ fontSize: 24 }}
/>
</div>
)}
</>
)}
</div>
</div>
</Tabs.Tab>
</Tabs>
</Card>
</div>
{/* 限制记录详情弹窗 */}
<Popup
visible={showRestrictions}
onMaskClick={() => setShowRestrictions(false)}
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
>
<div className={style["popup-content"]}>
<div className={style["popup-header"]}>
<h3></h3>
<Button
size="small"
fill="outline"
onClick={() => setShowRestrictions(false)}
>
</Button>
</div>
<p className={style["popup-description"]}>24</p>
{accountSummary && accountSummary.restrictions && (
<div className={style["restrictions-detail"]}>
{accountSummary.restrictions.map((restriction) => (
<div
key={restriction.id}
className={style["restriction-detail-item"]}
>
<div className={style["restriction-detail-info"]}>
<div className={style["restriction-detail-reason"]}>
{restriction.reason}
</div>
<div className={style["restriction-detail-date"]}>
{formatDateTime(restriction.date)}
</div>
</div>
<span
className={`${style["restriction-detail-level"]} ${getRestrictionLevelColor(restriction.level)}`}
>
{restriction.level === "high"
? "高风险"
: restriction.level === "medium"
? "中风险"
: "低风险"}
</span>
</div>
))}
</div>
)}
</div>
</Popup>
{/* 好友转移确认弹窗 */}
<Popup
visible={showTransferConfirm}
onMaskClick={() => setShowTransferConfirm(false)}
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
>
<div className={style["popup-content"]}>
<div className={style["popup-header"]}>
<h3></h3>
</div>
<p className={style["popup-description"]}>
</p>
<div className={style["popup-actions"]}>
<Button block color="primary" onClick={confirmTransferFriends}>
</Button>
<Button
block
color="danger"
fill="outline"
onClick={() => setShowTransferConfirm(false)}
>
</Button>
</div>
</div>
</Popup>
{/* 好友详情弹窗 */}
<Popup
visible={showFriendDetail}
onMaskClick={() => setShowFriendDetail(false)}
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
>
<div className={style["popup-content"]}>
<div className={style["popup-header"]}>
<h3></h3>
<Button
size="small"
fill="outline"
onClick={() => setShowFriendDetail(false)}
>
</Button>
</div>
{isLoadingFriendDetail ? (
<div className={style["loading-detail"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : friendDetailError ? (
<div className={style["error-detail"]}>
<p>{friendDetailError}</p>
<Button
size="small"
onClick={() => handleFriendClick(selectedFriend!)}
>
</Button>
</div>
) : friendDetail && selectedFriend ? (
<div className={style["friend-detail-content"]}>
<div className={style["friend-detail-header"]}>
<Avatar
src={selectedFriend.avatar}
className={style["friend-detail-avatar"]}
/>
<div className={style["friend-detail-info"]}>
<h4 className={style["friend-detail-name"]}>
{selectedFriend.nickname}
</h4>
<p className={style["friend-detail-wechat-id"]}>
{selectedFriend.wechatId}
</p>
</div>
</div>
<div className={style["friend-detail-items"]}>
<div className={style["detail-item"]}>
<span className={style["detail-label"]}></span>
<span className={style["detail-value"]}>
{friendDetail.region || "未知"}
</span>
</div>
<div className={style["detail-item"]}>
<span className={style["detail-label"]}></span>
<span className={style["detail-value"]}>
{friendDetail.addDate}
</span>
</div>
<div className={style["detail-item"]}>
<span className={style["detail-label"]}></span>
<span className={style["detail-value"]}>
{friendDetail.source || "未知"}
</span>
</div>
{friendDetail.memo && (
<div className={style["detail-item"]}>
<span className={style["detail-label"]}></span>
<span className={style["detail-value"]}>
{friendDetail.memo}
</span>
</div>
)}
{friendDetail.tags && friendDetail.tags.length > 0 && (
<div className={style["detail-item"]}>
<span className={style["detail-label"]}></span>
<div className={style["detail-tags"]}>
{friendDetail.tags.map((tag, index) => (
<Tag
key={index}
size="small"
className={style["detail-tag"]}
>
{tag}
</Tag>
))}
</div>
</div>
)}
</div>
</div>
) : null}
</div>
</Popup>
</Layout>
);
};
export default WechatAccountDetail;

View File

@@ -0,0 +1,30 @@
import request from "@/api/request";
// 获取微信号列表
export function getWechatAccounts(params: {
page: number;
page_size: number;
keyword?: string;
}) {
return request("v1/wechats", params, "GET");
}
// 获取微信号详情
export function getWechatAccountDetail(id: string) {
return request("v1/WechatAccount/detail", { id }, "GET");
}
// 获取微信号好友列表
export function getWechatFriends(params: {
wechatAccountKeyword: string;
pageIndex: number;
pageSize: number;
friendKeyword?: string;
}) {
return request("v1/WechatFriend/friendlistData", params, "POST");
}
// 获取微信好友详情
export function getWechatFriendDetail(id: string) {
return request("v1/WechatFriend/detail", { id }, "GET");
}

View File

@@ -0,0 +1,171 @@
.wechat-accounts-page {
padding: 0 12px;
}
.nav-title {
font-size: 18px;
font-weight: 600;
color: #222;
}
.card-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.account-card {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 14px 14px 10px 14px;
transition: box-shadow 0.2s;
cursor: pointer;
border: 1px solid #f0f0f0;
&:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.10);
border-color: #e6f7ff;
}
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.avatar-wrapper {
position: relative;
margin-right: 12px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
border: 3px solid #e6f0fa;
box-shadow: 0 0 0 2px #1677ff33;
object-fit: cover;
}
.status-dot-normal {
position: absolute;
right: -2px;
bottom: -2px;
width: 14px;
height: 14px;
background: #52c41a;
border: 2px solid #fff;
border-radius: 50%;
}
.status-dot-abnormal {
position: absolute;
right: -2px;
bottom: -2px;
width: 14px;
height: 14px;
background: #ff4d4f;
border: 2px solid #fff;
border-radius: 50%;
}
.header-info {
flex: 1;
min-width: 0;
}
.nickname-row {
display: flex;
align-items: center;
gap: 8px;
}
.nickname {
font-weight: 600;
font-size: 16px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-label-normal {
background: #e6fffb;
color: #13c2c2;
font-size: 12px;
border-radius: 8px;
padding: 2px 8px;
margin-left: 4px;
}
.status-label-abnormal {
background: #fff1f0;
color: #ff4d4f;
font-size: 12px;
border-radius: 8px;
padding: 2px 8px;
margin-left: 4px;
}
.wechat-id {
color: #888;
font-size: 13px;
margin-top: 2px;
}
.card-action {
margin-left: 8px;
}
.card-body {
margin-top: 2px;
}
.row-group {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
gap: 8px;
}
.row-item {
font-size: 13px;
color: #555;
display: flex;
align-items: center;
gap: 2px;
}
.strong {
font-weight: 600;
color: #222;
}
.strong-green {
font-weight: 600;
color: #52c41a;
}
.progress-bar {
margin: 6px 0 8px 0;
}
.progress-bg {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 8px;
background: linear-gradient(90deg, #1677ff 0%, #69c0ff 100%);
border-radius: 6px;
transition: width 0.3s;
}
.pagination {
margin: 16px 0 0 0;
display: flex;
justify-content: center;
}
.popup-content {
padding: 16px 0 8px 0;
}
.popup-content img {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.empty {
text-align: center;
color: #999;
padding: 48px 0 32px 0;
font-size: 15px;
}

View File

@@ -0,0 +1,309 @@
import React, { useState, useEffect } from "react";
import {
NavBar,
List,
Card,
Button,
SpinLoading,
Popup,
Toast,
} from "antd-mobile";
import { Pagination, Input, Tooltip } from "antd";
import {
ArrowLeftOutlined,
SearchOutlined,
ReloadOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss";
import { getWechatAccounts } from "./api";
interface WechatAccount {
id: number;
nickname: string;
avatar: string;
wechatId: string;
wechatAccount: string;
deviceId: number;
times: number; // 今日可添加
addedCount: number; // 今日新增
wechatStatus: number; // 1正常 0异常
totalFriend: number;
deviceMemo: string; // 设备名
activeTime: string; // 最后活跃
}
const PAGE_SIZE = 10;
const WechatAccounts: React.FC = () => {
const navigate = useNavigate();
const [accounts, setAccounts] = useState<WechatAccount[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalAccounts, setTotalAccounts] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [popupVisible, setPopupVisible] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<WechatAccount | null>(
null
);
const fetchAccounts = async (page = 1, keyword = "") => {
setIsLoading(true);
try {
const res = await getWechatAccounts({
page,
page_size: PAGE_SIZE,
keyword,
});
if (res && res.list) {
setAccounts(res.list);
setTotalAccounts(res.total || 0);
} else {
setAccounts([]);
setTotalAccounts(0);
}
} catch (e) {
Toast.show({ content: "获取微信号失败", position: "top" });
setAccounts([]);
setTotalAccounts(0);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchAccounts(currentPage, searchTerm);
// eslint-disable-next-line
}, [currentPage]);
const handleSearch = () => {
setCurrentPage(1);
fetchAccounts(1, searchTerm);
};
const handleRefresh = async () => {
setIsRefreshing(true);
await fetchAccounts(currentPage, searchTerm);
setIsRefreshing(false);
Toast.show({ content: "刷新成功", position: "top" });
};
const handleAccountClick = (account: WechatAccount) => {
setSelectedAccount(account);
setPopupVisible(true);
};
const handleTransferFriends = (account: WechatAccount) => {
// TODO: 实现好友转移弹窗或跳转
Toast.show({ content: `好友转移:${account.nickname}` });
};
return (
<Layout
header={
<>
<NavBar
back={null}
style={{ background: "#fff" }}
left={
<div className={style["nav-title"]}>
<ArrowLeftOutlined
twoToneColor="#1677ff"
onClick={() => navigate(-1)}
/>
</div>
}
>
<span className={style["nav-title"]}></span>
</NavBar>
<div className="search-bar">
<div className="search-input-wrapper">
<Input
placeholder="搜索微信号/昵称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
prefix={<SearchOutlined />}
allowClear
size="large"
onPressEnter={handleSearch}
/>
</div>
<Button
size="small"
onClick={handleRefresh}
loading={isRefreshing}
className="refresh-btn"
>
<ReloadOutlined />
</Button>
</div>
</>
}
>
<div className={style["wechat-accounts-page"]}>
{isLoading ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : accounts.length === 0 ? (
<div className={style["empty"]}></div>
) : (
<div className={style["card-list"]}>
{accounts.map((account) => {
const percent =
account.times > 0
? Math.min((account.addedCount / account.times) * 100, 100)
: 0;
return (
<div
key={account.id}
className={style["account-card"]}
onClick={() => handleAccountClick(account)}
>
<div className={style["card-header"]}>
<div className={style["avatar-wrapper"]}>
<img
src={account.avatar}
alt={account.nickname}
className={style["avatar"]}
/>
<span
className={
account.wechatStatus === 1
? style["status-dot-normal"]
: style["status-dot-abnormal"]
}
/>
</div>
<div className={style["header-info"]}>
<div className={style["nickname-row"]}>
<span className={style["nickname"]}>
{account.nickname}
</span>
<span
className={
account.wechatStatus === 1
? style["status-label-normal"]
: style["status-label-abnormal"]
}
>
{account.wechatStatus === 1 ? "正常" : "异常"}
</span>
</div>
<div className={style["wechat-id"]}>
{account.wechatAccount}
</div>
</div>
</div>
<div className={style["card-body"]}>
<div className={style["row-group"]}>
<div className={style["row-item"]}>
<span></span>
<span className={style["strong"]}>
{account.totalFriend}
</span>
</div>
<div className={style["row-item"]}>
<span></span>
<span className={style["strong-green"]}>
+{account.addedCount}
</span>
</div>
</div>
<div className={style["row-group"]}>
<div className={style["row-item"]}>
<span></span>
<span>{account.times}</span>
</div>
<div className={style["row-item"]}>
<Tooltip title={`每日最多添加 ${account.times} 个好友`}>
<span></span>
<span>
{account.addedCount}/{account.times}
</span>
</Tooltip>
</div>
</div>
<div className={style["progress-bar"]}>
<div className={style["progress-bg"]}>
<div
className={style["progress-fill"]}
style={{ width: `${percent}%` }}
/>
</div>
</div>
<div className={style["row-group"]}>
<div className={style["row-item"]}>
<span></span>
<span>{account.deviceMemo || "-"}</span>
</div>
<div className={style["row-item"]}>
<span></span>
<span>{account.activeTime}</span>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
<div className={style["pagination"]}>
{totalAccounts > PAGE_SIZE && (
<Pagination
total={Math.ceil(totalAccounts / PAGE_SIZE)}
current={currentPage}
onChange={setCurrentPage}
/>
)}
</div>
<Popup
visible={popupVisible}
onMaskClick={() => setPopupVisible(false)}
bodyStyle={{ borderRadius: "16px 16px 0 0" }}
>
{selectedAccount && (
<div className={style["popup-content"]}>
<div style={{ textAlign: "center", margin: 16 }}>
<img
src={selectedAccount.avatar}
alt="avatar"
style={{ width: 60, height: 60, borderRadius: 30 }}
/>
<div style={{ fontWeight: 600, marginTop: 8 }}>
{selectedAccount.nickname}
</div>
<div style={{ color: "#888", fontSize: 12 }}>
{selectedAccount.wechatAccount}
</div>
</div>
<div style={{ margin: 16 }}>
<Button
block
color="primary"
onClick={() => {
navigate(`/wechat-accounts/detail/${selectedAccount.id}`);
}}
>
</Button>
</div>
<Button
block
color="danger"
fill="outline"
onClick={() => setPopupVisible(false)}
>
</Button>
</div>
)}
</Popup>
</div>
</Layout>
);
};
export default WechatAccounts;

View 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");

View File

@@ -84,7 +84,7 @@
} }
.inputTime { .inputTime {
width: 90px; width: 100px;
height: 40px; height: 40px;
border-radius: 8px; border-radius: 8px;
font-size: 15px; font-size: 15px;

View File

@@ -7,8 +7,19 @@ import { NavBar } from "antd-mobile";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import style from "./index.module.scss"; import style from "./index.module.scss";
import request from "@/api/request"; 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 = { const defaultForm = {
taskName: "", taskName: "",
@@ -34,7 +45,7 @@ const NewMomentsSync: React.FC = () => {
if (!id) return; if (!id) return;
setLoading(true); setLoading(true);
try { try {
const res = await request("/v1/workbench/detail", { id }, "GET"); const res = await getMomentsSyncDetail(id);
if (res) { if (res) {
setFormData({ setFormData({
taskName: res.name, taskName: res.name,
@@ -95,11 +106,11 @@ const NewMomentsSync: React.FC = () => {
type: 2, type: 2,
}; };
if (isEditMode && id) { if (isEditMode && id) {
await request("/v1/workbench/update", { id, ...params }, "POST"); await updateMomentsSync({ id, ...params });
message.success("更新成功"); message.success("更新成功");
navigate(`/workspace/moments-sync/${id}`); navigate(`/workspace/moments-sync/${id}`);
} else { } else {
await request("/v1/workbench/create", params, "POST"); await createMomentsSync(params);
message.success("创建成功"); message.success("创建成功");
navigate("/workspace/moments-sync"); navigate("/workspace/moments-sync");
} }
@@ -214,18 +225,14 @@ const NewMomentsSync: React.FC = () => {
return ( return (
<div className={style.formStep}> <div className={style.formStep}>
<div className={style.formItem}> <div className={style.formItem}>
<Input <div className={style.formLabel}></div>
placeholder="选择设备" <DeviceSelection
prefix={<span className={style.searchIcon}>Q</span>} selectedDevices={formData.selectedDevices}
className={style.searchInput} onSelect={(devices) => updateForm({ selectedDevices: devices })}
onClick={() => message.info("这里应弹出设备选择器")} placeholder="请选择设备"
readOnly showSelectedList={true}
selectedListMaxHeight={200}
/> />
{formData.selectedDevices.length > 0 && (
<div className={style.selectedTip}>
: {formData.selectedDevices.length}
</div>
)}
</div> </div>
<div className={style.formStepBtnRow}> <div className={style.formStepBtnRow}>
<Button onClick={prev} className={style.prevBtn}> <Button onClick={prev} className={style.prevBtn}>
@@ -294,25 +301,7 @@ const NewMomentsSync: React.FC = () => {
} }
> >
<div className={style.formBg}> <div className={style.formBg}>
<div className={style.formSteps}> <StepIndicator currentStep={currentStep + 1} steps={steps} />
{steps.map((s, i) => (
<div
key={s}
className={
style.formStepIndicator +
" " +
(i === currentStep
? style.formStepActive
: i < currentStep
? style.formStepDone
: "")
}
>
<span className={style.formStepNum}>{i + 1}</span>
<span>{s}</span>
</div>
))}
</div>
{loading ? ( {loading ? (
<div className={style.formLoading}> <div className={style.formLoading}>
<Spin /> <Spin />

View File

@@ -0,0 +1,11 @@
import ComponentTest from "@/pages/component-test";
const componentTestRoutes = [
{
path: "/component-test",
element: <ComponentTest />,
auth: true,
},
];
export default componentTestRoutes;

View File

@@ -1,5 +1,7 @@
import Home from "@/pages/home/index"; import Home from "@/pages/home/index";
import Mine from "@/pages/mine/index"; import Mine from "@/pages/mine/index";
import WechatAccounts from "@/pages/wechat-accounts/list/index";
import WechatAccountDetail from "@/pages/wechat-accounts/detail/index";
const routes = [ const routes = [
// 基础路由 // 基础路由
@@ -13,6 +15,17 @@ const routes = [
element: <Mine />, element: <Mine />,
auth: true, auth: true,
}, },
// 微信号管理路由
{
path: "/wechat-accounts",
element: <WechatAccounts />,
auth: true,
},
{
path: "/wechat-accounts/detail/:id",
element: <WechatAccountDetail />,
auth: true,
},
]; ];
export default routes; export default routes;

View File

@@ -1,17 +1,17 @@
import WechatAccounts from "@/pages/wechat-accounts/WechatAccounts"; import WechatAccounts from "@/pages/wechat-accounts/list";
import WechatAccountDetail from "@/pages/wechat-accounts/WechatAccountDetail"; import WechatAccountDetail from "@/pages/wechat-accounts/detail";
const wechatAccountRoutes = [ const wechatAccountRoutes = [
{ {
path: "/wechat-accounts", path: "/wechat-accounts",
element: <WechatAccounts />, element: <WechatAccounts />,
auth: true, auth: true,
}, },
{ {
path: "/wechat-accounts/:id", path: "/wechat-accounts/detail/:id",
element: <WechatAccountDetail />, element: <WechatAccountDetail />,
auth: true, auth: true,
}, },
]; ];
export default wechatAccountRoutes; export default wechatAccountRoutes;