增加转发分类显示

This commit is contained in:
qaq
2025-07-25 12:49:50 +08:00
parent 31e9ee621f
commit decda2c892
4 changed files with 259 additions and 149 deletions
+1 -1
View File
@@ -1 +1 @@
VITE_API_BASE=https://1f.ink
VITE_API_BASE=http://127.0.0.1:6365
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@heroui/accordion": "^2.2.20",
"@heroui/alert": "^2.2.22",
"@heroui/avatar": "^2.2.18",
"@heroui/button": "2.2.22",
+4 -3
View File
@@ -4,6 +4,7 @@ export type SiteConfig = typeof siteConfig;
// 缓存相关常量
const CACHE_PREFIX = 'vite_config_';
const VERSION = "1.1.0";
// 缓存策略:
// - 长期有效,不设置过期时间
@@ -14,7 +15,7 @@ const getInitialConfig = () => {
if (typeof window === 'undefined') {
return {
name: "哆啦A梦",
version: "1.0.9",
version: VERSION,
};
}
@@ -22,12 +23,12 @@ const getInitialConfig = () => {
if (cachedAppName) {
return {
name: cachedAppName,
version: "1.0.9",
version: VERSION,
};
}
return {
name: "哆啦A梦",
version: "1.0.9",
version: VERSION,
};
};
+253 -145
View File
@@ -9,6 +9,7 @@ import { Chip } from "@heroui/chip";
import { Spinner } from "@heroui/spinner";
import { Switch } from "@heroui/switch";
import { Alert } from "@heroui/alert";
import { Accordion, AccordionItem } from "@heroui/accordion";
import toast from 'react-hot-toast';
import AdminLayout from "@/layouts/admin";
@@ -81,6 +82,19 @@ interface DiagnosisResult {
}>;
}
// 添加分组接口
interface UserGroup {
userId: number | null;
userName: string;
tunnelGroups: TunnelGroup[];
}
interface TunnelGroup {
tunnelId: number;
tunnelName: string;
forwards: Forward[];
}
export default function ForwardPage() {
const [loading, setLoading] = useState(true);
const [forwards, setForwards] = useState<Forward[]>([]);
@@ -149,6 +163,47 @@ export default function ForwardPage() {
}
};
// 按用户和隧道分组转发数据
const groupForwardsByUserAndTunnel = (): UserGroup[] => {
const userMap = new Map<string, UserGroup>();
forwards.forEach(forward => {
const userKey = forward.userId ? forward.userId.toString() : 'unknown';
const userName = forward.userName || '未知用户';
if (!userMap.has(userKey)) {
userMap.set(userKey, {
userId: forward.userId || null,
userName,
tunnelGroups: []
});
}
const userGroup = userMap.get(userKey)!;
let tunnelGroup = userGroup.tunnelGroups.find(tg => tg.tunnelId === forward.tunnelId);
if (!tunnelGroup) {
tunnelGroup = {
tunnelId: forward.tunnelId,
tunnelName: forward.tunnelName,
forwards: []
};
userGroup.tunnelGroups.push(tunnelGroup);
}
tunnelGroup.forwards.push(forward);
});
// 排序:先按用户名,再按隧道名
const result = Array.from(userMap.values());
result.sort((a, b) => a.userName.localeCompare(b.userName));
result.forEach(userGroup => {
userGroup.tunnelGroups.sort((a, b) => a.tunnelName.localeCompare(b.tunnelName));
});
return result;
};
// 表单验证
const validateForm = (): boolean => {
const newErrors: {[key: string]: string} = {};
@@ -598,7 +653,147 @@ export default function ForwardPage() {
return addresses.length;
};
// 渲染转发卡片
const renderForwardCard = (forward: Forward) => {
const statusDisplay = getStatusDisplay(forward.status);
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<Card key={forward.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">{forward.name}</h3>
<p className="text-xs text-default-500 truncate">{forward.tunnelName}</p>
</div>
<div className="flex items-center gap-1.5 ml-2">
<Switch
size="sm"
isSelected={forward.serviceRunning}
onValueChange={() => handleServiceToggle(forward)}
isDisabled={forward.status !== 1 && forward.status !== 0}
/>
<Chip
color={statusDisplay.color as any}
variant="flat"
size="sm"
className="text-xs"
>
{statusDisplay.text}
</Chip>
</div>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
<div className="space-y-2">
{/* 地址信息 */}
<div className="space-y-1">
<div
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.inIp) ? 'hover:bg-default-100 dark:hover:bg-default-200/50' : ''
}`}
onClick={() => showAddressModal(forward.inIp, forward.inPort, '入口地址')}
title={formatInAddress(forward.inIp, forward.inPort)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium text-default-600 flex-shrink-0">入口:</span>
<code className="text-xs font-mono text-foreground truncate min-w-0">
{formatInAddress(forward.inIp, forward.inPort)}
</code>
</div>
{hasMultipleAddresses(forward.inIp) && (
<svg className="w-3 h-3 text-default-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</div>
</div>
<div
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-default-100 dark:hover:bg-default-200/50' : ''
}`}
onClick={() => showAddressModal(forward.remoteAddr, null, '目标地址')}
title={formatRemoteAddress(forward.remoteAddr)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium text-default-600 flex-shrink-0">目标:</span>
<code className="text-xs font-mono text-foreground truncate min-w-0">
{formatRemoteAddress(forward.remoteAddr)}
</code>
</div>
{hasMultipleAddresses(forward.remoteAddr) && (
<svg className="w-3 h-3 text-default-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</div>
</div>
</div>
{/* 统计信息 */}
<div className="flex items-center justify-between pt-2 border-t border-divider">
<Chip color={strategyDisplay.color as any} variant="flat" size="sm" className="text-xs">
{strategyDisplay.text}
</Chip>
<Chip variant="flat" size="sm" className="text-xs">
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
</Chip>
</div>
</div>
<div className="flex gap-1.5 mt-3">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(forward)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="warning"
onPress={() => handleDiagnose(forward)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
}
>
诊断
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(forward)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
</svg>
}
>
删除
</Button>
</div>
</CardBody>
</Card>
);
};
if (loading) {
return (
@@ -613,6 +808,8 @@ export default function ForwardPage() {
);
}
const userGroups = groupForwardsByUserAndTunnel();
return (
<AdminLayout>
<div className="px-3 lg:px-6 py-8">
@@ -632,156 +829,69 @@ export default function ForwardPage() {
</Button>
</div>
{/* 转发卡片网格 */}
{forwards.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{forwards.map((forward) => {
const statusDisplay = getStatusDisplay(forward.status);
const strategyDisplay = getStrategyDisplay(forward.strategy);
return (
<Card key={forward.id} className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200">
<CardHeader className="pb-2">
<div className="flex justify-between items-start w-full">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm">{forward.name}</h3>
<p className="text-xs text-default-500 truncate">{forward.tunnelName}</p>
{/* 按用户和隧道分组的转发列表 */}
{userGroups.length > 0 ? (
<div className="space-y-6">
{userGroups.map((userGroup) => (
<Card key={userGroup.userId || 'unknown'} className="shadow-sm border border-divider w-full overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg>
</div>
<div className="flex items-center gap-1.5 ml-2">
<Switch
size="sm"
isSelected={forward.serviceRunning}
onValueChange={() => handleServiceToggle(forward)}
isDisabled={forward.status !== 1 && forward.status !== 0}
/>
<Chip
color={statusDisplay.color as any}
variant="flat"
size="sm"
className="text-xs"
>
{statusDisplay.text}
</Chip>
<div className="min-w-0 flex-1">
<h2 className="text-base font-medium text-foreground truncate max-w-[150px] sm:max-w-[250px] md:max-w-[350px] lg:max-w-[450px]">{userGroup.userName}</h2>
<p className="text-xs text-default-500 truncate max-w-[150px] sm:max-w-[250px] md:max-w-[350px] lg:max-w-[450px]">
{userGroup.tunnelGroups.length} 个隧道,
{userGroup.tunnelGroups.reduce((total, tg) => total + tg.forwards.length, 0)} 个转发
</p>
</div>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3">
<div className="space-y-2">
{/* 地址信息 */}
<div className="space-y-1">
<div
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.inIp) ? 'hover:bg-default-100 dark:hover:bg-default-200/50' : ''
}`}
onClick={() => showAddressModal(forward.inIp, forward.inPort, '入口地址')}
title={formatInAddress(forward.inIp, forward.inPort)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium text-default-600 flex-shrink-0">入口:</span>
<code className="text-xs font-mono text-foreground truncate min-w-0">
{formatInAddress(forward.inIp, forward.inPort)}
</code>
<Chip color="primary" variant="flat" size="sm" className="text-xs flex-shrink-0 ml-2">
用户
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0">
<Accordion variant="splitted" className="px-0">
{userGroup.tunnelGroups.map((tunnelGroup) => (
<AccordionItem
key={tunnelGroup.tunnelId}
aria-label={tunnelGroup.tunnelName}
title={
<div className="flex items-center justify-between w-full min-w-0 pr-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-success-100 dark:bg-success-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-foreground truncate max-w-[120px] sm:max-w-[200px] md:max-w-[300px] lg:max-w-[400px]">{tunnelGroup.tunnelName}</h3>
</div>
</div>
{hasMultipleAddresses(forward.inIp) && (
<svg className="w-3 h-3 text-default-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</div>
</div>
<div
className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.remoteAddr) ? 'hover:bg-default-100 dark:hover:bg-default-200/50' : ''
}`}
onClick={() => showAddressModal(forward.remoteAddr, null, '目标地址')}
title={formatRemoteAddress(forward.remoteAddr)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<span className="text-xs font-medium text-default-600 flex-shrink-0">目标:</span>
<code className="text-xs font-mono text-foreground truncate min-w-0">
{formatRemoteAddress(forward.remoteAddr)}
</code>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
<Chip variant="flat" size="sm" className="text-xs">
{tunnelGroup.forwards.filter(f => f.serviceRunning).length}/{tunnelGroup.forwards.length}
</Chip>
</div>
{hasMultipleAddresses(forward.remoteAddr) && (
<svg className="w-3 h-3 text-default-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</div>
}
className="shadow-none border border-divider"
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 p-4">
{tunnelGroup.forwards.map((forward) => renderForwardCard(forward))}
</div>
</div>
{/* 统计信息 */}
<div className="flex items-center justify-between pt-2 border-t border-divider">
<Chip color={strategyDisplay.color as any} variant="flat" size="sm" className="text-xs">
{strategyDisplay.text}
</Chip>
<Chip variant="flat" size="sm" className="text-xs">
{formatFlow((forward.inFlow || 0) + (forward.outFlow || 0))}
</Chip>
{forward.userName && (
<Chip variant="flat" size="sm" className="text-xs">
{forward.userName}
</Chip>
)}
</div>
</div>
<div className="flex gap-1.5 mt-3">
<Button
size="sm"
variant="flat"
color="primary"
onPress={() => handleEdit(forward)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
color="warning"
onPress={() => handleDiagnose(forward)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
}
>
诊断
</Button>
<Button
size="sm"
variant="flat"
color="danger"
onPress={() => handleDelete(forward)}
className="flex-1 min-h-8"
startContent={
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clipRule="evenodd" />
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clipRule="evenodd" />
</svg>
}
>
删除
</Button>
</div>
</CardBody>
</Card>
);
})}
</AccordionItem>
))}
</Accordion>
</CardBody>
</Card>
))}
</div>
) : (
/* 空状态 */
@@ -903,8 +1013,6 @@ export default function ForwardPage() {
<SelectItem key="hash" >哈希模式 - IP哈希</SelectItem>
</Select>
)}
</div>
</ModalBody>
<ModalFooter>