Files
flvx/vite-frontend/src/pages/group.tsx
T
sagit 938e9ff4fd feat(ui): redesign with transparent liquid glass (#414)
* docs: add design spec for iOS 26 Liquid Glass UI redesign

* docs: add implementation plan for iOS 26 Liquid Glass UI redesign

* feat(ui): add liquid glass CSS variables and update root layouts

* feat(ui): update Card and Modal components to iOS 26 Liquid Glass style

* feat(ui): redesign dashboard page to Liquid Glass style

* feat(ui): redesign node management page cards and charts

* feat(ui): redesign monitor page to Liquid Glass style

* feat(ui): redesign group and panel-sharing cards to Liquid Glass

* feat(ui): redesign settings and user components to Liquid Glass

* feat(ui): redesign profile and auth pages to Liquid Glass

* fix(ui): revert to LineChart to fix compilation issues

* refactor(ui): extract dashboard user header and cleanup admin layout

* feat(ui): refine monitor page hero metrics and fix build

* fix(ui): real data for monitor hero metrics and card padding adjustments

* fix(ui): apply global card padding and fix index layouts

* feat(ui): implement liquid-glass-react for all Card components

* fix(ui): resolve liquid-glass-react rendering issues causing invisible text and broken layout

* fix(ui): native CSS liquid-glass effect replacing NPM package

* fix(ui): cleanup duplicate closing tags and complete liquid glass tables

* fix(ui): apply perfect rounded corners to list views and table headers in non-compact mode

* fix(ui): apply liquid glass styling to grouped rules view in forward page

* fix(ui): re-implement liquid-glass natively to prevent text distortion and remove package dependency

* fix(ui): remove mouse distortion and preserve proper liquid glass texture

* fix(ui): remove svg filter causing text distortion and replace with pure css liquid glass

* fix(ui): apply properly scoped border radius to table headers

* fix(ui): remove hardcoded bg-default-50/60 from TableHeader

* fix(ui): apply global liquid glass styling and transparent overlays to all modals and dialogs

* fix(ui): remove absolute/relative class conflicts hiding Dialog modales and implement pure transparent layout

* feat(ui): apply global liquid glass styling to cards, buttons, and diagnosis modals
2026-04-05 13:22:12 +08:00

928 lines
28 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure,
} from "@/shadcn-bridge/heroui/modal";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@/shadcn-bridge/heroui/table";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import {
assignGroupPermission,
assignTunnelsToGroup,
assignUsersToGroup,
createTunnelGroup,
createUserGroup,
deleteTunnelGroup,
deleteUserGroup,
getAllUsers,
getGroupPermissionList,
getTunnelGroupList,
getTunnelList,
getUserGroupList,
removeGroupPermission,
updateTunnelGroup,
updateUserGroup,
} from "@/api";
import { getAdminFlag } from "@/utils/session";
interface TunnelItem {
id: number;
name: string;
}
interface UserItem {
id: number;
user: string;
}
interface TunnelGroup {
id: number;
name: string;
status: number;
tunnelIds: number[];
tunnelNames: string[];
createdTime: number;
}
interface UserGroup {
id: number;
name: string;
status: number;
userIds: number[];
userNames: string[];
createdTime: number;
}
interface GroupPermission {
id: number;
userGroupId: number;
userGroupName: string;
tunnelGroupId: number;
tunnelGroupName: string;
createdTime: number;
}
const formatDate = (timestamp?: number): string => {
if (!timestamp) {
return "-";
}
return new Date(timestamp).toLocaleString();
};
export default function GroupPage() {
const [loading, setLoading] = useState(true);
const [isAdmin] = useState(getAdminFlag());
const [tunnelGroups, setTunnelGroups] = useState<TunnelGroup[]>([]);
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
const [permissions, setPermissions] = useState<GroupPermission[]>([]);
const [tunnels, setTunnels] = useState<TunnelItem[]>([]);
const [users, setUsers] = useState<UserItem[]>([]);
const [selectedUserGroupId, setSelectedUserGroupId] = useState<number | null>(
null,
);
const [selectedTunnelGroupId, setSelectedTunnelGroupId] = useState<
number | null
>(null);
const [savingPermission, setSavingPermission] = useState(false);
const {
isOpen: tunnelGroupModalOpen,
onOpen: onTunnelGroupModalOpen,
onClose: onTunnelGroupModalClose,
onOpenChange: onTunnelGroupModalChange,
} = useDisclosure();
const {
isOpen: userGroupModalOpen,
onOpen: onUserGroupModalOpen,
onClose: onUserGroupModalClose,
onOpenChange: onUserGroupModalChange,
} = useDisclosure();
const {
isOpen: tunnelAssignModalOpen,
onOpen: onTunnelAssignModalOpen,
onClose: onTunnelAssignModalClose,
onOpenChange: onTunnelAssignModalChange,
} = useDisclosure();
const {
isOpen: userAssignModalOpen,
onOpen: onUserAssignModalOpen,
onClose: onUserAssignModalClose,
onOpenChange: onUserAssignModalChange,
} = useDisclosure();
const [editingTunnelGroup, setEditingTunnelGroup] =
useState<TunnelGroup | null>(null);
const [editingUserGroup, setEditingUserGroup] = useState<UserGroup | null>(
null,
);
const [groupName, setGroupName] = useState("");
const [groupStatus, setGroupStatus] = useState("1");
const [savingGroup, setSavingGroup] = useState(false);
const [assignTunnelGroup, setAssignTunnelGroup] =
useState<TunnelGroup | null>(null);
const [assignUserGroup, setAssignUserGroup] = useState<UserGroup | null>(
null,
);
const [selectedTunnelKeys, setSelectedTunnelKeys] = useState<Set<string>>(
new Set(),
);
const [selectedUserKeys, setSelectedUserKeys] = useState<Set<string>>(
new Set(),
);
const [savingAssign, setSavingAssign] = useState(false);
const tunnelNameMap = useMemo(() => {
const map = new Map<number, string>();
tunnels.forEach((item) => {
map.set(item.id, item.name);
});
return map;
}, [tunnels]);
const userNameMap = useMemo(() => {
const map = new Map<number, string>();
users.forEach((item) => {
map.set(item.id, item.user);
});
return map;
}, [users]);
const selectedTunnelSummary = useMemo(() => {
const value = Array.from(selectedTunnelKeys)
.map((id) => tunnelNameMap.get(Number(id)) || id)
.join("、");
return value || "无";
}, [selectedTunnelKeys, tunnelNameMap]);
const selectedUserSummary = useMemo(() => {
const value = Array.from(selectedUserKeys)
.map((id) => userNameMap.get(Number(id)) || id)
.join("、");
return value || "无";
}, [selectedUserKeys, userNameMap]);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [tunnelGroupRes, userGroupRes, permissionRes, tunnelRes, userRes] =
await Promise.all([
getTunnelGroupList(),
getUserGroupList(),
getGroupPermissionList(),
getTunnelList(),
getAllUsers(),
]);
if (tunnelGroupRes.code === 0) {
setTunnelGroups(
Array.isArray(tunnelGroupRes.data) ? tunnelGroupRes.data : [],
);
}
if (userGroupRes.code === 0) {
setUserGroups(
Array.isArray(userGroupRes.data) ? userGroupRes.data : [],
);
}
if (permissionRes.code === 0) {
setPermissions(
Array.isArray(permissionRes.data) ? permissionRes.data : [],
);
}
if (tunnelRes.code === 0) {
setTunnels(Array.isArray(tunnelRes.data) ? tunnelRes.data : []);
}
if (userRes.code === 0) {
setUsers(Array.isArray(userRes.data) ? userRes.data : []);
}
if (
tunnelGroupRes.code !== 0 ||
userGroupRes.code !== 0 ||
permissionRes.code !== 0
) {
toast.error("部分分组数据加载失败");
}
} catch {
toast.error("分组数据加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const openCreateTunnelGroup = () => {
setEditingTunnelGroup(null);
setGroupName("");
setGroupStatus("1");
onTunnelGroupModalOpen();
};
const openEditTunnelGroup = (group: TunnelGroup) => {
setEditingTunnelGroup(group);
setGroupName(group.name);
setGroupStatus(String(group.status));
onTunnelGroupModalOpen();
};
const openCreateUserGroup = () => {
setEditingUserGroup(null);
setGroupName("");
setGroupStatus("1");
onUserGroupModalOpen();
};
const openEditUserGroup = (group: UserGroup) => {
setEditingUserGroup(group);
setGroupName(group.name);
setGroupStatus(String(group.status));
onUserGroupModalOpen();
};
const saveTunnelGroup = async () => {
if (!groupName.trim()) {
toast.error("请输入分组名称");
return;
}
setSavingGroup(true);
try {
const payload = { name: groupName.trim(), status: Number(groupStatus) };
const res = editingTunnelGroup
? await updateTunnelGroup({ id: editingTunnelGroup.id, ...payload })
: await createTunnelGroup(payload);
if (res.code === 0) {
toast.success(editingTunnelGroup ? "更新成功" : "创建成功");
onTunnelGroupModalClose();
loadData();
} else {
toast.error(res.msg || "保存失败");
}
} catch {
toast.error("保存失败");
} finally {
setSavingGroup(false);
}
};
const saveUserGroup = async () => {
if (!groupName.trim()) {
toast.error("请输入分组名称");
return;
}
setSavingGroup(true);
try {
const payload = { name: groupName.trim(), status: Number(groupStatus) };
const res = editingUserGroup
? await updateUserGroup({ id: editingUserGroup.id, ...payload })
: await createUserGroup(payload);
if (res.code === 0) {
toast.success(editingUserGroup ? "更新成功" : "创建成功");
onUserGroupModalClose();
loadData();
} else {
toast.error(res.msg || "保存失败");
}
} catch {
toast.error("保存失败");
} finally {
setSavingGroup(false);
}
};
const handleDeleteTunnelGroup = async (id: number) => {
try {
const res = await deleteTunnelGroup(id);
if (res.code === 0) {
toast.success("删除成功");
loadData();
} else {
toast.error(res.msg || "删除失败");
}
} catch {
toast.error("删除失败");
}
};
const handleDeleteUserGroup = async (id: number) => {
try {
const res = await deleteUserGroup(id);
if (res.code === 0) {
toast.success("删除成功");
loadData();
} else {
toast.error(res.msg || "删除失败");
}
} catch {
toast.error("删除失败");
}
};
const openAssignTunnels = (group: TunnelGroup) => {
setAssignTunnelGroup(group);
setSelectedTunnelKeys(new Set(group.tunnelIds.map((id) => String(id))));
onTunnelAssignModalOpen();
};
const openAssignUsers = (group: UserGroup) => {
setAssignUserGroup(group);
setSelectedUserKeys(new Set(group.userIds.map((id) => String(id))));
onUserAssignModalOpen();
};
const saveAssignTunnels = async () => {
if (!assignTunnelGroup) return;
setSavingAssign(true);
try {
const tunnelIds = Array.from(selectedTunnelKeys).map((id) => Number(id));
const res = await assignTunnelsToGroup({
groupId: assignTunnelGroup.id,
tunnelIds,
});
if (res.code === 0) {
toast.success("分配成功");
onTunnelAssignModalClose();
loadData();
} else {
toast.error(res.msg || "分配失败");
}
} catch {
toast.error("分配失败");
} finally {
setSavingAssign(false);
}
};
const saveAssignUsers = async () => {
if (!assignUserGroup) return;
setSavingAssign(true);
try {
const userIds = Array.from(selectedUserKeys).map((id) => Number(id));
const res = await assignUsersToGroup({
groupId: assignUserGroup.id,
userIds,
});
if (res.code === 0) {
toast.success("分配成功");
onUserAssignModalClose();
loadData();
} else {
toast.error(res.msg || "分配失败");
}
} catch {
toast.error("分配失败");
} finally {
setSavingAssign(false);
}
};
const handleAssignPermission = async () => {
if (!selectedUserGroupId || !selectedTunnelGroupId) {
toast.error("请选择用户分组和隧道分组");
return;
}
setSavingPermission(true);
try {
const res = await assignGroupPermission({
userGroupId: selectedUserGroupId,
tunnelGroupId: selectedTunnelGroupId,
});
if (res.code === 0) {
toast.success(res.msg || "权限分配成功");
loadData();
} else {
toast.error(res.msg || "权限分配失败");
}
} catch {
toast.error("权限分配失败");
} finally {
setSavingPermission(false);
}
};
const handleRemovePermission = async (id: number) => {
try {
const res = await removeGroupPermission(id);
if (res.code === 0) {
toast.success("权限回收成功");
loadData();
} else {
toast.error(res.msg || "权限回收失败");
}
} catch {
toast.error("权限回收失败");
}
};
if (!isAdmin) {
return (
<div className="px-3 lg:px-6 py-8">
<Card>
<CardBody>
<p className="text-danger">
权限不足,只有管理员可以访问分组管理页面。
</p>
</CardBody>
</Card>
</div>
);
}
return (
<div className="px-3 lg:px-6 py-8 space-y-6">
{loading && (
<div className="flex justify-center py-10">
<Spinner size="lg" />
</div>
)}
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateTunnelGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="隧道分组列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道分组" items={tunnelGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.tunnelNames.length > 0
? item.tunnelNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignTunnels(item)}
>
分配隧道
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditTunnelGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteTunnelGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="用户分组列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无用户分组" items={userGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.userNames.length > 0
? item.userNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignUsers(item)}
>
分配用户
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditUserGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteUserGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">权限分配</h3>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
<Select
items={userGroups}
label="用户分组"
selectedKeys={
selectedUserGroupId ? [String(selectedUserGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedUserGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Select
items={tunnelGroups}
label="隧道分组"
selectedKeys={
selectedTunnelGroupId ? [String(selectedTunnelGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedTunnelGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Button
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
color="primary"
isLoading={savingPermission}
size="sm"
onPress={handleAssignPermission}
>
分配
</Button>
</div>
<Table
aria-label="分组权限列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户分组</TableColumn>
<TableColumn>隧道分组</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无权限分配记录" items={permissions}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>
{item.userGroupName || item.userGroupId}
</TableCell>
<TableCell>
{item.tunnelGroupName || item.tunnelGroupId}
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleRemovePermission(item.id)}
>
回收
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveTunnelGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userGroupModalOpen}
onOpenChange={onUserGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveUserGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
<ModalContent>
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={tunnels}
label="选择隧道"
selectedKeys={selectedTunnelKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedTunnelKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedTunnelSummary}`}
>
当前已选:{selectedTunnelSummary}
</p>
<p className="text-xs text-default-500">
不选择任何隧道并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignTunnels}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
<ModalContent>
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={users}
label="选择用户"
selectedKeys={selectedUserKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedUserKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedUserSummary}`}
>
当前已选:{selectedUserSummary}
</p>
<p className="text-xs text-default-500">
不选择任何用户并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignUsers}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}