Revert user page search bar to inline implementation for consistency

This commit is contained in:
sagitchu
2026-03-15 22:28:44 +08:00
parent 5f78dd66fc
commit 8f336377f6
+270 -154
View File
@@ -1,4 +1,3 @@
import { SearchBar } from "@/components/search-bar";
import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { parseDate } from "@internationalized/date"; import { parseDate } from "@internationalized/date";
@@ -60,12 +59,7 @@ import {
getUserGroupList, getUserGroupList,
getUserGroups, getUserGroups,
} from "@/api"; } from "@/api";
import { import { EditIcon, DeleteIcon, SettingsIcon, SearchIcon } from "@/components/icons";
SearchIcon,
EditIcon,
DeleteIcon,
SettingsIcon,
} from "@/components/icons";
import { PageLoadingState } from "@/components/page-state"; import { PageLoadingState } from "@/components/page-state";
import { useLocalStorageState } from "@/hooks/use-local-storage-state"; import { useLocalStorageState } from "@/hooks/use-local-storage-state";
import { removeItemsById, replaceItemById } from "@/utils/list-state"; import { removeItemsById, replaceItemById } from "@/utils/list-state";
@@ -784,6 +778,7 @@ export default function UserPage() {
const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => { const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => {
const userId = userForm.id; const userId = userForm.id;
if (!userId) { if (!userId) {
return; return;
} }
@@ -888,34 +883,67 @@ export default function UserPage() {
<AnimatedPage className="px-3 lg:px-6 py-8"> <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 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 shrink-0 ${isSearchVisible ? "min-w-[200px]" : "min-w-0"}`}> <div className="flex-1 max-w-sm flex items-center gap-2">
<SearchBar {!isSearchVisible ? (
isVisible={isSearchVisible} <Button
placeholder="搜索用户名" isIconOnly
value={searchKeyword} aria-label="搜索"
onChange={setSearchKeyword} className="text-default-600"
onClose={() => { color="default"
setIsSearchVisible(false); size="sm"
setSearchKeyword(""); variant="flat"
}} onPress={() => setIsSearchVisible(true)}
onOpen={() => { >
setIsSearchVisible(true); <SearchIcon className="w-4 h-4" />
setTimeout(() => { </Button>
const searchInput = document.querySelector('input[placeholder*="搜索"]'); ) : (
if (searchInput) (searchInput as HTMLElement).focus(); <div className="flex w-full items-center gap-2 animate-appearance-in">
}, 150); <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> </div>
<Button
className="w-fit self-end sm:self-auto" <Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
color="primary" 新增
size="sm" </Button>
variant="flat"
onPress={handleAdd}
>
新增
</Button>
</div> </div>
{/* 用户列表 */} {/* 用户列表 */}
@@ -1126,8 +1154,11 @@ export default function UserPage() {
)} )}
{/* 用户表单模态框 */} {/* 用户表单模态框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isUserModalOpen} isOpen={isUserModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -1281,8 +1312,8 @@ export default function UserPage() {
<div className="rounded-lg bg-background p-3"> <div className="rounded-lg bg-background p-3">
<div className="text-xs text-default-500">每月用量</div> <div className="text-xs text-default-500">每月用量</div>
<div className="mt-1 text-sm font-semibold text-foreground"> <div className="mt-1 text-sm font-semibold text-foreground">
{formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))} /{" "} {formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))}{" "}
{formatQuotaLimit(editingUser.monthlyQuotaGB)} / {formatQuotaLimit(editingUser.monthlyQuotaGB)}
</div> </div>
</div> </div>
</div> </div>
@@ -1386,37 +1417,61 @@ export default function UserPage() {
<h3 className="text-lg font-semibold mb-4">分配新权限</h3> <h3 className="text-lg font-semibold mb-4">分配新权限</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-2 relative"> <div className="flex flex-col gap-2 relative">
<p className="text-base text-default-700 ml-1 font-medium">隧道列表</p> <p className="text-base text-default-700 ml-1 font-medium">
隧道列表
</p>
{/* 顶部触发框 */} {/* 顶部触发框 */}
<div <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"}`} 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)} onClick={() =>
setIsTunnelListExpanded(!isTunnelListExpanded)
}
> >
<span className={`text-sm truncate ${batchTunnelSelections.size > 0 ? "text-primary-500 font-bold" : "text-default-400"}`}> <span
{batchTunnelSelections.size > 0 className={`text-sm truncate ${batchTunnelSelections.size > 0 ? "text-primary-500 font-bold" : "text-default-400"}`}
? `已选 ${batchTunnelSelections.size} 项:` + Array.from(batchTunnelSelections.keys()).map(id => tunnels.find(t => t.id === id)?.name).join('、') >
{batchTunnelSelections.size > 0
? `已选 ${batchTunnelSelections.size} 项:` +
Array.from(batchTunnelSelections.keys())
.map(
(id) => tunnels.find((t) => t.id === id)?.name,
)
.join("、")
: "请选择隧道(勾选后配置限速)"} : "请选择隧道(勾选后配置限速)"}
</span> </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> <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>
{/* 列表悬浮层 */} {/* 列表悬浮层 */}
{isTunnelListExpanded && ( {isTunnelListExpanded && (
<div <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]" 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()} onClick={(e) => e.stopPropagation()}
> >
<div className="max-h-[350px] overflow-y-auto p-2 custom-scrollbar"> <div className="max-h-[350px] overflow-y-auto p-2 custom-scrollbar">
{tunnels.map((tunnel) => { {tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id); const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(tunnel.id); const isSelected = batchTunnelSelections.has(
const tunnelSpeedLimits = getSpeedLimitsForTunnel(tunnel.id); tunnel.id,
const currentSpeedId = batchTunnelSelections.get(tunnel.id); );
const tunnelSpeedLimits = getSpeedLimitsForTunnel(
tunnel.id,
);
const currentSpeedId = batchTunnelSelections.get(
tunnel.id,
);
return ( return (
<div <div
key={tunnel.id} 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"}`} 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) => { onClick={(e) => {
@@ -1426,47 +1481,80 @@ export default function UserPage() {
}} }}
> >
<div className="flex items-center gap-4 min-w-0 flex-1"> <div className="flex items-center gap-4 min-w-0 flex-1">
<Checkbox <Checkbox
color="primary" color="primary"
isSelected={isSelected} isSelected={isSelected}
isDisabled={isAssigned} isDisabled={isAssigned}
// 关键:禁用 Checkbox 自身的点击,防止它跟父容器打架 // 关键:禁用 Checkbox 自身的点击,防止它跟父容器打架
className="pointer-events-none" className="pointer-events-none"
/> />
<span className={`text-sm font-medium truncate ${isSelected ? "text-primary-700" : ""}`}>{tunnel.name}</span> <span
className={`text-sm font-medium truncate ${isSelected ? "text-primary-700" : ""}`}
>
{tunnel.name}
</span>
</div> </div>
{/* 右侧限速选择:复刻 image_24879b */} {/* 右侧限速选择:复刻 image_24879b */}
{isSelected && !isAssigned && ( {isSelected && !isAssigned && (
<div className="flex items-center ml-2" onClick={(e) => e.stopPropagation()}> <div
className="flex items-center ml-2"
onClick={(e) => e.stopPropagation()}
>
<Select <Select
aria-label="限速选择" aria-label="限速选择"
className="w-32" className="w-32"
placeholder="不限速"
selectedKeys={
currentSpeedId
? [currentSpeedId.toString()]
: []
}
size="sm" size="sm"
variant="flat" variant="flat"
placeholder="不限速"
selectedKeys={currentSpeedId ? [currentSpeedId.toString()] : []}
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0]; const selectedKey = Array.from(keys)[0];
updateTunnelSpeedLimit(tunnel.id, selectedKey ? Number(selectedKey) : null);
updateTunnelSpeedLimit(
tunnel.id,
selectedKey
? Number(selectedKey)
: null,
);
}} }}
> >
{tunnelSpeedLimits.map(sl => <SelectItem key={sl.id.toString()}>{sl.name}</SelectItem>)} {tunnelSpeedLimits.map((sl) => (
<SelectItem key={sl.id.toString()}>
{sl.name}
</SelectItem>
))}
</Select> </Select>
</div> </div>
)} )}
{isAssigned && <span className="text-[10px] text-default-400 italic pr-2">已分配</span>} {isAssigned && (
<span className="text-[10px] text-default-400 italic pr-2">
已分配
</span>
)}
</div> </div>
); );
})} })}
</div> </div>
<div className="bg-default-50/80 border-t p-2 flex justify-end"> <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> <Button
className="font-bold"
color="primary"
size="md"
variant="light"
onPress={() => setIsTunnelListExpanded(false)}
>
完成配置
</Button>
</div> </div>
</div> </div>
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
className="w-fit px-8" className="w-fit px-8"
color="primary" color="primary"
@@ -1488,104 +1576,120 @@ export default function UserPage() {
{/* 已有权限部分 */} {/* 已有权限部分 */}
<div> <div>
<h3 className="text-lg font-semibold mb-4">已有权限</h3> <h3 className="text-lg font-semibold mb-4">已有权限</h3>
<Table <Table
aria-label="用户隧道权限列表" aria-label="用户隧道权限列表"
// 1. 去掉 layout="fixed",改为在 classNames.table 里写 table-fixed // 1. 去掉 layout="fixed",改为在 classNames.table 里写 table-fixed
classNames={{ classNames={{
wrapper: "shadow-none p-0 min-w-[380px] overflow-x-auto", wrapper: "shadow-none p-0 min-w-[380px] overflow-x-auto",
th: "bg-default-50 text-default-600 font-semibold", th: "bg-default-50 text-default-600 font-semibold",
td: "py-4 border-b border-divider" td: "py-4 border-b border-divider",
}} }}
> >
<TableHeader> <TableHeader>
<TableColumn className="w-[35%]">隧道名称</TableColumn> <TableColumn className="w-[35%]">隧道名称</TableColumn>
<TableColumn className="w-[35%]">流量统计</TableColumn> <TableColumn className="w-[35%]">流量统计</TableColumn>
<TableColumn className="w-[30%] text-right">操作</TableColumn> <TableColumn className="w-[30%] text-right">
</TableHeader> 操作
<TableBody </TableColumn>
emptyContent="暂无隧道权限" </TableHeader>
isLoading={tunnelListLoading} <TableBody
items={userTunnels} emptyContent="暂无隧道权限"
loadingContent={<Spinner />} isLoading={tunnelListLoading}
> items={userTunnels}
{(userTunnel) => ( loadingContent={<Spinner />}
<TableRow key={userTunnel.id} className="hover:bg-default-50/50 transition-colors"> >
<TableCell> {(userTunnel) => (
<div className="flex items-center gap-2"> <TableRow
<span className="font-bold text-default-700 whitespace-nowrap"> key={userTunnel.id}
{userTunnel.tunnelName} className="hover:bg-default-50/50 transition-colors"
</span> >
</div> <TableCell>
</TableCell> <div className="flex items-center gap-2">
<span className="font-bold text-default-700 whitespace-nowrap">
{userTunnel.tunnelName}
</span>
</div>
</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1 whitespace-nowrap text-sm"> <div className="flex items-center gap-1 whitespace-nowrap text-sm">
<span className="text-danger font-mono font-bold"> <span className="text-danger font-mono font-bold">
{formatFlow(calculateTunnelUsedFlow(userTunnel))} {formatFlow(calculateTunnelUsedFlow(userTunnel))}
</span> </span>
<span className="text-default-300">/</span> <span className="text-default-300">/</span>
<span className="text-default-500 font-mono"> <span className="text-default-500 font-mono">
{formatFlow(userTunnel.flow, "gb")} {formatFlow(userTunnel.flow, "gb")}
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
{/* 5. justify-end 确保按钮群组整体靠右 */} {/* 5. justify-end 确保按钮群组整体靠右 */}
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button <Button
isIconOnly isIconOnly
size="sm" className="bg-blue-50 text-blue-600 hover:bg-blue-100 w-8 h-8 min-w-8"
variant="flat" size="sm"
className="bg-blue-50 text-blue-600 hover:bg-blue-100 w-8 h-8 min-w-8" variant="flat"
onPress={() => handleEditTunnel(userTunnel)} onPress={() => handleEditTunnel(userTunnel)}
> >
<EditIcon className="w-4 h-4" /> <EditIcon className="w-4 h-4" />
</Button> </Button>
<Button <Button
isIconOnly isIconOnly
size="sm" className="bg-orange-50 text-orange-600 hover:bg-orange-100 w-8 h-8 min-w-8"
variant="flat" size="sm"
className="bg-orange-50 text-orange-600 hover:bg-orange-100 w-8 h-8 min-w-8" variant="flat"
onPress={() => handleResetTunnelFlow(userTunnel)} onPress={() => handleResetTunnelFlow(userTunnel)}
> >
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <svg
<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" /> className="w-4 h-4"
</svg> fill="currentColor"
</Button> viewBox="0 0 20 20"
<Button >
isIconOnly <path
size="sm" clipRule="evenodd"
variant="flat" 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"
className="bg-danger-50 text-danger hover:bg-danger-100 w-8 h-8 min-w-8" fillRule="evenodd"
onPress={() => handleRemoveTunnel(userTunnel)} />
> </svg>
<DeleteIcon className="w-4 h-4" /> </Button>
</Button> <Button
</div> isIconOnly
</TableCell> className="bg-danger-50 text-danger hover:bg-danger-100 w-8 h-8 min-w-8"
</TableRow> size="sm"
)} variant="flat"
</TableBody> onPress={() => handleRemoveTunnel(userTunnel)}
</Table> >
<DeleteIcon className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div> </div>
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter className="justify-end"> <ModalFooter className="justify-end">
<Button <Button
onPress={onTunnelModalClose} color="primary"
color="primary" variant="flat" // 建议加个 variant 保持和你其他按钮风格一致
variant="flat" // 建议加个 variant 保持和你其他按钮风格一致 onPress={onTunnelModalClose}
> >
关闭 关闭
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
{/* 编辑隧道权限模态框 */} {/* 编辑隧道权限模态框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isDismissable={false} isDismissable={false}
isOpen={isEditTunnelModalOpen} isOpen={isEditTunnelModalOpen}
placement="center" placement="center"
@@ -1762,8 +1866,11 @@ export default function UserPage() {
</Modal> </Modal>
{/* 删除确认对话框 */} {/* 删除确认对话框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -1805,8 +1912,11 @@ export default function UserPage() {
</Modal> </Modal>
{/* 删除隧道权限确认对话框 */} {/* 删除隧道权限确认对话框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isDeleteTunnelModalOpen} isOpen={isDeleteTunnelModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -1850,8 +1960,11 @@ export default function UserPage() {
</Modal> </Modal>
{/* 重置流量确认对话框 */} {/* 重置流量确认对话框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isResetFlowModalOpen} isOpen={isResetFlowModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"
@@ -1939,8 +2052,11 @@ export default function UserPage() {
</Modal> </Modal>
{/* 重置隧道流量确认对话框 */} {/* 重置隧道流量确认对话框 */}
<Modal classNames={{ base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl" }} <Modal
backdrop="blur" backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl",
}}
isOpen={isResetTunnelFlowModalOpen} isOpen={isResetTunnelFlowModalOpen}
placement="center" placement="center"
scrollBehavior="outside" scrollBehavior="outside"