2.1.8-beta1.7

This commit is contained in:
abai569ok
2026-03-15 20:33:53 +08:00
parent 004daeadb6
commit 375877b223
18 changed files with 1176 additions and 1975 deletions
+202 -369
View File
@@ -1,3 +1,4 @@
import { SearchBar } from "@/components/search-bar";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
@@ -204,7 +205,7 @@ export default function UserPage() {
user: "",
pwd: "",
status: 1,
flow: 100,
flow: 1000,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
@@ -234,6 +235,7 @@ export default function UserPage() {
// 分配新隧道权限相关状态
const [assignLoading, setAssignLoading] = useState(false);
const [isTunnelListExpanded, setIsTunnelListExpanded] = useState(false);
const [batchTunnelSelections, setBatchTunnelSelections] = useState<
Map<number, number | null>
>(new Map());
@@ -469,7 +471,7 @@ export default function UserPage() {
user: "",
pwd: "",
status: 1,
flow: 100,
flow: 1000,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
@@ -782,7 +784,6 @@ export default function UserPage() {
const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => {
const userId = userForm.id;
if (!userId) {
return;
}
@@ -887,67 +888,34 @@ export default function UserPage() {
<AnimatedPage className="px-3 lg:px-6 py-8">
{/* 页面头部 */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between mb-6 gap-3">
<div className="flex-1 max-w-sm flex items-center gap-2">
{!isSearchVisible ? (
<Button
isIconOnly
aria-label="搜索"
className="text-default-600"
color="default"
size="sm"
variant="flat"
onPress={() => setIsSearchVisible(true)}
>
<SearchIcon className="w-4 h-4" />
</Button>
) : (
<div className="flex w-full items-center gap-2 animate-appearance-in">
<Input
classNames={{
base: "bg-default-100",
input: "bg-transparent",
inputWrapper:
"bg-default-100 border-2 border-default-200 hover:border-default-300 data-[hover=true]:border-default-300",
}}
placeholder="搜索用户名"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
isIconOnly
aria-label="关闭搜索"
className="text-default-600 shrink-0"
color="default"
size="sm"
variant="light"
onPress={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</Button>
</div>
)}
<div className={`flex-1 max-w-sm flex items-center gap-2 shrink-0 ${isSearchVisible ? "min-w-[200px]" : "min-w-0"}`}>
<SearchBar
isVisible={isSearchVisible}
placeholder="搜索用户名"
value={searchKeyword}
onChange={setSearchKeyword}
onClose={() => {
setIsSearchVisible(false);
setSearchKeyword("");
}}
onOpen={() => {
setIsSearchVisible(true);
setTimeout(() => {
const searchInput = document.querySelector('input[placeholder*="搜索"]');
if (searchInput) (searchInput as HTMLElement).focus();
}, 150);
}}
/>
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<Button
className="w-fit self-end sm:self-auto"
color="primary"
size="sm"
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</div>
{/* 用户列表 */}
@@ -1049,26 +1017,6 @@ export default function UserPage() {
{/* 其他信息 */}
<div className="space-y-1.5 pt-2 border-t border-divider">
{(user.dailyQuotaGB ?? 0) > 0 ||
(user.monthlyQuotaGB ?? 0) > 0 ||
(user.disabledByQuota ?? 0) > 0 ? (
<>
<div className="flex justify-between text-sm">
<span className="text-default-600">每日配额</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /{" "}
{formatQuotaLimit(user.dailyQuotaGB)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-default-600">每月配额</span>
<span className="font-medium text-xs">
{formatFlow(Number(user.monthlyUsedBytes ?? 0))}{" "}
/ {formatQuotaLimit(user.monthlyQuotaGB)}
</span>
</div>
</>
) : null}
<div className="flex justify-between text-sm">
<span className="text-default-600">规则数量</span>
<span className="font-medium text-xs">
@@ -1178,7 +1126,7 @@ export default function UserPage() {
)}
{/* 用户表单模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }}
backdrop="blur"
isOpen={isUserModalOpen}
placement="center"
@@ -1224,38 +1172,6 @@ export default function UserPage() {
setUserForm((prev) => ({ ...prev, flow: value }));
}}
/>
<Input
label="每日配额(GB)"
max="99999"
min="0"
placeholder="0 表示不限"
type="number"
value={userForm.dailyQuotaGB.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 0),
99999,
);
setUserForm((prev) => ({ ...prev, dailyQuotaGB: value }));
}}
/>
<Input
label="每月配额(GB)"
max="99999"
min="0"
placeholder="0 表示不限"
type="number"
value={userForm.monthlyQuotaGB.toString()}
onChange={(e) => {
const value = Math.min(
Math.max(Number(e.target.value) || 0, 0),
99999,
);
setUserForm((prev) => ({ ...prev, monthlyQuotaGB: value }));
}}
/>
<Input
isRequired
label="规则数量"
@@ -1365,8 +1281,8 @@ export default function UserPage() {
<div className="rounded-lg bg-background p-3">
<div className="text-xs text-default-500">每月用量</div>
<div className="mt-1 text-sm font-semibold text-foreground">
{formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))}{" "}
/ {formatQuotaLimit(editingUser.monthlyQuotaGB)}
{formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))} /{" "}
{formatQuotaLimit(editingUser.monthlyQuotaGB)}
</div>
</div>
</div>
@@ -1452,7 +1368,7 @@ export default function UserPage() {
<Modal
backdrop="blur"
classNames={{
base: "max-w-[95vw] sm:max-w-4xl",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full sm:max-w-xl rounded-2xl",
}}
isDismissable={false}
isOpen={isTunnelModalOpen}
@@ -1469,136 +1385,90 @@ export default function UserPage() {
<div>
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
<div className="space-y-4">
<div className="text-sm text-default-500 bg-default-100 dark:bg-default-50 p-3 rounded-lg border border-default-200 dark:border-default-100/30">
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
</div>
<div className="flex flex-col gap-2 relative">
<p className="text-base text-default-700 ml-1 font-medium">隧道列表</p>
{/* 顶部触发框 */}
<div
className={`group flex items-center justify-between px-4 py-3 rounded-xl border-2 transition-all cursor-pointer shadow-sm w-[320px] ${isTunnelListExpanded ? "border-primary bg-white ring-2 ring-primary/10" : "border-default-200 bg-default-50 hover:border-primary-300"}`}
onClick={() => setIsTunnelListExpanded(!isTunnelListExpanded)}
>
<span className={`text-sm truncate ${batchTunnelSelections.size > 0 ? "text-primary-500 font-bold" : "text-default-400"}`}>
{batchTunnelSelections.size > 0
? `已选 ${batchTunnelSelections.size} 项:` + Array.from(batchTunnelSelections.keys()).map(id => tunnels.find(t => t.id === id)?.name).join('、')
: "请选择隧道(勾选后配置限速)"}
</span>
<svg className={`w-5 h-5 text-default-400 transition-transform ${isTunnelListExpanded ? "rotate-180 text-primary" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7" strokeWidth={2.5} /></svg>
</div>
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
{tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(tunnel.id);
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
tunnel.id,
);
{/* 列表悬浮层 */}
{isTunnelListExpanded && (
<div
className="absolute top-[calc(100%+8px)] left-0 w-[320px] border border-default-200 rounded-2xl bg-white dark:bg-default-900 shadow-2xl overflow-hidden z-[999]"
onClick={(e) => e.stopPropagation()}
>
<div className="max-h-[350px] overflow-y-auto p-2 custom-scrollbar">
{tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(tunnel.id);
const tunnelSpeedLimits = getSpeedLimitsForTunnel(tunnel.id);
const currentSpeedId = batchTunnelSelections.get(tunnel.id);
return (
<div
key={tunnel.id}
aria-disabled={isAssigned}
className={`
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
${
isAssigned
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
: isSelected
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
}
`}
role="button"
tabIndex={isAssigned ? -1 : 0}
onClick={() =>
!isAssigned && toggleTunnelSelection(tunnel.id)
}
onKeyDown={(event) => {
if (isAssigned) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleTunnelSelection(tunnel.id);
}
}}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
color="primary"
isDisabled={isAssigned}
isSelected={isSelected}
size="md"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onValueChange={() =>
toggleTunnelSelection(tunnel.id)
}
/>
<span
className={`font-medium truncate ${isAssigned ? "text-default-400" : "text-default-700 dark:text-default-600"}`}
return (
<div
key={tunnel.id}
className={`flex items-center justify-between px-4 py-2.5 rounded-xl mb-1 border transition-all ${isSelected ? "bg-primary-50/60 border-primary-200" : "bg-transparent border-transparent hover:bg-default-100"} ${isAssigned ? "opacity-40 grayscale cursor-not-allowed" : "cursor-pointer"}`}
// 核心:整行点击直接控制状态
onClick={(e) => {
if (isAssigned) return;
e.stopPropagation();
toggleTunnelSelection(tunnel.id);
}}
>
{tunnel.name}
</span>
{isAssigned && (
<Chip
className="shrink-0"
color="default"
size="sm"
variant="flat"
>
已分配
</Chip>
)}
</div>
<div className="flex items-center gap-4 min-w-0 flex-1">
<Checkbox
color="primary"
isSelected={isSelected}
isDisabled={isAssigned}
// 关键:禁用 Checkbox 自身的点击,防止它跟父容器打架
className="pointer-events-none"
/>
<span className={`text-sm font-medium truncate ${isSelected ? "text-primary-700" : ""}`}>{tunnel.name}</span>
</div>
{isSelected && !isAssigned && (
<div>
<Select
className="w-36"
classNames={{
trigger: "min-h-10 h-10",
}}
placeholder="不限速"
selectedKeys={
batchTunnelSelections.get(tunnel.id) !==
null &&
batchTunnelSelections.get(tunnel.id) !==
undefined
? [
batchTunnelSelections
.get(tunnel.id)!
.toString(),
]
: []
}
size="sm"
onClick={(e) => e.stopPropagation()}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
updateTunnelSpeedLimit(
tunnel.id,
selectedKey ? Number(selectedKey) : null,
);
}}
>
{tunnelSpeedLimits.map((sl) => (
<SelectItem
key={sl.id.toString()}
textValue={sl.name}
{/* 右侧限速选择:复刻 image_24879b */}
{isSelected && !isAssigned && (
<div className="flex items-center ml-2" onClick={(e) => e.stopPropagation()}>
<Select
aria-label="限速选择"
className="w-32"
size="sm"
variant="flat"
placeholder="不限速"
selectedKeys={currentSpeedId ? [currentSpeedId.toString()] : []}
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0];
updateTunnelSpeedLimit(tunnel.id, selectedKey ? Number(selectedKey) : null);
}}
>
{sl.name}
</SelectItem>
))}
</Select>
{tunnelSpeedLimits.map(sl => <SelectItem key={sl.id.toString()}>{sl.name}</SelectItem>)}
</Select>
</div>
)}
{isAssigned && <span className="text-[10px] text-default-400 italic pr-2">已分配</span>}
</div>
)}
</div>
);
})}
</div>
<div className="bg-default-50/80 border-t p-2 flex justify-end">
<Button size="md" variant="light" color="primary" className="font-bold" onPress={() => setIsTunnelListExpanded(false)}>完成配置</Button>
</div>
);
})}
{tunnels.length === 0 && (
<div className="p-8 text-center text-default-400 bg-default-50 dark:bg-default-100/50 rounded-lg border border-dashed border-default-200 dark:border-default-100/30">
暂无可用隧道
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
className="w-full sm:w-auto"
className="w-fit px-8"
color="primary"
isDisabled={batchTunnelSelections.size === 0}
isLoading={assignLoading}
@@ -1618,140 +1488,103 @@ export default function UserPage() {
{/* 已有权限部分 */}
<div>
<h3 className="text-lg font-semibold mb-4">已有权限</h3>
<Table
aria-label="用户隧道权限列表"
classNames={{
wrapper: "shadow-none",
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
}}
>
<TableHeader>
<TableColumn>隧道名称</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>规则数量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>限速规则</TableColumn>
<TableColumn>重置时间</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无隧道权限"
isLoading={tunnelListLoading}
items={userTunnels}
loadingContent={<Spinner />}
>
{(userTunnel) => (
<TableRow key={userTunnel.id}>
<TableCell>{userTunnel.tunnelName}</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex justify-between text-small">
<span className="text-gray-600">限制:</span>
<span className="font-medium">
{formatFlow(userTunnel.flow, "gb")}
</span>
</div>
<div className="flex justify-between text-small">
<span className="text-gray-600">已用:</span>
<span className="font-medium text-danger">
{formatFlow(
calculateTunnelUsedFlow(userTunnel),
)}
</span>
</div>
</div>
</TableCell>
<TableCell>{userTunnel.num}</TableCell>
<TableCell>
<Chip
color={
userTunnel.status === 1 ? "success" : "danger"
}
size="sm"
variant="flat"
>
{userTunnel.status === 1 ? "正常" : "禁用"}
</Chip>
</TableCell>
<TableCell>
<Chip
color={
userTunnel.speedLimitName ? "warning" : "success"
}
size="sm"
variant="flat"
>
{userTunnel.speedLimitName || "不限速"}
</Chip>
</TableCell>
<TableCell>
{userTunnel.flowResetTime === 0
? "不重置"
: `每月${userTunnel.flowResetTime}号`}
</TableCell>
<TableCell>{formatDate(userTunnel.expTime)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
isIconOnly
aria-label="编辑隧道权限"
color="primary"
size="sm"
variant="flat"
onPress={() => handleEditTunnel(userTunnel)}
>
<EditIcon className="w-4 h-4" />
</Button>
<Button
isIconOnly
aria-label="重置隧道流量"
color="warning"
size="sm"
title="重置流量"
variant="flat"
onPress={() => handleResetTunnelFlow(userTunnel)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
fillRule="evenodd"
/>
</svg>
</Button>
<Button
isIconOnly
aria-label="删除隧道权限"
color="danger"
size="sm"
variant="flat"
onPress={() => handleRemoveTunnel(userTunnel)}
>
<DeleteIcon className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Table
aria-label="用户隧道权限列表"
// 1. 去掉 layout="fixed",改为在 classNames.table 里写 table-fixed
classNames={{
wrapper: "shadow-none p-0 min-w-[380px] overflow-x-auto",
th: "bg-default-50 text-default-600 font-semibold",
td: "py-4 border-b border-divider"
}}
>
<TableHeader>
<TableColumn className="w-[35%]">隧道名称</TableColumn>
<TableColumn className="w-[35%]">流量统计</TableColumn>
<TableColumn className="w-[30%] text-right">操作</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无隧道权限"
isLoading={tunnelListLoading}
items={userTunnels}
loadingContent={<Spinner />}
>
{(userTunnel) => (
<TableRow key={userTunnel.id} className="hover:bg-default-50/50 transition-colors">
<TableCell>
<div className="flex items-center gap-2">
<span className="font-bold text-default-700 whitespace-nowrap">
{userTunnel.tunnelName}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 whitespace-nowrap text-sm">
<span className="text-danger font-mono font-bold">
{formatFlow(calculateTunnelUsedFlow(userTunnel))}
</span>
<span className="text-default-300">/</span>
<span className="text-default-500 font-mono">
{formatFlow(userTunnel.flow, "gb")}
</span>
</div>
</TableCell>
<TableCell>
{/* 5. justify-end 确保按钮群组整体靠右 */}
<div className="flex items-center justify-end gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
className="bg-blue-50 text-blue-600 hover:bg-blue-100 w-8 h-8 min-w-8"
onPress={() => handleEditTunnel(userTunnel)}
>
<EditIcon className="w-4 h-4" />
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
className="bg-orange-50 text-orange-600 hover:bg-orange-100 w-8 h-8 min-w-8"
onPress={() => handleResetTunnelFlow(userTunnel)}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path clipRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" fillRule="evenodd" />
</svg>
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
className="bg-danger-50 text-danger hover:bg-danger-100 w-8 h-8 min-w-8"
onPress={() => handleRemoveTunnel(userTunnel)}
>
<DeleteIcon className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onTunnelModalClose}>关闭</Button>
</ModalFooter>
<ModalFooter className="justify-end">
<Button
onPress={onTunnelModalClose}
color="primary"
variant="flat" // 建议加个 variant 保持和你其他按钮风格一致
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* 编辑隧道权限模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }}
backdrop="blur"
isDismissable={false}
isOpen={isEditTunnelModalOpen}
@@ -1929,7 +1762,7 @@ export default function UserPage() {
</Modal>
{/* 删除确认对话框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }}
backdrop="blur"
isOpen={isDeleteModalOpen}
placement="center"
@@ -1972,7 +1805,7 @@ export default function UserPage() {
</Modal>
{/* 删除隧道权限确认对话框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }}
backdrop="blur"
isOpen={isDeleteTunnelModalOpen}
placement="center"
@@ -2017,7 +1850,7 @@ export default function UserPage() {
</Modal>
{/* 重置流量确认对话框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }}
backdrop="blur"
isOpen={isResetFlowModalOpen}
placement="center"
@@ -2106,7 +1939,7 @@ export default function UserPage() {
</Modal>
{/* 重置隧道流量确认对话框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }}
backdrop="blur"
isOpen={isResetTunnelFlowModalOpen}
placement="center"