fix(node): compact card metadata for mobile (#300)

## Summary
- move secondary node metadata into a compact info dropdown so cards fit
better on smaller screens
- replace the full connection status chip in the header with a status
dot while keeping detailed status inside the info panel
- keep remarks and renewal metadata accessible without permanently
increasing card height

## Testing
- not run
This commit is contained in:
sagit
2026-03-10 20:13:11 +08:00
committed by GitHub
2 changed files with 159 additions and 52 deletions
+18
View File
@@ -0,0 +1,18 @@
node_modules
dist
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.DS_Store
.vscode
.idea
.env.local
.env.*.local
coverage
*.tsbuildinfo
+141 -52
View File
@@ -278,7 +278,7 @@ const SortableItem = ({
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
{...attributes} {...attributes}
className="overflow-hidden h-full" className="overflow-visible h-full"
{...listeners} {...listeners}
> >
{children(listeners)} {children(listeners)}
@@ -377,6 +377,40 @@ export default function NodePage() {
const [upgradeProgress, setUpgradeProgress] = useState< const [upgradeProgress, setUpgradeProgress] = useState<
Record<number, { stage: string; percent: number; message: string }> 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) => { const handleNodeOffline = useCallback((nodeId: number) => {
setNodeList((prev) => setNodeList((prev) =>
@@ -1650,13 +1684,24 @@ export default function NodePage() {
node.expiryTime, node.expiryTime,
node.renewalCycle, node.renewalCycle,
); );
const connectionStatusMeta = getConnectionStatusMeta(
node.connectionStatus,
);
const hasRemark = Boolean(node.remark?.trim());
const hasExpiryInfo = Boolean(
node.expiryTime && node.expiryTime > 0 && node.renewalCycle,
);
const hasInfoTrigger = hasRemark || hasExpiryInfo;
const infoCount = Number(hasExpiryInfo) + Number(hasRemark);
const infoPlacement = infoPopoverPlacement[node.id] ?? "left";
return ( return (
<SortableItem key={node.id} id={node.id}> <SortableItem key={node.id} id={node.id}>
{(listeners) => ( {(listeners) => (
<Card <Card
data-node-card="true"
key={node.id} key={node.id}
className={`group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full flex flex-col ${expiryMeta.accentClassName}`} className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${expiryMeta.accentClassName}`}
> >
<CardHeader className="pb-3 md:pb-3"> <CardHeader className="pb-3 md:pb-3">
<div className="flex justify-between items-start w-full gap-3"> <div className="flex justify-between items-start w-full gap-3">
@@ -1683,51 +1728,103 @@ export default function NodePage() {
onValueChange={() => toggleSelect(node.id)} onValueChange={() => toggleSelect(node.id)}
/> />
)} )}
<h3 className="pt-0.5 font-semibold text-foreground truncate text-sm leading-5"> <div className="min-w-0 flex-1 pt-0.5">
{node.name} <div className="flex items-center gap-2 min-w-0">
</h3> <h3 className="font-semibold text-foreground truncate text-sm leading-5">
{node.name}
</h3>
<span
className={`h-2.5 w-2.5 rounded-full flex-shrink-0 ${connectionStatusMeta.color === "success" ? "bg-emerald-500" : "bg-rose-500"}`}
title={connectionStatusMeta.text}
/>
</div>
</div>
</div> </div>
<div className="ml-2 flex max-w-[58%] flex-wrap items-center justify-end gap-1.5 self-start"> <div className="ml-2 flex-shrink-0 self-start">
{isRemoteNode && ( {hasInfoTrigger && (
<Chip <div className="group/info relative">
className="text-[10px] h-5 px-1 flex-shrink-0" <button
color="secondary" aria-label={`查看节点信息,共 ${infoCount} 项`}
size="sm" 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"
variant="flat" onFocus={(event) =>
updateInfoPopoverPlacement(
node.id,
event.currentTarget,
)
}
onMouseEnter={(event) =>
updateInfoPopoverPlacement(
node.id,
event.currentTarget,
)
}
type="button"
>
<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-30 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">
</Chip> {hasExpiryInfo && (
)} <div className="space-y-2">
{(() => { <div className="text-[11px] font-medium text-default-500">
const connectionStatusMeta = 到期提醒
getConnectionStatusMeta( </div>
node.connectionStatus, <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>
)}
return ( {hasRemark && (
<Chip <div className="space-y-2">
className="text-[10px] h-5 px-1" <div className="text-[11px] font-medium text-default-500">
color={connectionStatusMeta.color} 备注
size="sm" </div>
variant="flat" <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]"
{connectionStatusMeta.text} title={node.remark?.trim()}
</Chip> >
); {node.remark?.trim()}
})()} </div>
{node.expiryTime && </div>
node.expiryTime > 0 && )}
node.renewalCycle && ( </div>
<Chip </div>
className="text-[10px] h-5 px-1 flex-shrink-0" </div>
color={expiryMeta.tone} )}
size="sm"
title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}
variant="flat"
>
{expiryMeta.label}
</Chip>
)}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -2051,14 +2148,6 @@ export default function NodePage() {
)} )}
<div className="mt-auto space-y-3"> <div className="mt-auto space-y-3">
{node.remark?.trim() && (
<div className="rounded-md border border-divider/80 bg-default-50/80 px-2.5 py-2.5 text-xs leading-5 text-default-700 break-all">
<div title={node.remark.trim()}>
{node.remark.trim()}
</div>
</div>
)}
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
{!isRemoteNode && ( {!isRemoteNode && (