feat: 本次提交更新内容如下

保存下场景建设数据
This commit is contained in:
笔记本里的永平
2025-07-16 15:18:54 +08:00
parent 8d1362303d
commit 9e1dc36853
13 changed files with 1329 additions and 1020 deletions

View File

@@ -1,12 +1,17 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Search, RefreshCw, Loader2 } from 'lucide-react';
import { fetchContentLibraryList } from '@/api/content';
import { ContentLibrary } from '@/api/content';
import { useToast } from '@/components/ui/toast';
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Search, RefreshCw, Loader2 } from "lucide-react";
import { fetchContentLibraryList } from "@/api/content";
import { ContentLibrary } from "@/api/content";
import { useToast } from "@/components/ui/toast";
interface ContentLibrarySelectionDialogProps {
open: boolean;
@@ -22,7 +27,7 @@ export function ContentLibrarySelectionDialog({
onSelect,
}: ContentLibrarySelectionDialogProps) {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(false);
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
const [tempSelected, setTempSelected] = useState<string[]>([]);
@@ -35,11 +40,19 @@ export function ContentLibrarySelectionDialog({
if (response.code === 200 && response.data) {
setLibraries(response.data.list);
} else {
toast({ title: '获取内容库列表失败', description: response.msg, variant: 'destructive' });
toast({
title: "获取内容库列表失败",
description: response.msg,
variant: "destructive",
});
}
} catch (error) {
console.error('获取内容库列表失败:', error);
toast({ title: '获取内容库列表失败', description: '请检查网络连接', variant: 'destructive' });
console.error("获取内容库列表失败:", error);
toast({
title: "获取内容库列表失败",
description: "请检查网络连接",
variant: "destructive",
});
} finally {
setLoading(false);
}
@@ -60,14 +73,14 @@ export function ContentLibrarySelectionDialog({
if (tempSelected.length === libraries.length) {
setTempSelected([]);
} else {
setTempSelected(libraries.map(lib => lib.id));
setTempSelected(libraries.map((lib) => lib.id));
}
};
const handleLibraryToggle = (libraryId: string) => {
setTempSelected(prev =>
prev.includes(libraryId)
? prev.filter(id => id !== libraryId)
setTempSelected((prev) =>
prev.includes(libraryId)
? prev.filter((id) => id !== libraryId)
: [...prev, libraryId]
);
};
@@ -86,7 +99,7 @@ export function ContentLibrarySelectionDialog({
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className="flex flex-col">
<DialogContent className="flex flex-col bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
@@ -101,8 +114,17 @@ export function ContentLibrarySelectionDialog({
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button variant="outline" size="icon" onClick={handleRefresh} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
@@ -111,9 +133,9 @@ export function ContentLibrarySelectionDialog({
{tempSelected.length}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
disabled={loading || libraries.length === 0}
>
@@ -148,12 +170,19 @@ export function ContentLibrarySelectionDialog({
<div className="flex items-center justify-between">
<span className="font-medium">{library.name}</span>
<Badge variant="outline">
{library.sourceType === 1 ? '文本' : library.sourceType === 2 ? '图片' : '视频'}
{library.sourceType === 1
? "文本"
: library.sourceType === 2
? "图片"
: "视频"}
</Badge>
</div>
<div className="text-sm text-gray-500 mt-1">
<div>: {library.creatorName || '-'}</div>
<div>: {new Date(library.updateTime).toLocaleString()}</div>
<div>: {library.creatorName || "-"}</div>
<div>
:{" "}
{new Date(library.updateTime).toLocaleString()}
</div>
</div>
</div>
</label>
@@ -171,11 +200,11 @@ export function ContentLibrarySelectionDialog({
</Button>
<Button onClick={handleConfirm}>
{tempSelected.length > 0 ? ` (${tempSelected.length})` : ''}
{tempSelected.length > 0 ? ` (${tempSelected.length})` : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -1,11 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { fetchDeviceList } from '@/api/devices';
import React, { useState, useEffect } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { fetchDeviceList } from "@/api/devices";
// 设备选择项接口
interface DeviceSelectionItem {
@@ -13,7 +18,7 @@ interface DeviceSelectionItem {
name: string;
imei: string;
wechatId: string;
status: 'online' | 'offline';
status: "online" | "offline";
}
// 组件属性接口
@@ -24,16 +29,16 @@ interface DeviceSelectionProps {
className?: string;
}
export default function DeviceSelection({
selectedDevices,
onSelect,
export default function DeviceSelection({
selectedDevices,
onSelect,
placeholder = "选择设备",
className = ""
className = "",
}: DeviceSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [devices, setDevices] = useState<DeviceSelectionItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
// 当弹窗打开时获取设备列表
@@ -49,16 +54,18 @@ export default function DeviceSelection({
try {
const res = await fetchDeviceList(1, 100);
if (res && res.data && Array.isArray(res.data.list)) {
setDevices(res.data.list.map(d => ({
id: d.id?.toString() || '',
name: d.memo || d.imei || '',
imei: d.imei || '',
wechatId: d.wechatId || '',
status: d.alive === 1 ? 'online' : 'offline',
})));
setDevices(
res.data.list.map((d) => ({
id: d.id?.toString() || "",
name: d.memo || d.imei || "",
imei: d.imei || "",
wechatId: d.wechatId || "",
status: d.alive === 1 ? "online" : "offline",
}))
);
}
} catch (error) {
console.error('获取设备列表失败:', error);
console.error("获取设备列表失败:", error);
} finally {
setLoading(false);
}
@@ -72,9 +79,9 @@ export default function DeviceSelection({
device.wechatId.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'online' && device.status === 'online') ||
(statusFilter === 'offline' && device.status === 'offline');
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesSearch && matchesStatus;
});
@@ -82,7 +89,7 @@ export default function DeviceSelection({
// 处理设备选择
const handleDeviceToggle = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter(id => id !== deviceId));
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
@@ -90,7 +97,7 @@ export default function DeviceSelection({
// 获取显示文本
const getDisplayText = () => {
if (selectedDevices.length === 0) return '';
if (selectedDevices.length === 0) return "";
return `已选择 ${selectedDevices.length} 个设备`;
};
@@ -108,10 +115,9 @@ export default function DeviceSelection({
/>
</div>
{/* 设备选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
@@ -128,7 +134,7 @@ export default function DeviceSelection({
</div>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-32 h-10 rounded border border-gray-300 px-2 text-base"
>
<option value="all"></option>
@@ -157,10 +163,14 @@ export default function DeviceSelection({
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{device.name}</span>
<div className={`w-16 h-6 flex items-center justify-center text-xs ${
device.status === 'online' ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-600'
}`}>
{device.status === 'online' ? '在线' : '离线'}
<div
className={`w-16 h-6 flex items-center justify-center text-xs ${
device.status === "online"
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-600"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className="text-sm text-gray-500 mt-1">
@@ -182,13 +192,11 @@ export default function DeviceSelection({
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button onClick={() => setDialogOpen(false)}>
</Button>
<Button onClick={() => setDialogOpen(false)}></Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
}

View File

@@ -1,19 +1,24 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Search, RefreshCw, Loader2 } from 'lucide-react';
import { fetchDeviceList } from '@/api/devices';
import { ServerDevice } from '@/types/device';
import { useToast } from '@/components/ui/toast';
import React, { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Search, RefreshCw, Loader2 } from "lucide-react";
import { fetchDeviceList } from "@/api/devices";
import { ServerDevice } from "@/types/device";
import { useToast } from "@/components/ui/toast";
interface Device {
id: string;
name: string;
imei: string;
wxid: string;
status: 'online' | 'offline';
status: "online" | "offline";
usedInPlans: number;
nickname: string;
}
@@ -25,15 +30,15 @@ interface DeviceSelectionDialogProps {
onSelect: (devices: string[]) => void;
}
export function DeviceSelectionDialog({
open,
onOpenChange,
selectedDevices,
onSelect
export function DeviceSelectionDialog({
open,
onOpenChange,
selectedDevices,
onSelect,
}: DeviceSelectionDialogProps) {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [devices, setDevices] = useState<Device[]>([]);
@@ -44,22 +49,32 @@ export function DeviceSelectionDialog({
const response = await fetchDeviceList(1, 100, searchQuery);
if (response.code === 200 && response.data) {
// 转换服务端数据格式为组件需要的格式
const convertedDevices: Device[] = response.data.list.map((serverDevice: ServerDevice) => ({
id: serverDevice.id.toString(),
name: serverDevice.memo || `设备 ${serverDevice.id}`,
imei: serverDevice.imei,
wxid: serverDevice.wechatId || '',
status: serverDevice.alive === 1 ? 'online' : 'offline',
usedInPlans: 0, // 这个字段需要从其他API获取
nickname: serverDevice.nickname || '',
}));
const convertedDevices: Device[] = response.data.list.map(
(serverDevice: ServerDevice) => ({
id: serverDevice.id.toString(),
name: serverDevice.memo || `设备 ${serverDevice.id}`,
imei: serverDevice.imei,
wxid: serverDevice.wechatId || "",
status: serverDevice.alive === 1 ? "online" : "offline",
usedInPlans: 0, // 这个字段需要从其他API获取
nickname: serverDevice.nickname || "",
})
);
setDevices(convertedDevices);
} else {
toast({ title: '获取设备列表失败', description: response.msg, variant: 'destructive' });
toast({
title: "获取设备列表失败",
description: response.msg,
variant: "destructive",
});
}
} catch (error) {
console.error('获取设备列表失败:', error);
toast({ title: '获取设备列表失败', description: '请检查网络连接', variant: 'destructive' });
console.error("获取设备列表失败:", error);
toast({
title: "获取设备列表失败",
description: "请检查网络连接",
variant: "destructive",
});
} finally {
setLoading(false);
}
@@ -78,16 +93,16 @@ export function DeviceSelectionDialog({
device.wxid.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'online' && device.status === 'online') ||
(statusFilter === 'offline' && device.status === 'offline');
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline");
return matchesSearch && matchesStatus;
});
const handleDeviceSelect = (deviceId: string) => {
if (selectedDevices.includes(deviceId)) {
onSelect(selectedDevices.filter(id => id !== deviceId));
onSelect(selectedDevices.filter((id) => id !== deviceId));
} else {
onSelect([...selectedDevices, deviceId]);
}
@@ -95,7 +110,7 @@ export function DeviceSelectionDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex flex-col">
<DialogContent className="flex flex-col bg-white">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
@@ -110,8 +125,8 @@ export function DeviceSelectionDialog({
className="pl-9"
/>
</div>
<select
value={statusFilter}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-32 px-3 py-2 border border-gray-300 rounded-md text-sm"
>
@@ -119,8 +134,17 @@ export function DeviceSelectionDialog({
<option value="online">线</option>
<option value="offline">线</option>
</select>
<Button variant="outline" size="icon" onClick={fetchDevices} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
<Button
variant="outline"
size="icon"
onClick={fetchDevices}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
</div>
@@ -149,17 +173,23 @@ export function DeviceSelectionDialog({
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="font-medium">{device.name}</span>
<Badge variant={device.status === 'online' ? 'default' : 'secondary'}>
{device.status === 'online' ? '在线' : '离线'}
<Badge
variant={
device.status === "online" ? "default" : "secondary"
}
>
{device.status === "online" ? "在线" : "离线"}
</Badge>
</div>
<div className="text-sm text-gray-500 mt-1">
<div>IMEI: {device.imei}</div>
<div>: {device.wxid||'-'}</div>
<div>: {device.nickname||'-'}</div>
<div>: {device.wxid || "-"}</div>
<div>: {device.nickname || "-"}</div>
</div>
{device.usedInPlans > 0 && (
<div className="text-sm text-orange-500 mt-1"> {device.usedInPlans} </div>
<div className="text-sm text-orange-500 mt-1">
{device.usedInPlans}
</div>
)}
</div>
</label>
@@ -177,11 +207,12 @@ export function DeviceSelectionDialog({
</Button>
<Button onClick={() => onOpenChange(false)}>
{selectedDevices.length > 0 ? ` (${selectedDevices.length})` : ''}
{selectedDevices.length > 0 ? ` (${selectedDevices.length})` : ""}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -1,10 +1,15 @@
import React, { useState, useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { get } from '@/api/request';
import React, { useState, useEffect } from "react";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { get } from "@/api/request";
// 微信好友接口类型
interface WechatFriend {
@@ -42,18 +47,20 @@ const fetchFriendsList = async (params: {
if (params.deviceIds && params.deviceIds.length === 0) {
return {
code: 200,
msg: 'success',
msg: "success",
data: {
list: [],
total: 0,
page: params.page,
limit: params.limit
}
limit: params.limit,
},
};
}
const deviceIdsParam = params?.deviceIds?.join(',') || ''
return get<FriendsResponse>(`/v1/friend?page=${ params.page}&limit=${params.limit}&deviceIds=${deviceIdsParam}`);
const deviceIdsParam = params?.deviceIds?.join(",") || "";
return get<FriendsResponse>(
`/v1/friend?page=${params.page}&limit=${params.limit}&deviceIds=${deviceIdsParam}`
);
};
// 组件属性接口
@@ -67,18 +74,18 @@ interface FriendSelectionProps {
className?: string;
}
export default function FriendSelection({
selectedFriends,
onSelect,
export default function FriendSelection({
selectedFriends,
onSelect,
onSelectDetail,
deviceIds = [],
enableDeviceFilter = true,
placeholder = "选择微信好友",
className = ""
className = "",
}: FriendSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [friends, setFriends] = useState<WechatFriend[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalFriends, setTotalFriends] = useState(0);
@@ -116,49 +123,52 @@ export default function FriendSelection({
} else {
res = await fetchFriendsList({ page, limit: 20 });
}
if (res && res.code === 200 && res.data) {
setFriends(res.data.list.map((friend) => ({
id: friend.id?.toString() || '',
nickname: friend.nickname || '',
wechatId: friend.wechatId || '',
avatar: friend.avatar || '',
customer: friend.customer || '',
})));
setFriends(
res.data.list.map((friend) => ({
id: friend.id?.toString() || "",
nickname: friend.nickname || "",
wechatId: friend.wechatId || "",
avatar: friend.avatar || "",
customer: friend.customer || "",
}))
);
setTotalFriends(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 20));
}
} catch (error) {
console.error('获取好友列表失败:', error);
console.error("获取好友列表失败:", error);
} finally {
setLoading(false);
}
};
// 过滤好友
const filteredFriends = friends.filter(friend =>
friend.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
friend.wechatId.toLowerCase().includes(searchQuery.toLowerCase())
const filteredFriends = friends.filter(
(friend) =>
friend.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
friend.wechatId.toLowerCase().includes(searchQuery.toLowerCase())
);
// 处理好友选择
const handleFriendToggle = (friendId: string) => {
let newIds: string[];
if (selectedFriends.includes(friendId)) {
newIds = selectedFriends.filter(id => id !== friendId);
newIds = selectedFriends.filter((id) => id !== friendId);
} else {
newIds = [...selectedFriends, friendId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = friends.filter(f => newIds.includes(f.id));
const selectedObjs = friends.filter((f) => newIds.includes(f.id));
onSelectDetail(selectedObjs);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedFriends.length === 0) return '';
if (selectedFriends.length === 0) return "";
return `已选择 ${selectedFriends.length} 个好友`;
};
@@ -171,8 +181,19 @@ export default function FriendSelection({
{/* 输入框 */}
<div className={`relative ${className}`}>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<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
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
@@ -186,10 +207,12 @@ export default function FriendSelection({
{/* 微信好友选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden bg-white">
<div className="p-6">
<DialogTitle className="text-center text-xl font-medium mb-6"></DialogTitle>
<DialogTitle className="text-center text-xl font-medium mb-6">
</DialogTitle>
<div className="relative mb-4">
<Input
placeholder="搜索好友"
@@ -198,11 +221,11 @@ export default function FriendSelection({
className="pl-10 py-2 rounded-full border-gray-200"
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
onClick={() => setSearchQuery('')}
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
@@ -223,7 +246,13 @@ export default function FriendSelection({
onClick={() => handleFriendToggle(friend.id)}
>
<div className="mr-3 flex items-center justify-center">
<div className={`w-5 h-5 rounded-full border ${selectedFriends.includes(friend.id) ? 'border-blue-600' : 'border-gray-300'} flex items-center justify-center`}>
<div
className={`w-5 h-5 rounded-full border ${
selectedFriends.includes(friend.id)
? "border-blue-600"
: "border-gray-300"
} flex items-center justify-center`}
>
{selectedFriends.includes(friend.id) && (
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
)}
@@ -232,16 +261,24 @@ export default function FriendSelection({
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
{friend.avatar ? (
<img src={friend.avatar} alt={friend.nickname} className="w-full h-full object-cover" />
<img
src={friend.avatar}
alt={friend.nickname}
className="w-full h-full object-cover"
/>
) : (
friend.nickname.charAt(0)
)}
</div>
<div className="flex-1">
<div className="font-medium">{friend.nickname}</div>
<div className="text-sm text-gray-500">ID: {friend.wechatId}</div>
<div className="text-sm text-gray-500">
ID: {friend.wechatId}
</div>
{friend.customer && (
<div className="text-sm text-gray-400">: {friend.customer}</div>
<div className="text-sm text-gray-400">
: {friend.customer}
</div>
)}
</div>
</div>
@@ -251,7 +288,7 @@ export default function FriendSelection({
) : (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">
{deviceIds.length === 0 ? '请先选择设备' : '没有找到好友'}
{deviceIds.length === 0 ? "请先选择设备" : "没有找到好友"}
</div>
</div>
)}
@@ -271,11 +308,15 @@ export default function FriendSelection({
>
&lt;
</Button>
<span className="text-sm">{currentPage} / {totalPages}</span>
<span className="text-sm">
{currentPage} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className="px-2 py-0 h-8 min-w-0"
>
@@ -285,10 +326,17 @@ export default function FriendSelection({
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<Button variant="outline" onClick={() => setDialogOpen(false)} className="px-6 rounded-full border-gray-300">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
className="px-6 rounded-full border-gray-300"
>
</Button>
<Button onClick={handleConfirm} className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full">
<Button
onClick={handleConfirm}
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
>
({selectedFriends.length})
</Button>
</div>
@@ -296,4 +344,4 @@ export default function FriendSelection({
</Dialog>
</>
);
}
}

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { get } from '@/api/request';
import React, { useState, useEffect } from "react";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { get } from "@/api/request";
// 群组接口类型
interface WechatGroup {
@@ -36,8 +36,13 @@ interface GroupsResponse {
};
}
const fetchGroupsList = async (params: { page: number; limit: number; }): Promise<GroupsResponse> => {
return get<GroupsResponse>(`/v1/chatroom?page=${params.page}&limit=${params.limit}`);
const fetchGroupsList = async (params: {
page: number;
limit: number;
}): Promise<GroupsResponse> => {
return get<GroupsResponse>(
`/v1/chatroom?page=${params.page}&limit=${params.limit}`
);
};
interface GroupSelectionProps {
@@ -52,12 +57,12 @@ export default function GroupSelection({
selectedGroups,
onSelect,
onSelectDetail,
placeholder = '选择群聊',
className = ''
placeholder = "选择群聊",
className = "",
}: GroupSelectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [groups, setGroups] = useState<WechatGroup[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalGroups, setTotalGroups] = useState(0);
@@ -84,49 +89,52 @@ export default function GroupSelection({
try {
const res = await fetchGroupsList({ page, limit: 20 });
if (res && res.code === 200 && res.data) {
setGroups(res.data.list.map((group) => ({
id: group.id?.toString() || '',
chatroomId: group.chatroomId || '',
name: group.name || '',
avatar: group.avatar || '',
ownerWechatId: group.ownerWechatId || '',
ownerNickname: group.ownerNickname || '',
ownerAvatar: group.ownerAvatar || '',
})));
setGroups(
res.data.list.map((group) => ({
id: group.id?.toString() || "",
chatroomId: group.chatroomId || "",
name: group.name || "",
avatar: group.avatar || "",
ownerWechatId: group.ownerWechatId || "",
ownerNickname: group.ownerNickname || "",
ownerAvatar: group.ownerAvatar || "",
}))
);
setTotalGroups(res.data.total || 0);
setTotalPages(Math.ceil((res.data.total || 0) / 20));
}
} catch (error) {
console.error('获取群组列表失败:', error);
console.error("获取群组列表失败:", error);
} finally {
setLoading(false);
}
};
// 过滤群组
const filteredGroups = groups.filter(group =>
group.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
group.chatroomId.toLowerCase().includes(searchQuery.toLowerCase())
const filteredGroups = groups.filter(
(group) =>
group.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
group.chatroomId.toLowerCase().includes(searchQuery.toLowerCase())
);
// 处理群组选择
const handleGroupToggle = (groupId: string) => {
let newIds: string[];
if (selectedGroups.includes(groupId)) {
newIds = selectedGroups.filter(id => id !== groupId);
newIds = selectedGroups.filter((id) => id !== groupId);
} else {
newIds = [...selectedGroups, groupId];
}
onSelect(newIds);
if (onSelectDetail) {
const selectedObjs = groups.filter(g => newIds.includes(g.id));
const selectedObjs = groups.filter((g) => newIds.includes(g.id));
onSelectDetail(selectedObjs);
}
};
// 获取显示文本
const getDisplayText = () => {
if (selectedGroups.length === 0) return '';
if (selectedGroups.length === 0) return "";
return `已选择 ${selectedGroups.length} 个群聊`;
};
@@ -139,8 +147,19 @@ export default function GroupSelection({
{/* 输入框 */}
<div className={`relative ${className}`}>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<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
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
@@ -154,9 +173,11 @@ export default function GroupSelection({
{/* 群组选择弹窗 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<DialogContent className="max-w-xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden bg-white">
<div className="p-6">
<DialogTitle className="text-center text-xl font-medium mb-6"></DialogTitle>
<DialogTitle className="text-center text-xl font-medium mb-6">
</DialogTitle>
<div className="relative mb-4">
<Input
placeholder="搜索群聊"
@@ -169,7 +190,7 @@ export default function GroupSelection({
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded-full"
onClick={() => setSearchQuery('')}
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
@@ -190,7 +211,13 @@ export default function GroupSelection({
onClick={() => handleGroupToggle(group.id)}
>
<div className="mr-3 flex items-center justify-center">
<div className={`w-5 h-5 rounded-full border ${selectedGroups.includes(group.id) ? 'border-blue-600' : 'border-gray-300'} flex items-center justify-center`}>
<div
className={`w-5 h-5 rounded-full border ${
selectedGroups.includes(group.id)
? "border-blue-600"
: "border-gray-300"
} flex items-center justify-center`}
>
{selectedGroups.includes(group.id) && (
<div className="w-3 h-3 rounded-full bg-blue-600"></div>
)}
@@ -199,16 +226,24 @@ export default function GroupSelection({
<div className="flex items-center space-x-3 flex-1">
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-medium overflow-hidden">
{group.avatar ? (
<img src={group.avatar} alt={group.name} className="w-full h-full object-cover" />
<img
src={group.avatar}
alt={group.name}
className="w-full h-full object-cover"
/>
) : (
group.name.charAt(0)
)}
</div>
<div className="flex-1">
<div className="font-medium">{group.name}</div>
<div className="text-sm text-gray-500">ID: {group.chatroomId}</div>
<div className="text-sm text-gray-500">
ID: {group.chatroomId}
</div>
{group.ownerNickname && (
<div className="text-sm text-gray-400">: {group.ownerNickname}</div>
<div className="text-sm text-gray-400">
: {group.ownerNickname}
</div>
)}
</div>
</div>
@@ -236,11 +271,15 @@ export default function GroupSelection({
>
&lt;
</Button>
<span className="text-sm">{currentPage} / {totalPages}</span>
<span className="text-sm">
{currentPage} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
onClick={() =>
setCurrentPage(Math.min(totalPages, currentPage + 1))
}
disabled={currentPage === totalPages || loading}
className="px-2 py-0 h-8 min-w-0"
>
@@ -250,10 +289,17 @@ export default function GroupSelection({
</div>
<div className="border-t p-4 flex items-center justify-between bg-white">
<Button variant="outline" onClick={() => setDialogOpen(false)} className="px-6 rounded-full border-gray-300">
<Button
variant="outline"
onClick={() => setDialogOpen(false)}
className="px-6 rounded-full border-gray-300"
>
</Button>
<Button onClick={handleConfirm} className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full">
<Button
onClick={handleConfirm}
className="px-6 bg-blue-600 hover:bg-blue-700 rounded-full"
>
({selectedGroups.length})
</Button>
</div>
@@ -261,4 +307,4 @@ export default function GroupSelection({
</Dialog>
</>
);
}
}

View File

@@ -1,18 +1,18 @@
"use client"
"use client";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/utils"
import { cn } from "@/utils";
const Dialog = DialogPrimitive.Root
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -22,12 +22,12 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@@ -38,8 +38,8 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
"bg-white fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
@@ -50,18 +50,36 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@@ -69,19 +87,26 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
@@ -94,4 +119,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
};