Merge remote-tracking branch 'origin/main' into opencode/shiny-falcon

# Conflicts:
#	vite-frontend/src/pages/node.tsx
#	vite-frontend/src/pages/user.tsx
This commit is contained in:
sagitchu
2026-03-17 15:18:25 +08:00
175 changed files with 2403 additions and 168577 deletions
+2
View File
@@ -88,6 +88,8 @@ export const getNodeInstallCommand = (
export const updateNodeOrder = (data: {
nodes: Array<{ id: number; inx: number }>;
}) => Network.post("/node/update-order", data);
export const dismissNodeExpiryReminder = (id: number) =>
Network.post("/node/dismiss-expiry-reminder", { id });
export const checkNodeStatus = (nodeId?: number) => {
const params = nodeId ? { nodeId } : {};
+1
View File
@@ -6,6 +6,7 @@ export interface NodeApiItem {
remark?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
expiryReminderDismissed?: number;
syncError?: string;
[key: string]: unknown;
}
+5 -4
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { CheckIcon, MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -11,17 +11,18 @@ function Checkbox({
return (
<CheckboxPrimitive.Root
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow transition-transform duration-100 active:scale-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=checked]:shadow-none data-[state=indeterminate]:shadow-none",
className,
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150"
className="flex items-center justify-center text-current data-[state=checked]:animate-in data-[state=checked]:zoom-in-75 data-[state=checked]:duration-150 data-[state=indeterminate]:animate-in data-[state=indeterminate]:zoom-in-75 data-[state=indeterminate]:duration-150"
data-slot="checkbox-indicator"
>
<CheckIcon className="h-3.5 w-3.5" />
<CheckIcon className="h-3.5 w-3.5 data-[state=indeterminate]:hidden" />
<MinusIcon className="h-3.5 w-3.5 hidden data-[state=indeterminate]:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
+3
View File
@@ -657,6 +657,9 @@ export default function AdminLayout({
{/* 修改密码弹窗 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
+39 -26
View File
@@ -882,24 +882,13 @@ export default function ConfigPage() {
<Card className="shadow-md">
<CardHeader className="pb-6">
<div className="flex justify-between items-center w-full">
<div className="flex items-center w-full">
<div>
<h2 className="text-xl font-semibold">基本设置</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
配置网站的基本信息,这些设置会影响网站的显示效果
</p>
</div>
<div className="flex gap-2">
<Button
color="primary"
disabled={!hasChanges}
isLoading={saving}
startContent={<SaveIcon className="w-4 h-4" />}
onPress={handleSave}
>
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
</CardHeader>
@@ -975,19 +964,29 @@ export default function ConfigPage() {
</SelectItem>
</Select>
</div>
<div className="flex justify-end pt-6 border-t border-divider/50 mt-4">
<Button
color="primary"
disabled={!hasChanges}
isLoading={saving}
startContent={<SaveIcon className="w-4 h-4" />}
onPress={handleSave}
>
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</CardBody>
</Card>
{hasChanges && (
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<CardBody className="py-3">
<div className="w-full flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse" />
<span className="text-sm font-medium">
检测到配置变更,请记得保存您的修改
</span>
</div>
</CardBody>
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 shadow-sm overflow-hidden">
<div className="h-10 flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
<div className="w-2 h-2 bg-warning-500 rounded-full animate-pulse flex-shrink-0" />
<span className="text-sm font-medium leading-none">
检测到配置变更,请记得保存您的修改
</span>
</div>
</Card>
)}
@@ -1045,7 +1044,7 @@ export default function ConfigPage() {
公告支持 Markdown 语法,链接会在新标签页打开
</p>
<div className="flex justify-end">
<div className="flex justify-end mt-4 pt-4 border-t border-divider/50">
<Button
color="primary"
isLoading={announcementSaving}
@@ -1086,7 +1085,7 @@ export default function ConfigPage() {
当前已选 {exportTypes.length} / {BACKUP_TYPE_VALUES.length}
</p>
<div className="flex gap-3">
<div className="flex justify-end gap-3 pt-4">
<Button
color="primary"
isLoading={exporting}
@@ -1117,7 +1116,7 @@ export default function ConfigPage() {
onChange={handleFileChange}
/>
<div className="flex gap-3">
<div className="flex justify-end gap-3 pt-4">
<Button
color="primary"
isLoading={importing}
@@ -1136,7 +1135,14 @@ export default function ConfigPage() {
</CardBody>
</Card>
<Modal isOpen={exportSelectorOpen} onOpenChange={setExportSelectorOpen}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={exportSelectorOpen}
onOpenChange={setExportSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
@@ -1161,7 +1167,14 @@ export default function ConfigPage() {
</ModalContent>
</Modal>
<Modal isOpen={importSelectorOpen} onOpenChange={setImportSelectorOpen}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={importSelectorOpen}
onOpenChange={setImportSelectorOpen}
>
<ModalContent>
{(onClose) => (
<>
@@ -226,8 +226,10 @@ const normalizeNodeExpiryReminders = (items: NodeApiItem[]) => {
typeof item.expiryTime === "number" && item.expiryTime > 0
? item.expiryTime
: undefined,
expiryReminderDismissed: item.expiryReminderDismissed,
}))
.filter((item) => {
if (item.expiryReminderDismissed) return false;
if (!item.expiryTime || !item.renewalCycle) return false;
const snapshot = getNodeRenewalSnapshot(
item.expiryTime,
File diff suppressed because it is too large Load Diff
+37 -8
View File
@@ -477,10 +477,15 @@ export default function GroupPage() {
)}
<Card>
<CardHeader className="flex items-center justify-between">
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button color="primary" size="sm" onPress={openCreateTunnelGroup}>
新建隧道分组
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateTunnelGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
@@ -544,10 +549,15 @@ export default function GroupPage() {
</Card>
<Card>
<CardHeader className="flex items-center justify-between">
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button color="primary" size="sm" onPress={openCreateUserGroup}>
新建用户分组
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
@@ -651,7 +661,7 @@ export default function GroupPage() {
size="sm"
onPress={handleAssignPermission}
>
分配权限
分配
</Button>
</div>
@@ -692,6 +702,10 @@ export default function GroupPage() {
</Card>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
@@ -735,7 +749,14 @@ export default function GroupPage() {
</ModalContent>
</Modal>
<Modal isOpen={userGroupModalOpen} onOpenChange={onUserGroupModalChange}>
<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 ? "编辑用户分组" : "新建用户分组"}
@@ -777,6 +798,10 @@ export default function GroupPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
@@ -824,6 +849,10 @@ export default function GroupPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
+6
View File
@@ -341,6 +341,9 @@ export default function LimitPage() {
{/* 新增/编辑模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
@@ -416,6 +419,9 @@ export default function LimitPage() {
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
+157 -34
View File
@@ -52,6 +52,7 @@ import {
getNodeReleases,
rollbackNode,
getPeerRemoteUsageList,
dismissNodeExpiryReminder,
type ReleaseChannel,
} from "@/api";
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
@@ -84,6 +85,7 @@ interface Node {
remark?: string;
expiryTime?: number;
renewalCycle?: NodeRenewalCycle;
expiryReminderDismissed?: number;
ip: string;
serverIp: string;
serverIpV4?: string;
@@ -243,7 +245,7 @@ const SortableItem = ({
children,
}: {
id: number;
children: (listeners: any) => any;
children: (listeners: any, attributes?: any) => any;
}) => {
const {
attributes,
@@ -268,14 +270,8 @@ const SortableItem = ({
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className="overflow-visible h-full"
{...listeners}
>
{children(listeners)}
<div ref={setNodeRef} className="overflow-visible h-full" style={style}>
{children(listeners, attributes)}
</div>
);
};
@@ -316,6 +312,8 @@ export default function NodePage() {
const [submitLoading, setSubmitLoading] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [rollbackModalOpen, setRollbackModalOpen] = useState(false);
const [nodeToRollback, setNodeToRollback] = useState<Node | null>(null);
const [nodeToDelete, setNodeToDelete] = useState<Node | null>(null);
const [protocolDisabled, setProtocolDisabled] = useState(false);
const [protocolDisabledReason, setProtocolDisabledReason] = useState("");
@@ -328,7 +326,7 @@ export default function NodePage() {
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
port: "10000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
@@ -378,6 +376,7 @@ export default function NodePage() {
const [upgradeProgress, setUpgradeProgress] = useState<
Record<number, { stage: string; percent: number; message: string }>
>({});
const [infoPopoverPlacement, setInfoPopoverPlacement] = useState<
Record<number, "left" | "bottom">
>({});
@@ -900,7 +899,7 @@ export default function NodePage() {
serverHost: normalizedHost,
serverIpV4: normalizedV4,
serverIpV6: normalizedV6,
port: node.port || "1000-65535",
port: node.port || "10000-65535",
tcpListenAddr: node.tcpListenAddr || "[::]",
udpListenAddr: node.udpListenAddr || "[::]",
interfaceName: (node as any).interfaceName || "",
@@ -946,6 +945,24 @@ export default function NodePage() {
}
};
const handleDismissExpiryReminder = async (nodeId: number) => {
try {
const res = await dismissNodeExpiryReminder(nodeId);
if (res.code === 0) {
setNodeList((prev) =>
prev.map((n) =>
n.id === nodeId ? { ...n, expiryReminderDismissed: 1 } : n,
),
);
} else {
toast.error(res.msg || "操作失败");
}
} catch {
toast.error("网络错误,请重试");
}
};
const openInstallSelector = (node: Node) => {
setInstallTargetNode(node);
setInstallChannel("stable");
@@ -1113,7 +1130,16 @@ export default function NodePage() {
};
// 回退节点
const handleRollbackNode = async (node: Node) => {
const handleRollbackNode = (node: Node) => {
setNodeToRollback(node);
setRollbackModalOpen(true);
};
const confirmRollback = async () => {
if (!nodeToRollback) return;
const node = nodeToRollback;
setRollbackModalOpen(false);
setNodeList((prev) =>
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: true } : n)),
);
@@ -1133,6 +1159,7 @@ export default function NodePage() {
n.id === node.id ? { ...n, rollbackLoading: false } : n,
),
);
setNodeToRollback(null);
}
};
@@ -1216,7 +1243,7 @@ export default function NodePage() {
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
port: "10000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
@@ -1523,7 +1550,11 @@ export default function NodePage() {
onPress={() => setActiveTab("local")}
>
本地节点
<Chip className="ml-1" size="sm" variant="flat">
<Chip
className="ml-1 shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
{localNodes.length}
</Chip>
</Button>
@@ -1535,7 +1566,11 @@ export default function NodePage() {
onPress={() => setActiveTab("remote")}
>
远程节点
<Chip className="ml-1" size="sm" variant="flat">
<Chip
className="ml-1 shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
{remoteNodes.length}
</Chip>
</Button>
@@ -1557,7 +1592,17 @@ export default function NodePage() {
value={currentSearchKeyword}
onChange={setCurrentSearchKeyword}
onClose={() => setIsSearchVisible(false)}
onOpen={() => setIsSearchVisible(true)}
onOpen={() => {
setIsSearchVisible(true);
setTimeout(() => {
// 精准抓取带有“搜索”字样的输入框并强制聚焦
const searchInput = document.querySelector(
'input[placeholder*="搜索"]',
);
if (searchInput) (searchInput as HTMLElement).focus();
}, 150); // 150ms 刚好是 CSS 展开动画的时间
}}
/>
</div>
@@ -1733,7 +1778,10 @@ export default function NodePage() {
);
const hasRemark = Boolean(node.remark?.trim());
const hasExpiryInfo = Boolean(
node.expiryTime && node.expiryTime > 0 && node.renewalCycle,
node.expiryTime &&
node.expiryTime > 0 &&
node.renewalCycle &&
!node.expiryReminderDismissed,
);
const hasInfoTrigger = hasRemark || hasExpiryInfo;
const infoCount = Number(hasExpiryInfo) + Number(hasRemark);
@@ -1832,8 +1880,20 @@ export default function NodePage() {
<div className="space-y-3">
{hasExpiryInfo && (
<div className="space-y-2">
<div className="text-[11px] font-medium text-default-500">
到期提醒
<div className="flex items-center justify-between">
<div className="text-[11px] font-medium text-default-500">
到期提醒
</div>
<button
className="text-[10px] text-default-400 hover:text-default-600 transition-colors"
type="button"
onClick={(e) => {
e.stopPropagation();
handleDismissExpiryReminder(node.id);
}}
>
关闭提醒
</button>
</div>
<div className="flex flex-wrap gap-1.5">
<Chip
@@ -2030,7 +2090,7 @@ export default function NodePage() {
{remoteUsage.usedPorts.map((port) => (
<Chip
key={`${node.id}-port-${port}`}
className="font-mono"
className="font-mono shrink-0 whitespace-nowrap"
size="sm"
variant="flat"
>
@@ -2416,6 +2476,9 @@ export default function NodePage() {
{/* 新增/编辑节点对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={dialogVisible}
placement="center"
scrollBehavior="outside"
@@ -2485,10 +2548,11 @@ export default function NodePage() {
errorMessage={errors.expiryTime}
isInvalid={!!errors.expiryTime}
label="续费基准时间"
type="datetime-local"
max="9999-12-31"
type="date"
value={
form.expiryTime > 0
? new Date(form.expiryTime).toISOString().slice(0, 16)
? new Date(form.expiryTime).toISOString().slice(0, 10)
: ""
}
variant="bordered"
@@ -2509,11 +2573,11 @@ export default function NodePage() {
/>
<Input
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6/域名"
description="可选:不带协议、不带端口。建议在 IPv4 和 IPv6 都未填写时使用。至少填写一个 IPv4/IPv6 域名"
errorMessage={errors.serverHost}
isInvalid={!!errors.serverHost}
label="服务器域名/主机名"
placeholder="例如: node.example.com"
label="域名/主机名"
placeholder="例如: test.example.com"
value={form.serverHost}
variant="bordered"
onChange={(e) =>
@@ -2523,11 +2587,11 @@ export default function NodePage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
description="双栈节点组隧道时优先使用 IPv4"
description="填写一个 IPv4 地址"
errorMessage={errors.serverIpV4}
isInvalid={!!errors.serverIpV4}
label="服务器IPv4"
placeholder="例如: 203.0.113.10"
label="IPv4 地址"
placeholder="例如: 192.168.1.100"
value={form.serverIpV4}
variant="bordered"
onChange={(e) =>
@@ -2536,10 +2600,10 @@ export default function NodePage() {
/>
<Input
description="至少填写一个 IPv4/IPv6/域名"
description="填写一个 IPv6 地址"
errorMessage={errors.serverIpV6}
isInvalid={!!errors.serverIpV6}
label="服务器IPv6"
label="IPv6 地址"
placeholder="例如: 2001:db8::10"
value={form.serverIpV6}
variant="bordered"
@@ -2553,11 +2617,11 @@ export default function NodePage() {
classNames={{
input: "font-mono",
}}
description="支持单个端口(80)、多个端口(80,443)或端口范围(1000-65535),多个可用逗号分隔"
description="支持单个端口(80)、多个端口(80,443)或端口范围(10000-65535),多个可用逗号分隔"
errorMessage={errors.port}
isInvalid={!!errors.port}
label="可用端口"
placeholder="例如: 80,443,1000-65535"
placeholder="例如: 80,443,10000-65535"
value={form.port}
variant="bordered"
onChange={(e) =>
@@ -2810,7 +2874,7 @@ export default function NodePage() {
<Alert
className="mt-4"
color="primary"
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
description="节点ip地址是你要添加的入口/出口的ip地址,不是面板的ip地址。"
variant="flat"
/>
</div>
@@ -2830,9 +2894,53 @@ export default function NodePage() {
</ModalContent>
</Modal>
{/* 回退确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={rollbackModalOpen}
placement="center"
scrollBehavior="outside"
size="2xl"
onOpenChange={setRollbackModalOpen}
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
<h2 className="text-xl font-bold">确认回退</h2>
</ModalHeader>
<ModalBody>
<p>
确定要将节点{" "}
<strong>&quot;{nodeToRollback?.name}&quot;</strong>{" "}
回退到上一个版本吗?
</p>
<p className="text-small text-default-500">
节点将执行版本回退并自动重启,期间会导致节点短暂离线。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
取消
</Button>
<Button color="secondary" onPress={confirmRollback}>
确认回退
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2873,6 +2981,9 @@ export default function NodePage() {
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={installSelectorOpen}
placement="center"
size="md"
@@ -2923,6 +3034,9 @@ export default function NodePage() {
{/* 安装命令模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={installCommandModal}
placement="center"
scrollBehavior="outside"
@@ -2977,6 +3091,9 @@ export default function NodePage() {
{/* 版本选择升级模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={upgradeModalOpen}
placement="center"
scrollBehavior="outside"
@@ -3039,7 +3156,7 @@ export default function NodePage() {
: ""}
{r.channel === "dev" && (
<Chip
className="ml-1"
className="ml-1 shrink-0 whitespace-nowrap"
color="warning"
size="sm"
variant="flat"
@@ -3080,6 +3197,9 @@ export default function NodePage() {
{/* 批量删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={batchDeleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -3119,6 +3239,9 @@ export default function NodePage() {
</Modal>
<Modal
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isFilterModalOpen}
placement="center"
size="md"
+16 -1
View File
@@ -678,6 +678,10 @@ export default function PanelSharingPage() {
{/* Create Share Modal */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={createShareOpen}
scrollBehavior="inside"
onClose={() => setCreateShareOpen(false)}
@@ -785,6 +789,10 @@ export default function PanelSharingPage() {
{/* Edit Share Modal */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={editShareOpen}
scrollBehavior="inside"
onClose={() => setEditShareOpen(false)}
@@ -883,7 +891,14 @@ export default function PanelSharingPage() {
</Modal>
{/* Import Node Modal */}
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={importNodeOpen}
onClose={() => setImportNodeOpen(false)}
>
<ModalContent>
<ModalHeader>导入远程节点</ModalHeader>
<ModalBody>
+3
View File
@@ -327,6 +327,9 @@ export default function ProfilePage() {
{/* 修改密码弹窗 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
+14 -8
View File
@@ -247,14 +247,14 @@ export default function TunnelPage() {
const [selectMode, setSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
active: false,
label: "",
percent: 0,
});
const [batchResultModal, setBatchResultModal] =
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
useEffect(() => {
return () => {
@@ -1263,7 +1263,7 @@ export default function TunnelPage() {
</div>
</div>
<div
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
className="cursor-grab active:cursor-grabbing p-1 -mr-1 text-default-400 hover:text-default-600 transition-colors touch-manipulation flex-shrink-0"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
@@ -1515,6 +1515,9 @@ export default function TunnelPage() {
{/* 新增/编辑模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
@@ -2449,6 +2452,9 @@ export default function TunnelPage() {
{/* 删除确认模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2491,10 +2497,7 @@ export default function TunnelPage() {
<Modal
backdrop="blur"
classNames={{
base: "rounded-2xl",
header: "rounded-t-2xl",
body: "rounded-none",
footer: "rounded-b-2xl",
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={diagnosisModalOpen}
placement="center"
@@ -3077,6 +3080,10 @@ export default function TunnelPage() {
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={batchDeleteModalOpen}
onOpenChange={setBatchDeleteModalOpen}
>
@@ -3118,7 +3125,6 @@ export default function TunnelPage() {
return;
}
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
}}
/>
+195 -221
View File
@@ -64,10 +64,10 @@ import {
removeMonitorPermission,
} from "@/api";
import {
SearchIcon,
EditIcon,
DeleteIcon,
SettingsIcon,
SearchIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
@@ -208,7 +208,7 @@ export default function UserPage() {
user: "",
pwd: "",
status: 1,
flow: 100,
flow: 1000,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
@@ -245,6 +245,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());
@@ -507,7 +508,7 @@ export default function UserPage() {
user: "",
pwd: "",
status: 1,
flow: 100,
flow: 1000,
dailyQuotaGB: 0,
monthlyQuotaGB: 0,
num: 10,
@@ -1275,6 +1276,9 @@ export default function UserPage() {
{/* 用户表单模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isUserModalOpen}
placement="center"
scrollBehavior="outside"
@@ -1319,38 +1323,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="规则数量"
@@ -1547,7 +1519,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}
@@ -1598,136 +1570,147 @@ 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="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,
);
{/* 顶部触发框 */}
<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>
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;
}
{/* 列表悬浮层 */}
{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,
);
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;
{/* 右侧限速选择:复刻 image_24879b */}
{isSelected && !isAssigned && (
<div
className="flex items-center ml-2"
onClick={(e) => e.stopPropagation()}
>
<Select
aria-label="限速选择"
className="w-32"
placeholder="不限速"
selectedKeys={
currentSpeedId
? [currentSpeedId.toString()]
: []
}
size="sm"
variant="flat"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0];
updateTunnelSpeedLimit(
tunnel.id,
selectedKey ? Number(selectedKey) : null,
);
}}
>
{tunnelSpeedLimits.map((sl) => (
<SelectItem
key={sl.id.toString()}
textValue={sl.name}
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
className="font-bold"
color="primary"
size="md"
variant="light"
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">
<Button
className="w-full sm:w-auto"
className="w-fit px-8"
color="primary"
isDisabled={batchTunnelSelections.size === 0}
isLoading={assignLoading}
@@ -1749,20 +1732,19 @@ export default function UserPage() {
<h3 className="text-lg font-semibold mb-4">已有权限</h3>
<Table
aria-label="用户隧道权限列表"
// 1. 去掉 layout="fixed",改为在 classNames.table 里写 table-fixed
classNames={{
wrapper: "shadow-none",
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
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>隧道名称</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>规则数量</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>限速规则</TableColumn>
<TableColumn>重置时间</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn>
<TableColumn className="w-[35%]">隧道名称</TableColumn>
<TableColumn className="w-[35%]">流量统计</TableColumn>
<TableColumn className="w-[30%] text-right">
操作
</TableColumn>
</TableHeader>
<TableBody
emptyContent="暂无隧道权限"
@@ -1771,61 +1753,36 @@ export default function UserPage() {
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>
<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
aria-label="编辑隧道权限"
color="primary"
className="bg-blue-50 text-blue-600 hover:bg-blue-100 w-8 h-8 min-w-8"
size="sm"
variant="flat"
onPress={() => handleEditTunnel(userTunnel)}
@@ -1834,15 +1791,12 @@ export default function UserPage() {
</Button>
<Button
isIconOnly
aria-label="重置隧道流量"
color="warning"
className="bg-orange-50 text-orange-600 hover:bg-orange-100 w-8 h-8 min-w-8"
size="sm"
title="重置流量"
variant="flat"
onPress={() => handleResetTunnelFlow(userTunnel)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
@@ -1856,8 +1810,7 @@ export default function UserPage() {
</Button>
<Button
isIconOnly
aria-label="删除隧道权限"
color="danger"
className="bg-danger-50 text-danger hover:bg-danger-100 w-8 h-8 min-w-8"
size="sm"
variant="flat"
onPress={() => handleRemoveTunnel(userTunnel)}
@@ -1873,8 +1826,14 @@ export default function UserPage() {
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onPress={onTunnelModalClose}>关闭</Button>
<ModalFooter className="justify-end">
<Button
color="primary"
variant="flat" // 建议加个 variant 保持和你其他按钮风格一致
onPress={onTunnelModalClose}
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
@@ -1882,6 +1841,9 @@ export default function UserPage() {
{/* 编辑隧道权限模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isDismissable={false}
isOpen={isEditTunnelModalOpen}
placement="center"
@@ -2060,6 +2022,9 @@ export default function UserPage() {
{/* 删除确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isDeleteModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2103,6 +2068,9 @@ export default function UserPage() {
{/* 删除隧道权限确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isDeleteTunnelModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2148,6 +2116,9 @@ export default function UserPage() {
{/* 重置流量确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isResetFlowModalOpen}
placement="center"
scrollBehavior="outside"
@@ -2237,6 +2208,9 @@ export default function UserPage() {
{/* 重置隧道流量确认对话框 */}
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isResetTunnelFlowModalOpen}
placement="center"
scrollBehavior="outside"
@@ -11,6 +11,7 @@ export interface CheckboxProps
classNames?: Record<string, string>;
color?: string;
isDisabled?: boolean;
isIndeterminate?: boolean;
isSelected?: boolean;
onValueChange?: (value: boolean) => void;
size?: string;
@@ -20,6 +21,7 @@ export function Checkbox({
children,
className,
isDisabled,
isIndeterminate,
isSelected,
onValueChange,
...props
@@ -32,6 +34,10 @@ export function Checkbox({
onValueChange?.(value === true);
};
const checkedValue: boolean | "indeterminate" = isIndeterminate
? "indeterminate"
: Boolean(isSelected);
return (
<div
className={cn(
@@ -41,7 +47,7 @@ export function Checkbox({
)}
>
<BaseCheckbox
checked={Boolean(isSelected)}
checked={checkedValue}
disabled={isDisabled}
onCheckedChange={handleCheckedChange}
{...props}