@@ -11,9 +11,11 @@ import {
1111 Input ,
1212 Select ,
1313 SelectItem ,
14+ Listbox ,
15+ ListboxItem ,
1416} from "@heroui/react" ;
1517import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" ;
16- import { faHammer } from "@fortawesome/free-solid-svg-icons" ;
18+ import { faHammer , faPlus , faTrash } from "@fortawesome/free-solid-svg-icons" ;
1719import { addToast } from "@heroui/toast" ;
1820import { buildApiUrl } from '@/lib/utils' ;
1921
@@ -22,6 +24,13 @@ interface Endpoint {
2224 name : string ;
2325}
2426
27+ interface TunnelRule {
28+ id : string ;
29+ endpointId : string ;
30+ name : string ;
31+ url : string ;
32+ }
33+
2534interface ManualCreateTunnelModalProps {
2635 isOpen : boolean ;
2736 onOpenChange : ( open : boolean ) => void ;
@@ -40,12 +49,8 @@ export default function ManualCreateTunnelModal({
4049 const [ loading , setLoading ] = useState ( true ) ;
4150 const [ submitting , setSubmitting ] = useState ( false ) ;
4251
43- // 表单数据
44- const [ formData , setFormData ] = useState ( {
45- endpointId : "" ,
46- tunnelName : "" ,
47- tunnelUrl : ""
48- } ) ;
52+ // 隧道规则列表
53+ const [ tunnelRules , setTunnelRules ] = useState < TunnelRule [ ] > ( [ ] ) ;
4954
5055 // 当打开时加载端点
5156 useEffect ( ( ) => {
@@ -57,11 +62,6 @@ export default function ManualCreateTunnelModal({
5762 const response = await fetch ( buildApiUrl ( "/api/endpoints/simple?excludeFailed=true" ) ) ;
5863 const data = await response . json ( ) ;
5964 setEndpoints ( data ) ;
60-
61- // 如果有主控,默认选择第一个
62- if ( data . length > 0 ) {
63- setFormData ( prev => ( { ...prev , endpointId : String ( data [ 0 ] . id ) } ) ) ;
64- }
6565 } catch ( err ) {
6666 addToast ( {
6767 title : "获取主控失败" ,
@@ -74,63 +74,92 @@ export default function ManualCreateTunnelModal({
7474 } ;
7575
7676 fetchEndpoints ( ) ;
77+ resetForm ( ) ;
7778 } , [ isOpen ] ) ;
7879
7980 // 重置表单
8081 const resetForm = ( ) => {
81- setFormData ( {
82- endpointId : "" ,
83- tunnelName : "" ,
84- tunnelUrl : ""
85- } ) ;
82+ setTunnelRules ( [ ] ) ;
83+ } ;
84+
85+ // 添加新规则
86+ const addNewRule = ( ) => {
87+ const newRule : TunnelRule = {
88+ id : `rule-${ Date . now ( ) } ` ,
89+ endpointId : endpoints . length > 0 ? endpoints [ 0 ] . id : '' ,
90+ name : '' ,
91+ url : ''
92+ } ;
93+ setTunnelRules ( prev => [ ...prev , newRule ] ) ;
94+ } ;
95+
96+ // 删除规则
97+ const removeRule = ( ruleId : string ) => {
98+ setTunnelRules ( prev => prev . filter ( rule => rule . id !== ruleId ) ) ;
8699 } ;
87100
88- // 处理字段变化
89- const handleFieldChange = ( field : string , value : string ) => {
90- setFormData ( prev => ( { ...prev , [ field ] : value } ) ) ;
101+ // 更新规则
102+ const updateRule = ( ruleId : string , field : keyof TunnelRule , value : string ) => {
103+ setTunnelRules ( prev => prev . map ( rule =>
104+ rule . id === ruleId ? { ...rule , [ field ] : value } : rule
105+ ) ) ;
91106 } ;
92107
93108 // 提交表单
94109 const handleSubmit = async ( ) => {
95- if ( ! formData . endpointId . trim ( ) ) {
110+ if ( tunnelRules . length === 0 ) {
96111 addToast ( {
97112 title : "创建失败" ,
98- description : "请选择主控服务器" ,
99- color : "warning"
100- } ) ;
101- return ;
102- }
103-
104- if ( ! formData . tunnelName . trim ( ) ) {
105- addToast ( {
106- title : "创建失败" ,
107- description : "请输入实例名称" ,
113+ description : "请添加至少一条隧道" ,
108114 color : "warning"
109115 } ) ;
110116 return ;
111117 }
112118
113- if ( ! formData . tunnelUrl . trim ( ) ) {
114- addToast ( {
115- title : "创建失败" ,
116- description : "请输入实例URL" ,
117- color : "warning"
118- } ) ;
119- return ;
119+ // 验证所有规则的完整性
120+ for ( let i = 0 ; i < tunnelRules . length ; i ++ ) {
121+ const rule = tunnelRules [ i ] ;
122+ if ( ! rule . endpointId ) {
123+ addToast ( {
124+ title : "创建失败" ,
125+ description : `第 ${ i + 1 } 条规则请选择主控服务器` ,
126+ color : "warning"
127+ } ) ;
128+ return ;
129+ }
130+ if ( ! rule . name . trim ( ) ) {
131+ addToast ( {
132+ title : "创建失败" ,
133+ description : `第 ${ i + 1 } 条规则请输入实例名称` ,
134+ color : "warning"
135+ } ) ;
136+ return ;
137+ }
138+ if ( ! rule . url . trim ( ) ) {
139+ addToast ( {
140+ title : "创建失败" ,
141+ description : `第 ${ i + 1 } 条规则请输入实例URL` ,
142+ color : "warning"
143+ } ) ;
144+ return ;
145+ }
120146 }
121147
122148 try {
123149 setSubmitting ( true ) ;
124150
125- const response = await fetch ( buildApiUrl ( `/api/tunnels/quick` ) , {
151+ // 调用新的批量创建接口
152+ const response = await fetch ( buildApiUrl ( `/api/tunnels/quick-batch` ) , {
126153 method : 'POST' ,
127154 headers : {
128155 'Content-Type' : 'application/json' ,
129156 } ,
130157 body : JSON . stringify ( {
131- endpointId : Number ( formData . endpointId ) ,
132- name : formData . tunnelName . trim ( ) ,
133- url : formData . tunnelUrl . trim ( )
158+ rules : tunnelRules . map ( rule => ( {
159+ endpointId : Number ( rule . endpointId ) ,
160+ name : rule . name . trim ( ) ,
161+ url : rule . url . trim ( )
162+ } ) )
134163 } ) ,
135164 } ) ;
136165
@@ -139,9 +168,11 @@ export default function ManualCreateTunnelModal({
139168 throw new Error ( errorData . message || '创建实例失败' ) ;
140169 }
141170
171+ const result = await response . json ( ) ;
172+
142173 addToast ( {
143174 title : "创建成功" ,
144- description : `实例 " ${ formData . tunnelName } " 已成功创建 `,
175+ description : result . message || `成功创建 ${ tunnelRules . length } 个实例 `,
145176 color : "success" ,
146177 } ) ;
147178
@@ -169,61 +200,121 @@ export default function ManualCreateTunnelModal({
169200 isOpen = { isOpen }
170201 onOpenChange = { onOpenChange }
171202 placement = "center"
172- size = "lg"
203+ size = "5xl"
204+ scrollBehavior = "inside"
173205 >
174206 < ModalContent >
175207 { ( onClose ) => (
176208 < >
177- < ModalHeader className = "flex items-center gap-2" >
178- < FontAwesomeIcon icon = { faHammer } className = "text-warning" />
179- 手搓创建实例
209+ < ModalHeader className = "flex items-center justify-start gap-2" >
210+ < div className = "flex items-center gap-2" >
211+ < FontAwesomeIcon icon = { faHammer } className = "text-warning" />
212+ 手搓创建实例
213+ </ div >
214+ < Button
215+ size = "sm"
216+ color = "primary"
217+ variant = "flat"
218+ startContent = { < FontAwesomeIcon icon = { faPlus } className = "text-xs" /> }
219+ onClick = { addNewRule }
220+ isDisabled = { loading }
221+ >
222+ 添加
223+ </ Button >
180224 </ ModalHeader >
181- < ModalBody >
225+ < ModalBody >
182226 { loading ? (
183227 < div className = "flex justify-center items-center py-6" >
184228 < div className = "animate-spin rounded-full h-8 w-8 border-b-2 border-primary" > </ div >
185229 </ div >
186230 ) : (
187- < >
188- { /* 主控选择 */ }
189- < Select
190- label = "选择主控"
191- placeholder = "请选择要使用的主控服务器"
192- variant = "bordered"
193- selectedKeys = { formData . endpointId ? [ formData . endpointId ] : [ ] }
194- onSelectionChange = { ( keys ) => {
195- const selected = Array . from ( keys ) [ 0 ] as string ;
196- handleFieldChange ( 'endpointId' , selected ) ;
197- } }
198- isRequired
199- >
200- { endpoints . map ( ( endpoint ) => (
201- < SelectItem key = { endpoint . id } >
202- { endpoint . name }
203- </ SelectItem >
204- ) ) }
205- </ Select >
206-
207- { /* 实例名称 */ }
208- < Input
209- label = "实例名称"
210- placeholder = "请输入实例名称"
211- variant = "bordered"
212- value = { formData . tunnelName }
213- onValueChange = { ( value ) => handleFieldChange ( 'tunnelName' , value ) }
214- isRequired
215- />
216-
217- { /* 实例URL */ }
218- < Input
219- label = "实例URL"
220- placeholder = "<core>://<tunnel_addr>/<target_addr>"
221- variant = "bordered"
222- value = { formData . tunnelUrl }
223- onValueChange = { ( value ) => handleFieldChange ( 'tunnelUrl' , value ) }
224- isRequired
225- />
226- </ >
231+ < div className = "space-y-4" >
232+ { /* 隧道规则区域 */ }
233+ < div className = "space-y-3" >
234+ { tunnelRules . length === 0 ? (
235+ < div className = "text-center py-8 border-2 border-dashed border-default-200 rounded-lg" >
236+ < p className = "text-default-500 text-sm" > 暂无隧道规则,点击右上角"添加规则"开始配置</ p >
237+ </ div >
238+ ) : (
239+ < div className = "max-h-96 overflow-y-auto border border-default-200 rounded-lg" >
240+ < Listbox
241+ aria-label = "隧道规则列表"
242+ variant = "flat"
243+ selectionMode = "none"
244+ className = "p-0"
245+ >
246+ { tunnelRules . map ( ( rule , index ) => (
247+ < ListboxItem
248+ key = { rule . id }
249+ textValue = { `规则 ${ index + 1 } ` }
250+ className = "py-2"
251+ >
252+ < div className = "flex items-center gap-3" >
253+ < span className = "text-sm font-medium text-default-600 min-w-fit" >
254+ #{ index + 1 }
255+ </ span >
256+ < div className = "flex-1 grid grid-cols-8 gap-3" >
257+ { /* 主控选择 */ }
258+ < div className = "col-span-2" >
259+ < Select
260+ placeholder = "选择主控"
261+ selectedKeys = { rule . endpointId ? [ rule . endpointId ] : [ ] }
262+ onSelectionChange = { ( keys ) => {
263+ const selected = Array . from ( keys ) [ 0 ] as string ;
264+ updateRule ( rule . id , 'endpointId' , selected ) ;
265+ } }
266+ size = "sm"
267+ variant = "bordered"
268+ isRequired
269+ >
270+ { endpoints . map ( ( endpoint ) => (
271+ < SelectItem key = { endpoint . id } >
272+ { endpoint . name }
273+ </ SelectItem >
274+ ) ) }
275+ </ Select >
276+ </ div >
277+
278+ { /* 隧道名称 */ }
279+ < div className = "col-span-2" >
280+ < Input
281+ placeholder = "隧道名称"
282+ value = { rule . name }
283+ onValueChange = { ( value ) => updateRule ( rule . id , 'name' , value ) }
284+ size = "sm"
285+ variant = "bordered"
286+ />
287+ </ div >
288+
289+ { /* 隧道URL */ }
290+ < div className = "col-span-4" >
291+ < Input
292+ placeholder = "<core>://<tunnel_addr>/<target_addr>"
293+ value = { rule . url }
294+ onValueChange = { ( value ) => updateRule ( rule . id , 'url' , value ) }
295+ size = "sm"
296+ variant = "bordered"
297+ className = "font-mono"
298+ />
299+ </ div >
300+ </ div >
301+ < Button
302+ isIconOnly
303+ size = "sm"
304+ color = "danger"
305+ variant = "light"
306+ onClick = { ( ) => removeRule ( rule . id ) }
307+ >
308+ < FontAwesomeIcon icon = { faTrash } className = "text-xs" />
309+ </ Button >
310+ </ div >
311+ </ ListboxItem >
312+ ) ) }
313+ </ Listbox >
314+ </ div >
315+ ) }
316+ </ div >
317+ </ div >
227318 ) }
228319 </ ModalBody >
229320 < ModalFooter >
@@ -242,9 +333,13 @@ export default function ManualCreateTunnelModal({
242333 color = "primary"
243334 onPress = { handleSubmit }
244335 isLoading = { submitting }
245- isDisabled = { loading }
336+ isDisabled = { loading || tunnelRules . length === 0 }
337+ startContent = { ! submitting ? < FontAwesomeIcon icon = { faHammer } /> : null }
246338 >
247- 创建实例
339+ { submitting
340+ ? '创建中...'
341+ : `批量创建 (${ tunnelRules . length } )`
342+ }
248343 </ Button >
249344 </ ModalFooter >
250345 </ >
0 commit comments