Merge branch 'yongpxu-dev' into yongpxu-dev2

# Conflicts:
#	nkebao/.env.development   resolved by yongpxu-dev version
#	nkebao/src/components/SelectionTest.tsx   resolved by yongpxu-dev2 version
This commit is contained in:
笔记本里的永平
2025-07-22 19:24:15 +08:00
11 changed files with 1255 additions and 212 deletions

View File

@@ -1,5 +1,5 @@
# 基础环境变量示例
# 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

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

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

View File

@@ -205,13 +205,21 @@
}
.popupFooter {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.selectedCount {
font-size: 14px;
color: #888;
}
.footerBtnGroup {
display: flex;
gap: 12px;
}
.cancelBtn {
padding: 0 24px;
border-radius: 24px;

View File

@@ -1,11 +1,7 @@
import React, { useState, useEffect } from "react";
import {
SearchOutlined,
CloseOutlined,
LeftOutlined,
RightOutlined,
} from "@ant-design/icons";
import { Input, Button, Popup, Toast } from "antd-mobile";
import { SearchOutlined, DeleteOutlined } from "@ant-design/icons";
import { Popup, Toast } from "antd-mobile";
import { Button, Input } from "antd";
import { getFriendList } from "./api";
import style from "./index.module.scss";
@@ -29,6 +25,11 @@ interface FriendSelectionProps {
className?: string;
visible?: boolean; // 新增
onVisibleChange?: (visible: boolean) => void; // 新增
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedIds: string[], selectedItems: WechatFriend[]) => void; // 新增
}
export default function FriendSelection({
@@ -41,6 +42,11 @@ export default function FriendSelection({
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: FriendSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [friends, setFriends] = useState<WechatFriend[]>([]);
@@ -59,6 +65,7 @@ export default function FriendSelection({
// 打开弹窗并请求第一页好友
const openPopup = () => {
if (readonly) return;
setCurrentPage(1);
setSearchQuery("");
setRealVisible(true);
@@ -152,8 +159,23 @@ export default function FriendSelection({
return `已选择 ${selectedFriends.length} 个好友`;
};
// 获取已选好友详细信息
const selectedFriendObjs = selectedFriends
.map((id) => friends.find((f) => f.id === id))
.filter(Boolean) as WechatFriend[];
// 删除已选好友
const handleRemoveFriend = (id: string) => {
if (readonly) return;
onSelect(selectedFriends.filter((f) => f !== id));
};
// 确认按钮逻辑
const handleConfirm = () => {
setPopupVisible(false);
setRealVisible(false);
if (onConfirm) {
onConfirm(selectedFriends, selectedFriendObjs);
}
};
// 清空搜索
@@ -166,35 +188,85 @@ export default function FriendSelection({
return (
<>
{/* 输入框 */}
<div className={`${style.inputWrapper} ${className}`}>
<span className={style.inputIcon}>
<svg
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</span>
<Input
placeholder={placeholder}
className={style.input}
readOnly
onClick={openPopup}
value={getDisplayText()}
/>
</div>
{/* 微信好友选择弹窗 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<Input
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选好友列表窗口 */}
{showSelectedList && selectedFriendObjs.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedFriendObjs.map((friend) => (
<div
key={friend.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{friend.nickname || friend.wechatId || friend.id}
</div>
{!readonly && (
<Button
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveFriend(friend.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible}
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
@@ -206,23 +278,33 @@ export default function FriendSelection({
<Input
placeholder="搜索好友"
value={searchQuery}
onChange={(val) => setSearchQuery(val)}
className={style.searchInput}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={readonly}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
<SearchOutlined className={style.searchIcon} />
{searchQuery && (
{searchQuery && !readonly && (
<Button
fill="none"
size="mini"
type="text"
icon={<DeleteOutlined />}
size="small"
className={style.clearBtn}
onClick={handleClearSearch}
>
<CloseOutlined />
</Button>
style={{
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
)}
</div>
</div>
<div className={style.friendList}>
{loading ? (
<div className={style.loadingBox}>
@@ -234,7 +316,7 @@ export default function FriendSelection({
<label
key={friend.id}
className={style.friendItem}
onClick={() => handleFriendToggle(friend.id)}
onClick={() => !readonly && handleFriendToggle(friend.id)}
>
<div className={style.radioWrapper}>
<div
@@ -290,54 +372,48 @@ export default function FriendSelection({
</div>
)}
</div>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}> {totalFriends} </div>
<div className={style.paginationControls}>
<Button
fill="none"
size="mini"
type="text"
size="small"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<LeftOutlined />
&lt;
</Button>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
fill="none"
size="mini"
type="text"
size="small"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<RightOutlined />
&gt;
</Button>
</div>
</div>
{/* 底部按钮栏 */}
<div className={style.popupFooter}>
<Button
fill="outline"
onClick={() => setRealVisible(false)}
className={style.cancelBtn}
>
</Button>
<Button
color="primary"
onClick={() => {
setRealVisible(false);
handleConfirm();
}}
className={style.confirmBtn}
>
({selectedFriends.length})
</Button>
<div className={style.selectedCount}>
{selectedFriends.length}
</div>
<div className={style.footerBtnGroup}>
<Button onClick={() => setRealVisible(false)}></Button>
<Button type="primary" onClick={handleConfirm}>
</Button>
</div>
</div>
</div>
</Popup>

View File

@@ -205,19 +205,18 @@
}
.popupFooter {
border-top: 1px solid #f0f0f0;
padding: 16px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.cancelBtn {
padding: 0 24px;
border-radius: 24px;
border: 1px solid #e5e6eb;
.selectedCount {
font-size: 14px;
color: #888;
}
.confirmBtn {
padding: 0 24px;
border-radius: 24px;
.footerBtnGroup {
display: flex;
gap: 12px;
}

View File

@@ -4,8 +4,10 @@ import {
CloseOutlined,
LeftOutlined,
RightOutlined,
DeleteOutlined,
} from "@ant-design/icons";
import { Input, Button, Popup, Toast } from "antd-mobile";
import { Button as AntdButton, Input as AntdInput } from "antd";
import { Popup, Toast } from "antd-mobile";
import { getGroupList } from "./api";
import style from "./index.module.scss";
@@ -27,8 +29,13 @@ interface GroupSelectionProps {
onSelectDetail?: (groups: WechatGroup[]) => void;
placeholder?: string;
className?: string;
visible?: boolean; // 新增
onVisibleChange?: (visible: boolean) => void; // 新增
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
selectedListMaxHeight?: number;
showInput?: boolean;
showSelectedList?: boolean;
readonly?: boolean;
onConfirm?: (selectedIds: string[], selectedItems: WechatGroup[]) => void; // 新增
}
export default function GroupSelection({
@@ -39,6 +46,11 @@ export default function GroupSelection({
className = "",
visible,
onVisibleChange,
selectedListMaxHeight = 300,
showInput = true,
showSelectedList = true,
readonly = false,
onConfirm,
}: GroupSelectionProps) {
const [popupVisible, setPopupVisible] = useState(false);
const [groups, setGroups] = useState<WechatGroup[]>([]);
@@ -48,6 +60,17 @@ export default function GroupSelection({
const [totalGroups, setTotalGroups] = useState(0);
const [loading, setLoading] = useState(false);
// 获取已选群聊详细信息
const selectedGroupObjs = selectedGroups
.map((id) => groups.find((g) => g.id === id))
.filter(Boolean) as WechatGroup[];
// 删除已选群聊
const handleRemoveGroup = (id: string) => {
if (readonly) return;
onSelect(selectedGroups.filter((g) => g !== id));
};
// 受控弹窗逻辑
const realVisible = visible !== undefined ? visible : popupVisible;
const setRealVisible = (v: boolean) => {
@@ -55,8 +78,9 @@ export default function GroupSelection({
if (visible === undefined) setPopupVisible(v);
};
// 打开弹窗并请求第一页群组
// 打开弹窗
const openPopup = () => {
if (readonly) return;
setCurrentPage(1);
setSearchQuery("");
setRealVisible(true);
@@ -138,8 +162,12 @@ export default function GroupSelection({
return `已选择 ${selectedGroups.length} 个群聊`;
};
// 确认按钮逻辑
const handleConfirm = () => {
setRealVisible(false);
if (onConfirm) {
onConfirm(selectedGroups, selectedGroupObjs);
}
};
// 清空搜索
@@ -152,35 +180,85 @@ export default function GroupSelection({
return (
<>
{/* 输入框 */}
<div className={`${style.inputWrapper} ${className}`}>
<span className={style.inputIcon}>
<svg
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</span>
<Input
placeholder={placeholder}
className={style.input}
readOnly
onClick={openPopup}
value={getDisplayText()}
/>
</div>
{/* 群组选择弹窗 */}
{showInput && (
<div className={`${style.inputWrapper} ${className}`}>
<AntdInput
placeholder={placeholder}
value={getDisplayText()}
onClick={openPopup}
prefix={<SearchOutlined />}
allowClear={!readonly}
size="large"
readOnly={readonly}
disabled={readonly}
style={
readonly ? { background: "#f5f5f5", cursor: "not-allowed" } : {}
}
/>
</div>
)}
{/* 已选群聊列表窗口 */}
{showSelectedList && selectedGroupObjs.length > 0 && (
<div
className={style.selectedListWindow}
style={{
maxHeight: selectedListMaxHeight,
overflowY: "auto",
marginTop: 8,
border: "1px solid #e5e6eb",
borderRadius: 8,
background: "#fff",
}}
>
{selectedGroupObjs.map((group) => (
<div
key={group.id}
className={style.selectedListRow}
style={{
display: "flex",
alignItems: "center",
padding: "4px 8px",
borderBottom: "1px solid #f0f0f0",
fontSize: 14,
}}
>
<div
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{group.name || group.chatroomId || group.id}
</div>
{!readonly && (
<AntdButton
type="text"
icon={<DeleteOutlined />}
size="small"
style={{
marginLeft: 4,
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => handleRemoveGroup(group.id)}
/>
)}
</div>
))}
</div>
)}
{/* 弹窗 */}
<Popup
visible={realVisible}
visible={realVisible && !readonly}
onMaskClick={() => setRealVisible(false)}
position="bottom"
bodyStyle={{ height: "100vh" }}
@@ -189,26 +267,37 @@ export default function GroupSelection({
<div className={style.popupHeader}>
<div className={style.popupTitle}></div>
<div className={style.searchWrapper}>
<Input
<AntdInput
placeholder="搜索群聊"
value={searchQuery}
onChange={(val) => setSearchQuery(val)}
className={style.searchInput}
onChange={(e) => setSearchQuery(e.target.value)}
disabled={readonly}
prefix={<SearchOutlined />}
allowClear
size="large"
/>
<SearchOutlined className={style.searchIcon} />
{searchQuery && (
<Button
fill="none"
size="mini"
{searchQuery && !readonly && (
<AntdButton
type="text"
icon={<CloseOutlined />}
size="small"
className={style.clearBtn}
onClick={handleClearSearch}
>
<CloseOutlined />
</Button>
style={{
color: "#ff4d4f",
border: "none",
background: "none",
minWidth: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
/>
)}
</div>
</div>
<div className={style.groupList}>
{loading ? (
<div className={style.loadingBox}>
@@ -220,7 +309,7 @@ export default function GroupSelection({
<label
key={group.id}
className={style.groupItem}
onClick={() => handleGroupToggle(group.id)}
onClick={() => !readonly && handleGroupToggle(group.id)}
>
<div className={style.radioWrapper}>
<div
@@ -272,57 +361,50 @@ export default function GroupSelection({
</div>
)}
</div>
{/* 分页栏 */}
<div className={style.paginationRow}>
<div className={style.totalCount}>
{totalGroups}
{searchQuery && ` (搜索: "${searchQuery}")`}
</div>
<div className={style.totalCount}> {totalGroups} </div>
<div className={style.paginationControls}>
<Button
fill="none"
size="mini"
<AntdButton
type="text"
size="small"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1 || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<LeftOutlined />
</Button>
</AntdButton>
<span className={style.pageInfo}>
{currentPage} / {totalPages}
</span>
<Button
fill="none"
size="mini"
<AntdButton
type="text"
size="small"
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className={style.pageBtn}
style={{ borderRadius: 16 }}
>
<RightOutlined />
</Button>
</AntdButton>
</div>
</div>
{/* 底部按钮栏 */}
<div className={style.popupFooter}>
<Button
fill="outline"
onClick={() => setRealVisible(false)}
className={style.cancelBtn}
>
</Button>
<Button
color="primary"
onClick={() => {
setRealVisible(false);
handleConfirm();
}}
className={style.confirmBtn}
>
({selectedGroups.length})
</Button>
<div className={style.selectedCount}>
{selectedGroups.length}
</div>
<div className={style.footerBtnGroup}>
<AntdButton type="default" onClick={() => setRealVisible(false)}>
</AntdButton>
<AntdButton type="primary" onClick={handleConfirm}>
</AntdButton>
</div>
</div>
</div>
</Popup>

View File

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

View File

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

View File

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

View File

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

View File

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