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
-108
View File
@@ -1,5 +1,3 @@
import type { BatchOperationFailure } from "@/api/types";
import axios from "axios";
interface ErrorPayload {
@@ -7,20 +5,6 @@ interface ErrorPayload {
message?: string;
}
interface BatchFailurePayload {
id?: number;
name?: string;
reason?: string;
msg?: string;
message?: string;
}
interface BatchResultPayload {
failures?: unknown[];
}
const MAX_BATCH_FAILURES_IN_TOAST = 3;
export const isUnauthorizedError = (error: unknown): boolean => {
return axios.isAxiosError(error) && error.response?.status === 401;
};
@@ -41,95 +25,3 @@ export const extractApiErrorMessage = (
return fallback;
};
const normalizeBatchFailure = (
failure: unknown,
): BatchOperationFailure | null => {
if (typeof failure === "string") {
const reason = failure.trim();
return reason ? { reason } : null;
}
const payload = (failure ?? {}) as BatchFailurePayload;
const id =
typeof payload.id === "number" && Number.isFinite(payload.id)
? payload.id
: undefined;
const name = typeof payload.name === "string" ? payload.name.trim() : "";
const reasonSource = [payload.reason, payload.msg, payload.message].find(
(item) => typeof item === "string" && item.trim() !== "",
);
const reason = typeof reasonSource === "string" ? reasonSource.trim() : "";
if (!name && !reason && id === undefined) {
return null;
}
return {
...(id !== undefined ? { id } : {}),
...(name ? { name } : {}),
...(reason ? { reason } : {}),
};
};
const normalizeBatchFailureReason = (
failure: BatchOperationFailure,
): string => {
const name = typeof failure.name === "string" ? failure.name.trim() : "";
const reason =
typeof failure.reason === "string" ? failure.reason.trim() : "";
if (name && reason) {
return `${name}: ${reason}`;
}
if (reason) {
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
return `ID ${failure.id}: ${reason}`;
}
return reason;
}
if (name) {
return name;
}
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
return `ID ${failure.id} 下发失败`;
}
return "";
};
export const extractBatchFailures = (
result: unknown,
): BatchOperationFailure[] => {
const payload = (result ?? {}) as BatchResultPayload;
return Array.isArray(payload.failures)
? payload.failures
.map((item) => normalizeBatchFailure(item))
.filter((item): item is BatchOperationFailure => item !== null)
: [];
};
export const buildBatchFailureMessage = (
result: unknown,
fallbackSummary: string,
): string => {
const failures = extractBatchFailures(result)
.map((item) => normalizeBatchFailureReason(item))
.filter((item) => item !== "");
if (failures.length === 0) {
return fallbackSummary;
}
const visibleFailures = failures.slice(0, MAX_BATCH_FAILURES_IN_TOAST);
const hiddenCount = failures.length - visibleFailures.length;
const hiddenSuffix = hiddenCount > 0 ? ` 等 ${failures.length} 项` : "";
return `${fallbackSummary}:${visibleFailures.join(";")}${hiddenSuffix}`;
};
-2
View File
@@ -78,8 +78,6 @@ 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 } : {};
-9
View File
@@ -6,7 +6,6 @@ export interface NodeApiItem {
remark?: string;
expiryTime?: number;
renewalCycle?: "month" | "quarter" | "year" | "";
expiryReminderDismissed?: number;
syncError?: string;
[key: string]: unknown;
}
@@ -211,14 +210,6 @@ export interface UserPackageInfoApiData {
export interface BatchOperationResult {
successCount: number;
failCount: number;
failures?: BatchOperationFailure[];
[key: string]: unknown;
}
export interface BatchOperationFailure {
id?: number;
name?: string;
reason?: string;
[key: string]: unknown;
}
+1 -7
View File
@@ -56,18 +56,12 @@ function DialogContent({
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
{...props}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-default-200 bg-white 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=open]:slide-in-from-bottom-4 data-[state=closed]:slide-out-to-bottom-2 sm:rounded-lg dark:bg-default-50",
className,
)}
data-slot="dialog-content"
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
{...props}
>
{children}
{showCloseButton && (
@@ -87,12 +87,7 @@ export function VersionFooter({
return (
<div className={containerClassName}>
<p className={versionClassName}>
v{version}
{updateAvailable && latestUpdateVersion && (
<span className={updateBadgeClassName} role="status">
{latestUpdateVersion}
</span>
)}
{version}
</p>
<p className={poweredClassName}>
Powered by{" "}
@@ -102,7 +97,7 @@ export function VersionFooter({
rel="noopener noreferrer"
target="_blank"
>
FLVX
Flvx
</a>
</p>
</div>
+1 -1
View File
@@ -574,7 +574,7 @@ export default function AdminLayout({
</div>
{/* 修改密码弹窗 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={isOpen}
placement="center"
+23 -66
View File
@@ -88,7 +88,7 @@ const CONFIG_ITEMS: ConfigItem[] = [
label: "面板后端地址",
placeholder: "请输入面板后端IP:PORT",
description:
'格式"ip:port"或"domain:port",用于对接节点时使用。支持套CDN和HTTPS,通讯数据有加密',
"格式“ip:port”,用于对接节点时使用,ip是你安装面板服务器的公网ip,端口是安装脚本内输入的后端端口。不要套CDN,不支持https,通讯数据有加密",
type: "input",
},
{
@@ -332,14 +332,6 @@ export default function ConfigPage() {
}
};
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
const handleConfigChange = (key: string, value: string) => {
const newConfigs = { ...configs, [key]: value };
@@ -882,24 +874,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>
@@ -942,52 +923,28 @@ export default function ConfigPage() {
<Divider className="my-2" />
<div className="space-y-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
更新通道
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
的版本。
</p>
</div>
<Select
selectedKeys={[updateChannel]}
size="md"
variant="bordered"
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
<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}
>
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
稳定版
</SelectItem>
<SelectItem
key="dev"
description="仅 alpha / beta / rc 关键字版本"
>
开发版
</SelectItem>
</Select>
{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 +1002,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 +1043,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 +1074,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 +1093,7 @@ 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 +1118,7 @@ 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,10 +226,8 @@ 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
@@ -1,4 +1,4 @@
import type { BatchOperationFailure, BatchOperationResult } from "@/api/types";
import type { BatchOperationResult } from "@/api/types";
import {
batchChangeTunnel,
@@ -7,19 +7,12 @@ import {
batchRedeployForwards,
batchResumeForwards,
} from "@/api";
import {
buildBatchFailureMessage,
extractBatchFailures,
extractApiErrorMessage,
} from "@/api/error-message";
import { extractApiErrorMessage } from "@/api/error-message";
export interface ForwardBatchActionOutcome {
toastVariant: "success" | "error";
toastMessage: string;
shouldRefresh: boolean;
resultTitle?: string;
resultSummary?: string;
failureDetails?: BatchOperationFailure[];
progressPercent?: number;
progressLabel?: string;
closeDeleteModal?: boolean;
@@ -33,41 +26,23 @@ const normalizeBatchResult = (value: unknown): BatchOperationResult => {
return {
successCount: Number(raw.successCount ?? 0),
failCount: Number(raw.failCount ?? 0),
failures: extractBatchFailures(raw),
};
};
const buildBatchToast = (
result: BatchOperationResult,
successText: string,
resultTitle: string,
): Pick<
ForwardBatchActionOutcome,
| "toastVariant"
| "toastMessage"
| "resultTitle"
| "resultSummary"
| "failureDetails"
> => {
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
if (result.failCount === 0) {
return {
toastVariant: "success",
toastMessage: successText,
resultTitle,
resultSummary: successText,
failureDetails: [],
};
}
return {
toastVariant: "error",
toastMessage: buildBatchFailureMessage(
result,
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
),
resultTitle,
resultSummary: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failureDetails: result.failures || [],
toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
};
};
@@ -88,11 +63,7 @@ export const executeForwardBatchDelete = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(
summary,
`成功删除 ${summary.successCount} 项`,
"批量删除结果",
),
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
@@ -134,7 +105,6 @@ export const executeForwardBatchToggleService = async (
enable
? `成功启用 ${summary.successCount} 项`
: `成功停用 ${summary.successCount} 项`,
enable ? "批量启用结果" : "批量停用结果",
),
shouldRefresh: true,
progressPercent: 100,
@@ -166,11 +136,7 @@ export const executeForwardBatchRedeploy = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(
summary,
`成功重新下发 ${summary.successCount} 项`,
"批量下发结果",
),
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
@@ -205,11 +171,7 @@ export const executeForwardBatchChangeTunnel = async (
const summary = normalizeBatchResult(response.data);
return {
...buildBatchToast(
summary,
`成功换隧道 ${summary.successCount} 项`,
"批量换隧道结果",
),
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
shouldRefresh: true,
progressPercent: 100,
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
+21 -11
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
color="primary"
size="sm"
className="h-7 px-3 text-xs font-medium min-w-0 shadow-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
color="primary"
size="sm"
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
@@ -651,7 +661,7 @@ export default function GroupPage() {
size="sm"
onPress={handleAssignPermission}
>
分配权限
分配
</Button>
</div>
@@ -691,7 +701,7 @@ export default function GroupPage() {
</CardBody>
</Card>
<Modal
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
@@ -735,7 +745,7 @@ 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 ? "编辑用户分组" : "新建用户分组"}
@@ -776,7 +786,7 @@ export default function GroupPage() {
</ModalContent>
</Modal>
<Modal
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
@@ -823,7 +833,7 @@ export default function GroupPage() {
</ModalContent>
</Modal>
<Modal
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
+2 -2
View File
@@ -339,7 +339,7 @@ export default function LimitPage() {
)}
{/* 新增/编辑模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={modalOpen}
placement="center"
@@ -414,7 +414,7 @@ export default function LimitPage() {
</Modal>
{/* 删除确认模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={deleteModalOpen}
placement="center"
+185 -288
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date";
import {
DndContext,
KeyboardSensor,
@@ -40,7 +40,6 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
import {
createNode,
getNodeList,
@@ -54,7 +53,6 @@ import {
getNodeReleases,
rollbackNode,
getPeerRemoteUsageList,
dismissNodeExpiryReminder,
type ReleaseChannel,
} from "@/api";
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
@@ -84,7 +82,6 @@ interface Node {
remark?: string;
expiryTime?: number;
renewalCycle?: NodeRenewalCycle;
expiryReminderDismissed?: number;
ip: string;
serverIp: string;
serverIpV4?: string;
@@ -252,7 +249,7 @@ const SortableItem = ({
children,
}: {
id: number;
children: (listeners: any) => any;
children: (listeners: any, attributes?: any) => any;
}) => {
const {
attributes,
@@ -280,11 +277,9 @@ const SortableItem = ({
<div
ref={setNodeRef}
style={style}
{...attributes}
className="overflow-visible h-full"
{...listeners}
>
{children(listeners)}
{children(listeners, attributes)}
</div>
);
};
@@ -318,6 +313,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("");
@@ -330,7 +327,7 @@ export default function NodePage() {
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
port: "10000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
@@ -865,7 +862,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 || "",
@@ -911,24 +908,6 @@ 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");
@@ -1081,9 +1060,7 @@ export default function NodePage() {
);
if (res.code === 0) {
toast.success(
`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`,
);
toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`);
} else {
toast.error(res.msg || "批量升级失败");
}
@@ -1096,13 +1073,20 @@ 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)),
);
try {
const res = await rollbackNode(node.id);
if (res.code === 0) {
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
} else {
@@ -1116,6 +1100,7 @@ export default function NodePage() {
n.id === node.id ? { ...n, rollbackLoading: false } : n,
),
);
setNodeToRollback(null);
}
};
@@ -1199,7 +1184,7 @@ export default function NodePage() {
serverHost: "",
serverIpV4: "",
serverIpV6: "",
port: "1000-65535",
port: "10000-65535",
tcpListenAddr: "[::]",
udpListenAddr: "[::]",
interfaceName: "",
@@ -1239,7 +1224,6 @@ export default function NodePage() {
}
const nextId = reorderedDisplayIds[reorderedDisplayIndex];
reorderedDisplayIndex += 1;
return nextId;
@@ -1419,8 +1403,7 @@ export default function NodePage() {
return nodes.filter(
(node) =>
(node.name && node.name.toLowerCase().includes(normalizedKeyword)) ||
(node.remark &&
node.remark.toLowerCase().includes(normalizedKeyword)) ||
(node.remark && node.remark.toLowerCase().includes(normalizedKeyword)) ||
(node.serverIp &&
node.serverIp.toLowerCase().includes(normalizedKeyword)) ||
(node.serverIpV4 &&
@@ -1440,31 +1423,34 @@ export default function NodePage() {
[sortedNodes],
);
const filteredLocalNodes = useMemo(() => {
const keywordFiltered = filterNodesByKeyword(
localNodes,
localSearchKeyword,
);
const filteredLocalNodes = useMemo(
() => {
const keywordFiltered = filterNodesByKeyword(localNodes, localSearchKeyword);
if (nodeFilterMode === "all") {
return keywordFiltered;
}
return keywordFiltered.filter((node) => {
const expiryMeta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
switch (nodeFilterMode) {
case "expiringSoon":
return expiryMeta.state === "expiringSoon";
case "expired":
return expiryMeta.state === "expired";
case "withExpiry":
return getNodeReminderEnabled(node);
default:
return true;
if (nodeFilterMode === "all") {
return keywordFiltered;
}
});
}, [filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode]);
return keywordFiltered.filter((node) => {
const expiryMeta = getNodeExpiryMeta(
node.expiryTime,
node.renewalCycle,
);
switch (nodeFilterMode) {
case "expiringSoon":
return expiryMeta.state === "expiringSoon";
case "expired":
return expiryMeta.state === "expired";
case "withExpiry":
return getNodeReminderEnabled(node);
default:
return true;
}
});
},
[filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode],
);
const filteredRemoteNodes = useMemo(
() => filterNodesByKeyword(remoteNodes, remoteSearchKeyword),
@@ -1486,8 +1472,7 @@ export default function NodePage() {
const canUseExpiryFilter = activeTab === "local";
const hasKeywordSearch = currentSearchKeyword.trim().length > 0;
const hasActiveFilters = nodeFilterMode !== "all";
const isDisplayFiltered =
hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters);
const isDisplayFiltered = hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters);
const sortableNodeIds = useMemo(
() => displayNodes.map((n) => n.id),
@@ -1506,7 +1491,7 @@ 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>
@@ -1518,7 +1503,7 @@ 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>
@@ -1540,7 +1525,14 @@ 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>
@@ -1600,6 +1592,7 @@ export default function NodePage() {
<Button
isIconOnly
aria-label="筛选条件"
isDisabled={!canUseExpiryFilter}
className={
canUseExpiryFilter && nodeFilterMode !== "all"
? "bg-primary/20 text-primary relative"
@@ -1610,11 +1603,8 @@ export default function NodePage() {
? "primary"
: "default"
}
isDisabled={!canUseExpiryFilter}
size="sm"
title={
canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"
}
title={canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"}
variant="flat"
onPress={() => setIsFilterModalOpen(true)}
>
@@ -1704,9 +1694,7 @@ export default function NodePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{displayNodes.map((node) => {
const isRemoteNode = node.isRemote === 1;
const remoteUsage = isRemoteNode
? remoteUsageMap[node.id]
: null;
const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null;
const expiryMeta = getNodeExpiryMeta(
node.expiryTime,
node.renewalCycle,
@@ -1716,10 +1704,7 @@ export default function NodePage() {
);
const hasRemark = Boolean(node.remark?.trim());
const hasExpiryInfo = Boolean(
node.expiryTime &&
node.expiryTime > 0 &&
node.renewalCycle &&
!node.expiryReminderDismissed,
node.expiryTime && node.expiryTime > 0 && node.renewalCycle,
);
const hasInfoTrigger = hasRemark || hasExpiryInfo;
const infoCount = Number(hasExpiryInfo) + Number(hasRemark);
@@ -1727,30 +1712,16 @@ export default function NodePage() {
return (
<SortableItem key={node.id} id={node.id}>
{(listeners) => (
{(listeners, attributes) => (
<Card
data-node-card="true"
key={node.id}
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${expiryMeta.accentClassName}`}
data-node-card="true"
>
<CardHeader className="pb-3 md:pb-3">
<div className="flex justify-between items-start w-full gap-3">
<div className="flex items-start gap-2 flex-1 min-w-0">
<div
className="cursor-grab active:cursor-grabbing p-2 -ml-2 -mt-1 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100 flex-shrink-0"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
{selectMode && (
<Checkbox
className="mt-0.5"
@@ -1770,108 +1741,10 @@ export default function NodePage() {
</div>
</div>
</div>
<div className="ml-2 flex-shrink-0 self-start">
{hasInfoTrigger && (
<div className="group/info relative">
<button
aria-label={`查看节点信息,共 ${infoCount} 项`}
className="relative flex h-7 w-7 items-center justify-center rounded-full border border-divider/80 bg-background/95 text-default-500 shadow-sm transition hover:border-default-300 hover:text-foreground focus-visible:border-default-300 focus-visible:text-foreground focus-visible:outline-none"
type="button"
onFocus={(event) =>
updateInfoPopoverPlacement(
node.id,
event.currentTarget,
)
}
onMouseEnter={(event) =>
updateInfoPopoverPlacement(
node.id,
event.currentTarget,
)
}
>
<svg
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
/>
</svg>
{hasRemark && (
<span className="absolute -right-1 -top-1 flex h-2.5 w-2.5 rounded-full border border-background bg-default-300 shadow-sm dark:bg-default-500" />
)}
</button>
<div
className={`pointer-events-none invisible absolute z-[60] w-72 max-w-[min(18rem,calc(100vw-4rem))] rounded-xl border border-divider/80 bg-background/98 p-3 opacity-0 shadow-xl backdrop-blur transition-all duration-150 group-hover/info:visible group-hover/info:pointer-events-auto group-hover/info:opacity-100 group-focus-within/info:visible group-focus-within/info:pointer-events-auto group-focus-within/info:opacity-100 ${
infoPlacement === "bottom"
? "right-0 top-[calc(100%+0.75rem)] translate-y-1 group-hover/info:translate-y-0 group-focus-within/info:translate-y-0"
: "right-[calc(100%+0.75rem)] top-1/2 -translate-y-1/2 translate-x-1 group-hover/info:translate-x-0 group-focus-within/info:translate-x-0"
}`}
>
<div className="space-y-3">
{hasExpiryInfo && (
<div className="space-y-2">
<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
className="text-[10px] h-5 px-1 flex-shrink-0"
color={expiryMeta.tone}
size="sm"
title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}
variant="flat"
>
{expiryMeta.label}
</Chip>
</div>
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2 text-xs leading-5 text-default-700">
{formatNodeRenewalTime(
expiryMeta.nextDueTime,
)}
</div>
</div>
)}
{hasRemark && (
<div className="space-y-2">
<div className="text-[11px] font-medium text-default-500">
备注
</div>
<div
className="max-h-32 overflow-y-auto rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2 text-xs leading-5 text-default-700 break-all [scrollbar-width:thin]"
title={node.remark?.trim()}
>
{node.remark?.trim()}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
<div className="ml-2 flex-shrink-0 self-start flex items-center gap-1.5">
<div className="cursor-grab active:cursor-grabbing p-1 ml-1 shrink-0 text-default-400 hover:text-default-600 transition-colors touch-manipulation flex-shrink-0" {...attributes} {...listeners} style={{ touchAction: "none" }} title="拖拽排序"><svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" /></svg></div>
</div>
</div>
</CardHeader>
@@ -1963,47 +1836,29 @@ export default function NodePage() {
<>
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5 space-y-2">
<div className="flex justify-between gap-2">
<span className="text-default-500">
远程地址
</span>
<span className="text-default-500">远程地址</span>
<span
className="font-mono text-right truncate"
title={
remoteUsage.remoteUrl ||
node.remoteUrl ||
"-"
}
title={remoteUsage.remoteUrl || node.remoteUrl || "-"}
>
{remoteUsage.remoteUrl ||
node.remoteUrl ||
"-"}
{remoteUsage.remoteUrl || node.remoteUrl || "-"}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-default-500">
共享ID
</span>
<span className="font-mono">
#{remoteUsage.shareId}
</span>
<span className="text-default-500">共享ID</span>
<span className="font-mono">#{remoteUsage.shareId}</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-default-500">
流量
</span>
<span className="text-default-500">流量</span>
<span className="font-mono">
{formatFlow(remoteUsage.currentFlow)}
</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-default-500">
带宽上限
</span>
<span className="text-default-500">带宽上限</span>
<span className="font-mono">
{remoteUsage.maxBandwidth > 0
? formatSpeed(
remoteUsage.maxBandwidth,
)
? formatSpeed(remoteUsage.maxBandwidth)
: "不限"}
</span>
</div>
@@ -2011,12 +1866,9 @@ export default function NodePage() {
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5">
<div className="flex items-center justify-between mb-2">
<span className="text-default-500">
占用端口
</span>
<span className="text-default-500">占用端口</span>
<span className="font-mono text-default-700 dark:text-default-300">
{remoteUsage.usedPorts.length}/
{Math.max(
{remoteUsage.usedPorts.length}/{Math.max(
remoteUsage.portRangeEnd -
remoteUsage.portRangeStart +
1,
@@ -2030,7 +1882,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"
>
@@ -2039,18 +1891,14 @@ export default function NodePage() {
))}
</div>
) : (
<div className="text-default-400">
暂无占用端口
</div>
<div className="text-default-400">暂无占用端口</div>
)}
</div>
</div>
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5">
<div className="flex items-center justify-between mb-2">
<span className="text-default-500">
绑定明细
</span>
<span className="text-default-500">绑定明细</span>
<span className="font-mono text-default-700 dark:text-default-300">
{remoteUsage.activeBindingNum}
</span>
@@ -2074,22 +1922,13 @@ export default function NodePage() {
</span>
</div>
<div className="mt-1 text-[11px] text-default-500 flex items-center justify-between gap-2">
<span>
{formatChainType(
binding.chainType,
binding.hopInx,
)}
</span>
<span className="font-mono">
端口 {binding.allocatedPort}
</span>
<span>{formatChainType(binding.chainType, binding.hopInx)}</span>
<span className="font-mono">端口 {binding.allocatedPort}</span>
</div>
</div>
))
) : (
<div className="text-default-400">
暂无绑定明细
</div>
<div className="text-default-400">暂无绑定明细</div>
)}
</div>
</div>
@@ -2227,6 +2066,38 @@ export default function NodePage() {
</>
)}
{hasInfoTrigger && (
<div className="mt-2 mb-3 space-y-1.5 rounded-lg border border-divider/50 bg-default-50/50 p-2.5">
{hasExpiryInfo && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] font-medium text-default-500 flex-shrink-0">
提醒:
</span>
<Chip
className="text-[10px] h-5 px-1 max-w-full flex-shrink-0"
color={expiryMeta.tone}
size="sm"
variant="flat"
>
<span className="truncate max-w-[120px] inline-block align-bottom">{expiryMeta.label}</span>
</Chip>
<span className="text-[11px] text-default-600 truncate flex-1 min-w-0">
{formatNodeRenewalTime(expiryMeta.nextDueTime).split(' ')[0]}
</span>
</div>
)}
{hasRemark && (
<div className="flex items-start gap-1.5">
<span className="text-[11px] font-medium text-default-500 flex-shrink-0">
备注:{node.remark?.trim()}
</span>
</div>
)}
</div>
)}
<div className="mt-auto space-y-3">
{/* 操作按钮 */}
<div className="space-y-1.5">
@@ -2310,7 +2181,7 @@ export default function NodePage() {
)}
{/* 新增/编辑节点对话框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={dialogVisible}
placement="center"
@@ -2376,37 +2247,26 @@ export default function NodePage() {
</Select>
</div>
<DatePicker
showMonthAndYearPickers
<Input
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
errorMessage={errors.expiryTime}
isInvalid={!!errors.expiryTime}
label="续费基准时间"
type="date" max="9999-12-31"
value={
form.expiryTime > 0
? (parseDate(
new Date(form.expiryTime).toISOString().split("T")[0],
) as any)
: null
? new Date(form.expiryTime).toISOString().slice(0, 10)
: ""
}
variant="bordered"
onChange={(e) =>
setForm((prev) => ({
...prev,
expiryTime: e.target.value
? new Date(e.target.value).getTime()
: 0,
}))
}
onChange={(date) => {
if (date) {
const jsDate = new Date(
date.year,
date.month - 1,
date.day,
);
setForm((prev) => ({
...prev,
expiryTime: jsDate.getTime(),
}));
} else {
setForm((prev) => ({
...prev,
expiryTime: 0,
}));
}
}}
/>
<Alert
@@ -2416,11 +2276,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) =>
@@ -2430,11 +2290,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) =>
@@ -2443,10 +2303,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"
@@ -2460,11 +2320,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) =>
@@ -2717,7 +2577,7 @@ export default function NodePage() {
<Alert
className="mt-4"
color="primary"
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
description="节点ip地址是你要添加的入口/出口的ip地址,不是面板的ip地址。"
variant="flat"
/>
</div>
@@ -2737,8 +2597,45 @@ export default function NodePage() {
</ModalContent>
</Modal>
{/* 删除确认模态框 */}
{/* 回退确认模态框 */}
<Modal
classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
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 classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={deleteModalOpen}
placement="center"
@@ -2778,7 +2675,7 @@ export default function NodePage() {
</ModalContent>
</Modal>
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={installSelectorOpen}
placement="center"
@@ -2828,7 +2725,7 @@ export default function NodePage() {
</Modal>
{/* 安装命令模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={installCommandModal}
placement="center"
@@ -2882,7 +2779,7 @@ export default function NodePage() {
</Modal>
{/* 版本选择升级模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={upgradeModalOpen}
placement="center"
@@ -2946,7 +2843,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"
@@ -2985,7 +2882,7 @@ export default function NodePage() {
</Modal>
{/* 批量删除确认模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={batchDeleteModalOpen}
placement="center"
@@ -3025,7 +2922,7 @@ export default function NodePage() {
</ModalContent>
</Modal>
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
isOpen={isFilterModalOpen}
placement="center"
size="md"
+3 -3
View File
@@ -677,7 +677,7 @@ export default function PanelSharingPage() {
</Tabs>
{/* Create Share Modal */}
<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)}
@@ -784,7 +784,7 @@ export default function PanelSharingPage() {
</Modal>
{/* Edit Share Modal */}
<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 +883,7 @@ 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>
+1 -1
View File
@@ -325,7 +325,7 @@ export default function ProfilePage() {
</div>
{/* 修改密码弹窗 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={isOpen}
placement="center"
+14 -106
View File
@@ -1,5 +1,3 @@
import type { BatchOperationFailure } from "@/api/types";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import toast from "react-hot-toast";
import {
@@ -22,7 +20,6 @@ import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
@@ -66,11 +63,7 @@ import {
} from "@/pages/tunnel/form";
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
import {
buildBatchFailureMessage,
extractBatchFailures,
extractApiErrorMessage,
} from "@/api/error-message";
import { extractApiErrorMessage } from "@/api/error-message";
interface ChainTunnel {
nodeId: number;
@@ -129,20 +122,6 @@ interface BatchProgressState {
percent: number;
}
interface BatchResultModalState {
failures: BatchOperationFailure[];
open: boolean;
summary: string;
title: string;
}
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
failures: [],
open: false,
summary: "",
title: "",
};
const TUNNEL_ORDER_KEY = "tunnel-order";
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
@@ -247,8 +226,6 @@ 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,
@@ -381,7 +358,7 @@ export default function TunnelPage() {
});
setErrors({});
setModalOpen(true);
};
};
// 删除隧道
const handleDelete = (tunnel: Tunnel) => {
@@ -865,18 +842,6 @@ export default function TunnelPage() {
setSelectedIds(new Set());
};
const openBatchResultModal = useCallback(
(title: string, summary: string, failures: BatchOperationFailure[]) => {
setBatchResultModal({
failures,
open: true,
summary,
title,
});
},
[],
);
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return;
setBatchLoading(true);
@@ -909,19 +874,9 @@ export default function TunnelPage() {
return next;
});
} else {
const failures = extractBatchFailures(result);
if (failures.length > 0) {
openBatchResultModal(
"批量删除结果",
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failures,
);
} else {
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
}
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
setBatchProgress({
active: true,
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
@@ -960,22 +915,9 @@ export default function TunnelPage() {
if (result.failCount === 0) {
toast.success(`成功重新下发 ${result.successCount} 项`);
} else {
const failures = extractBatchFailures(result);
if (failures.length > 0) {
openBatchResultModal(
"批量下发结果",
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
failures,
);
} else {
toast.error(
buildBatchFailureMessage(
result,
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
),
);
}
toast.error(
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
);
}
setSelectedIds(new Set());
setSelectMode(false);
@@ -1262,21 +1204,7 @@ export default function TunnelPage() {
</Chip>
</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"
{...listeners}
style={{ touchAction: "none" }}
title="拖拽排序"
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
</svg>
</div>
<div 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="拖拽排序"><svg aria-hidden="true" className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" /></svg></div>
</div>
</CardHeader>
@@ -1415,6 +1343,7 @@ export default function TunnelPage() {
</div>
)}
</div>
</div>
<div className="flex gap-1.5 mt-3">
@@ -1513,7 +1442,7 @@ export default function TunnelPage() {
)}
{/* 新增/编辑模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={modalOpen}
placement="center"
@@ -2447,7 +2376,7 @@ export default function TunnelPage() {
</Modal>
{/* 删除确认模态框 */}
<Modal
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
backdrop="blur"
isOpen={deleteModalOpen}
placement="center"
@@ -2490,12 +2419,7 @@ export default function TunnelPage() {
{/* 诊断结果模态框 */}
<Modal
backdrop="blur"
classNames={{
base: "rounded-2xl",
header: "rounded-t-2xl",
body: "rounded-none",
footer: "rounded-b-2xl",
}}
classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
isOpen={diagnosisModalOpen}
placement="center"
scrollBehavior="inside"
@@ -3076,7 +3000,7 @@ export default function TunnelPage() {
</ModalContent>
</Modal>
<Modal
<Modal backdrop="blur" classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden" }}
isOpen={batchDeleteModalOpen}
onOpenChange={setBatchDeleteModalOpen}
>
@@ -3106,22 +3030,6 @@ export default function TunnelPage() {
)}
</ModalContent>
</Modal>
<BatchActionResultModal
failures={batchResultModal.failures}
isOpen={batchResultModal.open}
summary={batchResultModal.summary}
title={batchResultModal.title}
onOpenChange={(open) => {
if (open) {
setBatchResultModal((prev) => ({ ...prev, open: true }));
return;
}
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
}}
/>
</AnimatedPage>
);
}
+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"
@@ -145,7 +145,6 @@ export function ModalContent({
return (
<BaseDialogContent
{...props}
className={cn(
mapSize(resolvedSize),
context?.classNames?.base,
@@ -158,12 +157,7 @@ export function ModalContent({
className,
)}
showCloseButton={false}
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
onCloseAutoFocus={(e) => {
e.preventDefault();
}}
{...props}
>
{renderedChildren}
</BaseDialogContent>