11import { zodResolver } from "@hookform/resolvers/zod" ;
22import { useMutation , useQuery , useQueryClient } from "@tanstack/react-query" ;
3- import { AlertTriangle , ArrowLeft , Info , RefreshCw } from "lucide-react" ;
3+ import type { ColumnDef } from "@tanstack/react-table" ;
4+ import { AlertTriangle , ArrowLeft , Info , RefreshCw , Sparkles , Trash2 } from "lucide-react" ;
45import { useEffect , useState } from "react" ;
56import { useForm } from "react-hook-form" ;
6- import { useNavigate , useParams } from "react-router-dom" ;
7+ import { useLocation , useNavigate , useParams } from "react-router-dom" ;
78import { Badge } from "../../components/ui/Badge" ;
89import { Button } from "../../components/ui/Button" ;
910import { Card } from "../../components/ui/Card" ;
11+ import { DataTable } from "../../components/ui/DataTable" ;
1012import { Input } from "../../components/ui/Input" ;
13+ import { OffsetPagination } from "../../components/ui/OffsetPagination" ;
1114import { Select } from "../../components/ui/Select" ;
1215import { Switch } from "../../components/ui/Switch" ;
1316import { Textarea } from "../../components/ui/Textarea" ;
1417import { ToastContainer } from "../../components/ui/Toast" ;
1518import { useToast } from "../../hooks/useToast" ;
1619import { useI18n } from "../../i18n" ;
1720import { formatApiErrorMessage } from "../../lib/error-message" ;
18- import { formatGoDuration , formatRelativeTime } from "../../lib/time" ;
19- import { clearAllPlatformLeases , deletePlatform , getPlatform , resetPlatform , updatePlatform } from "./api" ;
21+ import { formatDateTime , formatGoDuration , formatRelativeTime } from "../../lib/time" ;
22+ import {
23+ clearAllPlatformLeases ,
24+ deletePlatform ,
25+ deletePlatformLease ,
26+ getPlatform ,
27+ listPlatformLeases ,
28+ resetPlatform ,
29+ updatePlatform ,
30+ } from "./api" ;
2031import {
2132 allocationPolicies ,
2233 allocationPolicyLabel ,
@@ -35,10 +46,13 @@ import {
3546} from "./formModel" ;
3647import { PlatformAccessPanel } from "./PlatformAccessPanel" ;
3748import { PlatformMonitorPanel } from "./PlatformMonitorPanel" ;
49+ import type { PlatformLease } from "./types" ;
3850
3951type PlatformDetailTab = "monitor" | "access" | "config" | "ops" ;
4052
4153const ZERO_UUID = "00000000-0000-0000-0000-000000000000" ;
54+ const LEASE_MANAGEMENT_ANCHOR = "platform-lease-management" ;
55+ const LEASE_PAGE_SIZE_OPTIONS = [ 10 , 25 , 50 , 100 ] as const ;
4256const DETAIL_TABS : Array < { key : PlatformDetailTab ; label : string ; hint : string } > = [
4357 { key : "monitor" , label : "监控" , hint : "平台运行态趋势和快照" } ,
4458 { key : "access" , label : "接入" , hint : "复制正向/反向代理地址" } ,
@@ -49,8 +63,11 @@ const DETAIL_TABS: Array<{ key: PlatformDetailTab; label: string; hint: string }
4963export function PlatformDetailPage ( ) {
5064 const { t } = useI18n ( ) ;
5165 const { platformId = "" } = useParams ( ) ;
66+ const location = useLocation ( ) ;
5267 const navigate = useNavigate ( ) ;
5368 const [ activeTab , setActiveTab ] = useState < PlatformDetailTab > ( "monitor" ) ;
69+ const [ leasePage , setLeasePage ] = useState ( 0 ) ;
70+ const [ leasePageSize , setLeasePageSize ] = useState < number > ( LEASE_PAGE_SIZE_OPTIONS [ 0 ] ) ;
5471 const { toasts, showToast, dismissToast } = useToast ( ) ;
5572 const queryClient = useQueryClient ( ) ;
5673 const formatPlatformMutationError = ( error : unknown ) => {
@@ -71,6 +88,33 @@ export function PlatformDetailPage() {
7188
7289 const platform = platformQuery . data ?? null ;
7390
91+ const leaseQuery = useQuery ( {
92+ queryKey : [ "platform-leases" , platform ?. id , leasePage , leasePageSize ] ,
93+ queryFn : ( ) => {
94+ if ( ! platform ) {
95+ throw new Error ( "平台不存在或已被删除" ) ;
96+ }
97+ return listPlatformLeases ( platform . id , {
98+ limit : leasePageSize ,
99+ offset : leasePage * leasePageSize ,
100+ sort_by : "expiry" ,
101+ sort_order : "asc" ,
102+ } ) ;
103+ } ,
104+ enabled : Boolean ( platform ?. id ) && activeTab === "ops" ,
105+ refetchInterval : 30_000 ,
106+ placeholderData : ( previous ) => previous ,
107+ } ) ;
108+
109+ const leasesPage = leaseQuery . data ?? {
110+ items : [ ] ,
111+ total : 0 ,
112+ limit : leasePageSize ,
113+ offset : leasePage * leasePageSize ,
114+ } ;
115+ const leases = leasesPage . items ;
116+ const leaseTotalPages = Math . max ( 1 , Math . ceil ( leasesPage . total / leasePageSize ) ) ;
117+
74118 const editForm = useForm < PlatformFormValues > ( {
75119 resolver : zodResolver ( platformFormSchema ) ,
76120 defaultValues : defaultPlatformFormValues ,
@@ -84,6 +128,34 @@ export function PlatformDetailPage() {
84128 editForm . reset ( platformToFormValues ( platform ) ) ;
85129 } , [ platform , editForm ] ) ;
86130
131+ useEffect ( ( ) => {
132+ setLeasePage ( 0 ) ;
133+ } , [ platformId ] ) ;
134+
135+ useEffect ( ( ) => {
136+ const maxPage = Math . max ( 0 , Math . ceil ( leasesPage . total / leasePageSize ) - 1 ) ;
137+ if ( leasePage > maxPage ) {
138+ setLeasePage ( maxPage ) ;
139+ }
140+ } , [ leasePage , leasePageSize , leasesPage . total ] ) ;
141+
142+ useEffect ( ( ) => {
143+ const tab = new URLSearchParams ( location . search ) . get ( "tab" ) ;
144+ if ( tab === "ops" || location . hash === `#${ LEASE_MANAGEMENT_ANCHOR } ` ) {
145+ setActiveTab ( "ops" ) ;
146+ }
147+ } , [ location . hash , location . search ] ) ;
148+
149+ useEffect ( ( ) => {
150+ if ( activeTab !== "ops" || location . hash !== `#${ LEASE_MANAGEMENT_ANCHOR } ` ) {
151+ return ;
152+ }
153+
154+ window . requestAnimationFrame ( ( ) => {
155+ document . getElementById ( LEASE_MANAGEMENT_ANCHOR ) ?. scrollIntoView ( { block : "start" } ) ;
156+ } ) ;
157+ } , [ activeTab , location . hash ] ) ;
158+
87159 const invalidatePlatform = async ( id : string ) => {
88160 await Promise . all ( [
89161 queryClient . invalidateQueries ( { queryKey : [ "platforms" ] } ) ,
@@ -135,14 +207,39 @@ export function PlatformDetailPage() {
135207 return platform ;
136208 } ,
137209 onSuccess : async ( updated ) => {
138- await queryClient . invalidateQueries ( { queryKey : [ "platform-monitor" ] } ) ;
210+ await Promise . all ( [
211+ queryClient . invalidateQueries ( { queryKey : [ "platform-monitor" ] } ) ,
212+ queryClient . invalidateQueries ( { queryKey : [ "platform-leases" , updated . id ] } ) ,
213+ ] ) ;
139214 showToast ( "success" , t ( "平台 {{name}} 的所有租约已清除" , { name : updated . name } ) ) ;
140215 } ,
141216 onError : ( error ) => {
142217 showToast ( "error" , formatApiErrorMessage ( error , t ) ) ;
143218 } ,
144219 } ) ;
145220
221+ const releaseLeaseMutation = useMutation ( {
222+ mutationFn : async ( lease : PlatformLease ) => {
223+ if ( ! platform ) {
224+ throw new Error ( "平台不存在或已被删除" ) ;
225+ }
226+ await deletePlatformLease ( platform . id , lease . account ) ;
227+ return lease ;
228+ } ,
229+ onSuccess : async ( lease ) => {
230+ if ( platform ) {
231+ await Promise . all ( [
232+ queryClient . invalidateQueries ( { queryKey : [ "platform-monitor" ] } ) ,
233+ queryClient . invalidateQueries ( { queryKey : [ "platform-leases" , platform . id ] } ) ,
234+ ] ) ;
235+ }
236+ showToast ( "success" , t ( "账号 {{account}} 的租约已释放" , { account : lease . account } ) ) ;
237+ } ,
238+ onError : ( error ) => {
239+ showToast ( "error" , formatApiErrorMessage ( error , t ) ) ;
240+ } ,
241+ } ) ;
242+
146243 const deleteMutation = useMutation ( {
147244 mutationFn : async ( ) => {
148245 if ( ! platform ) {
@@ -190,6 +287,82 @@ export function PlatformDetailPage() {
190287 await clearLeasesMutation . mutateAsync ( ) ;
191288 } ;
192289
290+ const handleReleaseLease = async ( lease : PlatformLease ) => {
291+ const confirmed = window . confirm ( t ( "确认释放账号 {{account}} 的租约?" , { account : lease . account } ) ) ;
292+ if ( ! confirmed ) {
293+ return ;
294+ }
295+ await releaseLeaseMutation . mutateAsync ( lease ) ;
296+ } ;
297+
298+ const changeLeasePageSize = ( next : number ) => {
299+ setLeasePageSize ( next ) ;
300+ setLeasePage ( 0 ) ;
301+ } ;
302+
303+ const leaseColumns : ColumnDef < PlatformLease > [ ] = [
304+ {
305+ accessorKey : "account" ,
306+ header : t ( "账号" ) ,
307+ cell : ( { row } ) => (
308+ < span className = "lease-account-cell" title = { row . original . account } >
309+ { row . original . account || "-" }
310+ </ span >
311+ ) ,
312+ } ,
313+ {
314+ id : "node" ,
315+ header : t ( "节点" ) ,
316+ cell : ( { row } ) => {
317+ const lease = row . original ;
318+ return (
319+ < span className = "lease-node-cell" title = { lease . node_tag || lease . node_hash } >
320+ < strong > { lease . node_tag || "-" } </ strong >
321+ < small > { lease . node_hash || "-" } </ small >
322+ </ span >
323+ ) ;
324+ } ,
325+ } ,
326+ {
327+ accessorKey : "egress_ip" ,
328+ header : t ( "出口 IP" ) ,
329+ cell : ( { row } ) => row . original . egress_ip || "-" ,
330+ } ,
331+ {
332+ accessorKey : "expiry" ,
333+ header : t ( "过期时间" ) ,
334+ cell : ( { row } ) => formatDateTime ( row . original . expiry ) ,
335+ } ,
336+ {
337+ accessorKey : "last_accessed" ,
338+ header : t ( "最后访问" ) ,
339+ cell : ( { row } ) => formatDateTime ( row . original . last_accessed ) ,
340+ } ,
341+ {
342+ id : "actions" ,
343+ header : t ( "操作" ) ,
344+ cell : ( { row } ) => {
345+ const lease = row . original ;
346+ const releasing = releaseLeaseMutation . isPending && releaseLeaseMutation . variables ?. account === lease . account ;
347+ return (
348+ < div className = "lease-row-actions" onClick = { ( event ) => event . stopPropagation ( ) } >
349+ < Button
350+ variant = "ghost"
351+ size = "sm"
352+ onClick = { ( ) => void handleReleaseLease ( lease ) }
353+ disabled = { releasing || clearLeasesMutation . isPending }
354+ title = { t ( "释放租约" ) }
355+ aria-label = { t ( "释放账号 {{account}} 的租约" , { account : lease . account } ) }
356+ style = { { color : "var(--delete-btn-color, #c27070)" } }
357+ >
358+ < Trash2 size = { 14 } />
359+ </ Button >
360+ </ div >
361+ ) ;
362+ } ,
363+ } ,
364+ ] ;
365+
193366 const stickyTTL = platform ? formatGoDuration ( platform . sticky_ttl , t ( "默认" ) ) : t ( "默认" ) ;
194367 const regionCount = platform ?. region_filters . length ?? 0 ;
195368 const regexCount = platform ?. regex_filters . length ?? 0 ;
@@ -494,49 +667,105 @@ export function PlatformDetailPage() {
494667 ) : null }
495668
496669 { activeTab === "ops" ? (
497- < section
670+ < div
498671 id = "platform-tabpanel-ops"
499672 role = "tabpanel"
500673 aria-labelledby = "platform-tab-ops"
501- className = "platform-detail-tabpanel platform-ops-section "
674+ className = "platform-detail-tabpanel platform-ops-tabpanel "
502675 >
503- < div className = "platform-drawer-section-head" >
504- < h4 > { t ( "运维操作" ) } </ h4 >
505- < p > { t ( "以下操作会直接作用于当前平台,请谨慎执行。" ) } </ p >
506- </ div >
676+ < section className = "platform-ops-section" >
677+ < div className = "platform-drawer-section-head" >
678+ < h4 > { t ( "运维操作" ) } </ h4 >
679+ < p > { t ( "以下操作会直接作用于当前平台,请谨慎执行。" ) } </ p >
680+ </ div >
507681
508- < div className = "platform-ops-list" >
509- < div className = "platform-op-item" >
510- < div className = "platform-op-copy" >
511- < h5 > { t ( "重置为默认配置" ) } </ h5 >
512- < p className = "platform-op-hint" > { t ( "恢复默认设置,并覆盖当前修改。" ) } </ p >
682+ < div className = "platform-ops-list" >
683+ < div className = "platform-op-item" >
684+ < div className = "platform-op-copy" >
685+ < h5 > { t ( "重置为默认配置" ) } </ h5 >
686+ < p className = "platform-op-hint" > { t ( "恢复默认设置,并覆盖当前修改。" ) } </ p >
687+ </ div >
688+ < Button variant = "secondary" onClick = { ( ) => void resetMutation . mutateAsync ( ) } disabled = { resetMutation . isPending } >
689+ { resetMutation . isPending ? t ( "重置中..." ) : t ( "重置为默认配置" ) }
690+ </ Button >
513691 </ div >
514- < Button variant = "secondary" onClick = { ( ) => void resetMutation . mutateAsync ( ) } disabled = { resetMutation . isPending } >
515- { resetMutation . isPending ? t ( "重置中..." ) : t ( "重置为默认配置" ) }
516- </ Button >
517- </ div >
518692
519- < div className = "platform-op-item" >
520- < div className = "platform-op-copy" >
521- < h5 > { t ( "清除所有租约" ) } </ h5 >
522- < p className = "platform-op-hint" > { t ( "立即清除当前平台的全部租约,下次请求将重新分配出口。" ) } </ p >
693+ < div className = "platform-op-item" >
694+ < div className = "platform-op-copy" >
695+ < h5 > { t ( "清除所有租约" ) } </ h5 >
696+ < p className = "platform-op-hint" > { t ( "立即清除当前平台的全部租约,下次请求将重新分配出口。" ) } </ p >
697+ </ div >
698+ < Button variant = "danger" onClick = { ( ) => void handleClearAllLeases ( ) } disabled = { clearLeasesMutation . isPending } >
699+ { clearLeasesMutation . isPending ? t ( "清除中..." ) : t ( "清除所有租约" ) }
700+ </ Button >
701+ </ div >
702+
703+ < div className = "platform-op-item" >
704+ < div className = "platform-op-copy" >
705+ < h5 > { t ( "删除平台" ) } </ h5 >
706+ < p className = "platform-op-hint" > { t ( "永久删除当前平台及其配置,操作不可撤销。" ) } </ p >
707+ </ div >
708+ < Button variant = "danger" onClick = { ( ) => void handleDelete ( ) } disabled = { deleteDisabled } >
709+ { deleteMutation . isPending ? t ( "删除中..." ) : t ( "删除平台" ) }
710+ </ Button >
523711 </ div >
524- < Button variant = "danger" onClick = { ( ) => void handleClearAllLeases ( ) } disabled = { clearLeasesMutation . isPending } >
525- { clearLeasesMutation . isPending ? t ( "清除中..." ) : t ( "清除所有租约" ) }
526- </ Button >
527712 </ div >
713+ </ section >
528714
529- < div className = "platform-op-item" >
530- < div className = "platform-op-copy" >
531- < h5 > { t ( "删除平台" ) } </ h5 >
532- < p className = "platform-op-hint" > { t ( "永久删除当前平台及其配置,操作不可撤销。" ) } </ p >
715+ < section id = { LEASE_MANAGEMENT_ANCHOR } className = "platform-lease-section" >
716+ < div className = "platform-drawer-section-head platform-lease-head" >
717+ < div >
718+ < h4 > { t ( "租约管理" ) } </ h4 >
719+ < p > { t ( "查看当前平台的租约绑定,并按账号释放单个租约。" ) } </ p >
533720 </ div >
534- < Button variant = "danger" onClick = { ( ) => void handleDelete ( ) } disabled = { deleteDisabled } >
535- { deleteMutation . isPending ? t ( "删除中..." ) : t ( "删除平台" ) }
721+ < Button
722+ variant = "secondary"
723+ size = "sm"
724+ onClick = { ( ) => void leaseQuery . refetch ( ) }
725+ disabled = { leaseQuery . isFetching }
726+ >
727+ < RefreshCw size = { 16 } className = { leaseQuery . isFetching ? "spin" : undefined } />
728+ { t ( "刷新" ) }
536729 </ Button >
537730 </ div >
538- </ div >
539- </ section >
731+
732+ { leaseQuery . isLoading ? < p className = "muted" > { t ( "正在加载租约数据..." ) } </ p > : null }
733+
734+ { leaseQuery . isError ? (
735+ < div className = "callout callout-error" >
736+ < AlertTriangle size = { 14 } />
737+ < span > { formatApiErrorMessage ( leaseQuery . error , t ) } </ span >
738+ </ div >
739+ ) : null }
740+
741+ { ! leaseQuery . isLoading && ! leases . length ? (
742+ < div className = "empty-box" >
743+ < Sparkles size = { 16 } />
744+ < p > { t ( "当前平台暂无租约" ) } </ p >
745+ </ div >
746+ ) : null }
747+
748+ { leases . length ? (
749+ < DataTable
750+ data = { leases }
751+ columns = { leaseColumns }
752+ getRowId = { ( lease ) => lease . account }
753+ className = "data-table-leases"
754+ wrapClassName = "platform-lease-table-wrap"
755+ />
756+ ) : null }
757+
758+ < OffsetPagination
759+ page = { leasePage }
760+ totalPages = { leaseTotalPages }
761+ totalItems = { leasesPage . total }
762+ pageSize = { leasePageSize }
763+ pageSizeOptions = { LEASE_PAGE_SIZE_OPTIONS }
764+ onPageChange = { setLeasePage }
765+ onPageSizeChange = { changeLeasePageSize }
766+ />
767+ </ section >
768+ </ div >
540769 ) : null }
541770 </ Card >
542771 </ >
0 commit comments