Skip to content

Commit c3e83c9

Browse files
author
yangtao
committed
feat: 主控管理页增加刷新按钮、复制配置、快速添加实例,修改样式
1 parent 1f5b7da commit c3e83c9

5 files changed

Lines changed: 317 additions & 43 deletions

File tree

app/endpoints/page.tsx

Lines changed: 124 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
ModalHeader,
1717
Skeleton,
1818
cn,
19-
useDisclosure
19+
useDisclosure,
20+
Dropdown,
21+
DropdownTrigger,
22+
DropdownMenu,
23+
DropdownItem
2024
} from "@heroui/react";
2125
import { useState, useEffect } from "react";
2226

@@ -25,7 +29,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
2529
import {
2630
faPlus,
2731
faServer,
28-
faCheck,
32+
faBullseye,
2933
faEye,
3034
faEdit,
3135
faTrash,
@@ -38,8 +42,8 @@ import {
3842
faPen,
3943
faWifi,
4044
faSpinner,
41-
faAdd,
42-
faLightbulb
45+
faCopy,
46+
faEllipsisVertical
4347
} from "@fortawesome/free-solid-svg-icons";
4448
import AddEndpointModal from "./components/add-endpoint-modal";
4549
import RenameEndpointModal from "./components/rename-endpoint-modal";
@@ -430,7 +434,7 @@ export default function EndpointsPage() {
430434
<div className="flex items-center justify-between h-full w-full">
431435
<div className="flex items-center gap-2">
432436
<FontAwesomeIcon
433-
icon={faCheck}
437+
icon={faBullseye}
434438
className={
435439
realTimeData.status === 'ONLINE' ? "text-success-600" :
436440
realTimeData.status === 'FAIL' ? "text-danger-600" : "text-warning-600"
@@ -440,37 +444,45 @@ export default function EndpointsPage() {
440444
{realTimeData.tunnelCount ? `${realTimeData.tunnelCount} 个实例` : "0 个实例"}
441445
</p>
442446
</div>
443-
<div className="flex items-center gap-1">
444-
<div
445-
className={cn(
446-
"inline-flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer transition-colors",
447-
realTimeData.status === 'ONLINE'
448-
? "text-warning hover:bg-warning/10"
449-
: "text-success hover:bg-success/10"
450-
)}
451-
onClick={(e) => {
452-
e.stopPropagation();
453-
if (realTimeData.status === 'ONLINE') {
454-
handleDisconnect(endpoint.id);
455-
} else {
456-
handleConnect(endpoint.id);
457-
}
458-
}}
459-
>
460-
<FontAwesomeIcon
461-
icon={realTimeData.status === 'ONLINE' ? faPlugCircleXmark : faPlug}
462-
className={realTimeData.status === 'ONLINE' ? "text-warning" : "text-success"}
463-
/>
464-
</div>
465-
<div
466-
className="inline-flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer text-danger hover:bg-danger/10 transition-colors"
467-
onClick={(e) => {
468-
e.stopPropagation();
469-
handleDeleteClick(endpoint);
470-
}}
471-
>
472-
<FontAwesomeIcon icon={faTrash} />
473-
</div>
447+
<div className="flex items-center">
448+
<Dropdown placement="bottom-end">
449+
<DropdownTrigger>
450+
<Button isIconOnly variant="light" size="sm" onPress={(e)=>{(e as any).stopPropagation?.();}}>
451+
<FontAwesomeIcon icon={faEllipsisVertical} />
452+
</Button>
453+
</DropdownTrigger>
454+
<DropdownMenu aria-label="Actions" onAction={(key)=>{
455+
switch(key){
456+
case 'toggle':
457+
if(realTimeData.status==='ONLINE') handleDisconnect(endpoint.id); else handleConnect(endpoint.id);
458+
break;
459+
case 'rename':
460+
handleCardClick(endpoint);
461+
break;
462+
case 'copy':
463+
handleCopyConfig(endpoint);
464+
break;
465+
case 'addTunnel':
466+
handleAddTunnel(endpoint);
467+
break;
468+
case 'delete':
469+
handleDeleteClick(endpoint);
470+
break;
471+
}}}>
472+
<DropdownItem key="addTunnel" startContent={<FontAwesomeIcon icon={faPlus}/>} className="text-primary" color="primary">添加隧道</DropdownItem>
473+
<DropdownItem key="rename" startContent={<FontAwesomeIcon icon={faPen} />}>重命名</DropdownItem>
474+
<DropdownItem key="copy" startContent={<FontAwesomeIcon icon={faCopy}/>}>复制配置</DropdownItem>
475+
<DropdownItem
476+
key="toggle"
477+
startContent={<FontAwesomeIcon icon={realTimeData.status==='ONLINE'?faPlugCircleXmark:faPlug}/> }
478+
color={realTimeData.status==='ONLINE' ? 'warning' : 'success'}
479+
className={realTimeData.status==='ONLINE' ? 'text-warning' : 'text-success'}
480+
>
481+
{realTimeData.status==='ONLINE'?'断开连接':'连接主控'}
482+
</DropdownItem>
483+
<DropdownItem key="delete" className="text-danger" color="danger" startContent={<FontAwesomeIcon icon={faTrash}/>}>删除主控</DropdownItem>
484+
</DropdownMenu>
485+
</Dropdown>
474486
</div>
475487
</div>
476488
);
@@ -552,10 +564,59 @@ export default function EndpointsPage() {
552564
}
553565
};
554566

567+
// 打开添加隧道弹窗
568+
const {isOpen: isAddTunnelOpen, onOpen: onAddTunnelOpen, onOpenChange: onAddTunnelOpenChange} = useDisclosure();
569+
const [tunnelUrl, setTunnelUrl] = useState('');
570+
571+
function handleAddTunnel(endpoint: FormattedEndpoint) {
572+
setSelectedEndpoint(endpoint);
573+
setTunnelUrl('');
574+
onAddTunnelOpen();
575+
}
576+
577+
// 提交添加隧道
578+
const handleSubmitAddTunnel = async () => {
579+
if(!selectedEndpoint) return;
580+
if(!tunnelUrl.trim()) {
581+
addToast({title:'请输入 URL', description:'隧道 URL 不能为空', color:'warning'});
582+
return;
583+
}
584+
try {
585+
const res = await fetch(buildApiUrl('/api/tunnels/quick'), {
586+
method: 'POST',
587+
headers: {'Content-Type':'application/json'},
588+
body: JSON.stringify({endpointId: selectedEndpoint.id, url: tunnelUrl.trim()})
589+
});
590+
const data = await res.json();
591+
if(!res.ok || !data.success){
592+
throw new Error(data.error || '创建隧道失败');
593+
}
594+
addToast({title:'创建成功', description: data.message || '隧道已创建', color:'success'});
595+
onAddTunnelOpenChange();
596+
} catch(err){
597+
addToast({title:'创建失败', description: err instanceof Error ? err.message : '无法创建隧道', color:'danger'});
598+
}
599+
};
600+
601+
// 复制配置到剪贴板
602+
function handleCopyConfig(endpoint: FormattedEndpoint) {
603+
const cfg = `API URL: ${endpoint.url}${endpoint.apiPath}\nAPI KEY: ${endpoint.apiKey}`;
604+
navigator.clipboard.writeText(cfg).then(()=>{
605+
addToast({title:'已复制', description:'配置已复制到剪贴板', color:'success'});
606+
}).catch(()=>{
607+
addToast({title:'复制失败', description:'无法复制到剪贴板', color:'danger'});
608+
});
609+
}
610+
555611
return (
556612
<div className="max-w-7xl mx-auto py-6 space-y-6">
557613
<div className="flex justify-between items-center">
558-
<h1 className="text-2xl font-bold">API 主控管理</h1>
614+
<div className="flex items-center gap-4">
615+
<h1 className="text-2xl font-bold">API 主控管理</h1>
616+
<Button isIconOnly variant="light" onPress={async ()=>{await fetchEndpoints();}}>
617+
<FontAwesomeIcon icon={faRotateRight} />
618+
</Button>
619+
</div>
559620
</div>
560621

561622
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -610,8 +671,6 @@ export default function EndpointsPage() {
610671
<Card
611672
key={endpoint.id}
612673
className="relative w-full h-[200px]"
613-
isPressable
614-
onPress={() => handleCardClick(endpoint)}
615674
>
616675
{/* 状态按钮 */}
617676
<div
@@ -636,14 +695,14 @@ export default function EndpointsPage() {
636695
<h2 className="inline bg-gradient-to-br from-foreground-800 to-foreground-500 bg-clip-text text-2xl font-semibold tracking-tight text-transparent dark:to-foreground-200">
637696
{endpoint.name}
638697
</h2>
639-
<span className="inline-flex items-center px-2 py-1 text-xs font-normal rounded-md bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
698+
{/* <span className="inline-flex items-center px-2 py-1 text-xs font-normal rounded-md bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
640699
{endpoint.apiPath}
641-
</span>
700+
</span> */}
642701
</div>
643702
<div className="space-y-2">
644703
<div className="flex items-center gap-2 text-default-400">
645704
<FontAwesomeIcon icon={faServer} />
646-
<span className="text-small truncate">{endpoint.url}</span>
705+
<span className="text-small truncate">{endpoint.url}{endpoint.apiPath}</span>
647706
</div>
648707
<div className="flex items-center gap-2 text-default-400">
649708
<FontAwesomeIcon
@@ -712,11 +771,33 @@ export default function EndpointsPage() {
712771
<RenameEndpointModal
713772
isOpen={isRenameOpen}
714773
onOpenChange={onRenameOpenChange}
715-
onRename={handleRename}
716774
currentName={selectedEndpoint.name}
775+
onRename={handleRename}
717776
/>
718777
)}
719778

779+
{/* 添加隧道弹窗 */}
780+
<Modal isOpen={isAddTunnelOpen} onOpenChange={onAddTunnelOpenChange} placement="center">
781+
<ModalContent>
782+
{(onClose)=> (
783+
<>
784+
<ModalHeader>添加隧道</ModalHeader>
785+
<ModalBody>
786+
<Input
787+
placeholder="<core>://<tunnel_addr>/<target_addr>"
788+
value={tunnelUrl}
789+
onValueChange={setTunnelUrl}
790+
/>
791+
</ModalBody>
792+
<ModalFooter>
793+
<Button variant="light" onPress={onClose}>取消</Button>
794+
<Button color="primary" onPress={handleSubmitAddTunnel}>确定</Button>
795+
</ModalFooter>
796+
</>
797+
)}
798+
</ModalContent>
799+
</Modal>
800+
720801
{/* 删除确认模态框 */}
721802
<Modal isOpen={isDeleteOpen} onOpenChange={onDeleteOpenChange} placement="center">
722803
<ModalContent>

internal/api/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func (r *Router) registerRoutes() {
122122
// 隧道相关路由
123123
r.router.HandleFunc("/api/tunnels", r.tunnelHandler.HandleGetTunnels).Methods("GET")
124124
r.router.HandleFunc("/api/tunnels", r.tunnelHandler.HandleCreateTunnel).Methods("POST")
125+
r.router.HandleFunc("/api/tunnels/quick", r.tunnelHandler.HandleQuickCreateTunnel).Methods("POST")
125126
r.router.HandleFunc("/api/tunnels", r.tunnelHandler.HandlePatchTunnels).Methods("PATCH")
126127
r.router.HandleFunc("/api/tunnels/{id}", r.tunnelHandler.HandlePatchTunnels).Methods("PATCH")
127128
r.router.HandleFunc("/api/tunnels/{id}", r.tunnelHandler.HandleGetTunnels).Methods("GET")

internal/api/tunnel.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,3 +794,47 @@ func ptrString(ns sql.NullString) string {
794794
}
795795
return ""
796796
}
797+
798+
// HandleQuickCreateTunnel 根据 URL 快速创建隧道
799+
func (h *TunnelHandler) HandleQuickCreateTunnel(w http.ResponseWriter, r *http.Request) {
800+
if r.Method != http.MethodPost {
801+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
802+
return
803+
}
804+
805+
var req struct {
806+
EndpointID int64 `json:"endpointId"`
807+
URL string `json:"url"`
808+
}
809+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
810+
w.WriteHeader(http.StatusBadRequest)
811+
json.NewEncoder(w).Encode(tunnel.TunnelResponse{
812+
Success: false,
813+
Error: "无效的请求数据",
814+
})
815+
return
816+
}
817+
818+
if req.EndpointID == 0 || req.URL == "" {
819+
w.WriteHeader(http.StatusBadRequest)
820+
json.NewEncoder(w).Encode(tunnel.TunnelResponse{
821+
Success: false,
822+
Error: "endpointId 和 url 均不能为空",
823+
})
824+
return
825+
}
826+
827+
if err := h.tunnelService.QuickCreateTunnel(req.EndpointID, req.URL); err != nil {
828+
w.WriteHeader(http.StatusBadRequest)
829+
json.NewEncoder(w).Encode(tunnel.TunnelResponse{
830+
Success: false,
831+
Error: err.Error(),
832+
})
833+
return
834+
}
835+
836+
json.NewEncoder(w).Encode(tunnel.TunnelResponse{
837+
Success: true,
838+
Message: "隧道创建成功",
839+
})
840+
}

internal/sse/manager.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ func (m *Manager) ConnectEndpoint(endpointID int64, url, apiPath, apiKey string)
171171
go m.listenSSE(ctx, conn)
172172
slog.Info("已启动 SSE 监听协程", "endpointID", endpointID)
173173

174+
// 立即标记端点为 ONLINE(监听协程会负责后续状态更新)
175+
m.markEndpointOnline(endpointID)
176+
174177
return nil
175178
}
176179

0 commit comments

Comments
 (0)