mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-30 16:26:36 +08:00
Restore removed features and keep UI improvements (#322)
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# 恢复 PR #322 移除的功能
|
||||
|
||||
**状态**: ✅ 已完成
|
||||
|
||||
## 背景
|
||||
|
||||
PR #322 (https://github.com/Sagit-chu/flvx/pull/322) 原本移除了三个功能,用户要求**加回**这些被移除的功能:
|
||||
1. 批量操作失败详情弹窗(`BatchOperationFailure` 类型及相关处理)
|
||||
2. 节点到期提醒关闭功能(`dismissNodeExpiryReminder` API)
|
||||
3. 更新通道选择功能(稳定版/开发版切换)
|
||||
|
||||
用户要求**保留**的改动:
|
||||
- 版本显示简化(移除 "v" 前缀和更新可用徽章)
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 检出 PR #322 到本地分支 `pr-322`
|
||||
- [x] 恢复 `api/types.ts` 中的 `expiryReminderDismissed` 字段
|
||||
- [x] 恢复 `api/types.ts` 中的 `BatchOperationFailure` 类型和 `failures` 字段
|
||||
- [x] 恢复 `api/error-message.ts` 中的批量操作失败处理函数
|
||||
- [x] 恢复 `api/index.ts` 中的 `dismissNodeExpiryReminder` API
|
||||
- [x] 恢复 `config.tsx` 中的更新通道选择功能
|
||||
- [x] 恢复 `use-dashboard-data.ts` 中的 `expiryReminderDismissed` 过滤逻辑
|
||||
- [x] 恢复 `batch-actions.ts` 中的 `BatchOperationFailure` 相关处理
|
||||
- [x] 恢复 `forward.tsx` 中的 `BatchActionResultModal` 使用
|
||||
- [x] 恢复 `tunnel.tsx` 中的 `BatchActionResultModal` 使用
|
||||
- [x] 提交并推送修改
|
||||
|
||||
## 修改的文件
|
||||
|
||||
- `vite-frontend/src/api/types.ts` - 添加 `expiryReminderDismissed` 和 `BatchOperationFailure`
|
||||
- `vite-frontend/src/api/error-message.ts` - 添加批量操作失败处理函数
|
||||
- `vite-frontend/src/api/index.ts` - 添加 `dismissNodeExpiryReminder` API
|
||||
- `vite-frontend/src/pages/config.tsx` - 添加更新通道选择功能
|
||||
- `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` - 恢复 `expiryReminderDismissed` 过滤逻辑
|
||||
- `vite-frontend/src/pages/forward/batch-actions.ts` - 恢复批量操作失败处理
|
||||
- `vite-frontend/src/pages/forward.tsx` - 恢复 `BatchActionResultModal` 组件使用
|
||||
- `vite-frontend/src/pages/tunnel.tsx` - 恢复 `BatchActionResultModal` 组件使用
|
||||
- `vite-frontend/src/pages/node.tsx` - 恢复 `expiryReminderDismissed` 功能和 "关闭提醒" 按钮
|
||||
|
||||
## 保留的 UI 改进
|
||||
|
||||
- Modal 样式优化(group.tsx, limit.tsx, panel-sharing.tsx)
|
||||
- 按钮文本简化
|
||||
- 用户页面隧道列表下拉展开
|
||||
|
||||
## forward.tsx 重构审查结果
|
||||
|
||||
PR #322 对 forward.tsx 进行了大规模重构(~2200 行 diff),经审查决定**保留**以下改动:
|
||||
|
||||
| 改动 | 说明 |
|
||||
|------|------|
|
||||
| DnD 碰撞检测 | `closestCenter` → `pointerWithin`,更适合嵌套拖拽 |
|
||||
| 高级筛选模态框 | 从 SearchBar 改为五合一筛选(名称/用户/隧道/端口/目标地址) |
|
||||
| 始终显示复选框 | 移除 selectMode 状态,用户无需切换模式即可选择 |
|
||||
| 组件位置移动 | Sortable 组件移到组件顶部,代码组织更好 |
|
||||
| UI 改进 | 表头全选、端口独立列、倍率显示优化、Modal 样式、"落地地址"文案 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `version-footer.tsx` 保持简化版本显示(不恢复)
|
||||
- `batch-action-result-modal.tsx` 组件文件未被 PR 删除,无需恢复(只需恢复 forward.tsx 中的使用)
|
||||
@@ -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",
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -576,6 +576,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"
|
||||
|
||||
@@ -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) => (
|
||||
<>
|
||||
|
||||
+956
-822
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
+201
-208
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -40,7 +39,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,
|
||||
@@ -252,7 +250,7 @@ const SortableItem = ({
|
||||
children,
|
||||
}: {
|
||||
id: number;
|
||||
children: (listeners: any) => any;
|
||||
children: (listeners: any, attributes?: any) => any;
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
@@ -277,14 +275,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>
|
||||
);
|
||||
};
|
||||
@@ -318,6 +310,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 +324,7 @@ export default function NodePage() {
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
port: "1000-65535",
|
||||
port: "10000-65535",
|
||||
tcpListenAddr: "[::]",
|
||||
udpListenAddr: "[::]",
|
||||
interfaceName: "",
|
||||
@@ -380,40 +374,6 @@ export default function NodePage() {
|
||||
const [upgradeProgress, setUpgradeProgress] = useState<
|
||||
Record<number, { stage: string; percent: number; message: string }>
|
||||
>({});
|
||||
const [infoPopoverPlacement, setInfoPopoverPlacement] = useState<
|
||||
Record<number, "left" | "bottom">
|
||||
>({});
|
||||
|
||||
const updateInfoPopoverPlacement = useCallback(
|
||||
(nodeId: number, triggerElement: HTMLElement | null) => {
|
||||
if (!triggerElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = triggerElement.getBoundingClientRect();
|
||||
const cardElement = triggerElement.closest("[data-node-card='true']");
|
||||
const cardRect =
|
||||
cardElement instanceof HTMLElement
|
||||
? cardElement.getBoundingClientRect()
|
||||
: null;
|
||||
const estimatedPanelWidth = 288;
|
||||
const containerPadding = 16;
|
||||
const availableLeftSpace = cardRect
|
||||
? rect.left - cardRect.left
|
||||
: rect.left;
|
||||
const nextPlacement: "left" | "bottom" =
|
||||
availableLeftSpace >= estimatedPanelWidth + containerPadding
|
||||
? "left"
|
||||
: "bottom";
|
||||
|
||||
setInfoPopoverPlacement((prev) =>
|
||||
prev[nodeId] === nextPlacement
|
||||
? prev
|
||||
: { ...prev, [nodeId]: nextPlacement },
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleNodeOffline = useCallback((nodeId: number) => {
|
||||
setNodeList((prev) =>
|
||||
@@ -865,7 +825,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 || "",
|
||||
@@ -1096,7 +1056,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)),
|
||||
);
|
||||
@@ -1116,6 +1085,7 @@ export default function NodePage() {
|
||||
n.id === node.id ? { ...n, rollbackLoading: false } : n,
|
||||
),
|
||||
);
|
||||
setNodeToRollback(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1199,7 +1169,7 @@ export default function NodePage() {
|
||||
serverHost: "",
|
||||
serverIpV4: "",
|
||||
serverIpV6: "",
|
||||
port: "1000-65535",
|
||||
port: "10000-65535",
|
||||
tcpListenAddr: "[::]",
|
||||
udpListenAddr: "[::]",
|
||||
interfaceName: "",
|
||||
@@ -1506,7 +1476,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>
|
||||
@@ -1518,7 +1492,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>
|
||||
@@ -1540,7 +1518,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>
|
||||
|
||||
@@ -1722,12 +1710,10 @@ export default function NodePage() {
|
||||
!node.expiryReminderDismissed,
|
||||
);
|
||||
const hasInfoTrigger = hasRemark || hasExpiryInfo;
|
||||
const infoCount = Number(hasExpiryInfo) + Number(hasRemark);
|
||||
const infoPlacement = infoPopoverPlacement[node.id] ?? "left";
|
||||
|
||||
return (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
{(listeners, attributes) => (
|
||||
<Card
|
||||
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}`}
|
||||
@@ -1736,21 +1722,6 @@ export default function NodePage() {
|
||||
<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,107 +1741,23 @@ 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 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>
|
||||
@@ -2030,7 +1917,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"
|
||||
>
|
||||
@@ -2227,6 +2114,60 @@ export default function NodePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasInfoTrigger && (
|
||||
<div className="mt-2 mb-3 space-y-2 p-2.5">
|
||||
{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 className="mt-auto space-y-3">
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
@@ -2312,6 +2253,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"
|
||||
@@ -2376,37 +2320,27 @@ export default function NodePage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
showMonthAndYearPickers
|
||||
<Input
|
||||
description="填写最近一次续费时间或周期起始时间,系统会按月/季/年自动推算下次续费"
|
||||
errorMessage={errors.expiryTime}
|
||||
isInvalid={!!errors.expiryTime}
|
||||
label="续费基准时间"
|
||||
max="9999-12-31"
|
||||
type="date"
|
||||
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 +2350,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 +2364,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 +2377,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 +2394,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 +2651,7 @@ export default function NodePage() {
|
||||
<Alert
|
||||
className="mt-4"
|
||||
color="primary"
|
||||
description="服务器ip是你要添加的服务器的ip地址,不是面板的ip地址。"
|
||||
description="节点ip地址是你要添加的入口/出口的ip地址,不是面板的ip地址。"
|
||||
variant="flat"
|
||||
/>
|
||||
</div>
|
||||
@@ -2737,9 +2671,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>"{nodeToRollback?.name}"</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"
|
||||
@@ -2780,6 +2758,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"
|
||||
@@ -2830,6 +2811,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"
|
||||
@@ -2884,6 +2868,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"
|
||||
@@ -2946,7 +2933,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"
|
||||
@@ -2987,6 +2974,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"
|
||||
@@ -3026,6 +3016,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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
-241
@@ -60,10 +60,10 @@ import {
|
||||
getUserGroups,
|
||||
} 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";
|
||||
@@ -204,7 +204,7 @@ export default function UserPage() {
|
||||
user: "",
|
||||
pwd: "",
|
||||
status: 1,
|
||||
flow: 100,
|
||||
flow: 1000,
|
||||
dailyQuotaGB: 0,
|
||||
monthlyQuotaGB: 0,
|
||||
num: 10,
|
||||
@@ -234,6 +234,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 +470,7 @@ export default function UserPage() {
|
||||
user: "",
|
||||
pwd: "",
|
||||
status: 1,
|
||||
flow: 100,
|
||||
flow: 1000,
|
||||
dailyQuotaGB: 0,
|
||||
monthlyQuotaGB: 0,
|
||||
num: 10,
|
||||
@@ -1049,26 +1050,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">
|
||||
@@ -1180,6 +1161,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"
|
||||
@@ -1224,38 +1208,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="规则数量"
|
||||
@@ -1452,7 +1404,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 +1421,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}
|
||||
@@ -1620,20 +1583,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="暂无隧道权限"
|
||||
@@ -1642,61 +1604,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)}
|
||||
@@ -1705,15 +1642,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"
|
||||
@@ -1727,8 +1661,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)}
|
||||
@@ -1744,8 +1677,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>
|
||||
@@ -1753,6 +1692,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"
|
||||
@@ -1931,6 +1873,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"
|
||||
@@ -1974,6 +1919,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"
|
||||
@@ -2019,6 +1967,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"
|
||||
@@ -2108,6 +2059,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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user