Skip to content

Commit 78b1d8d

Browse files
committed
feat: 手搓实例改为可批量手搓
1 parent bcb0f8e commit 78b1d8d

3 files changed

Lines changed: 266 additions & 90 deletions

File tree

app/tunnels/components/manual-create-tunnel-modal.tsx

Lines changed: 185 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import {
1111
Input,
1212
Select,
1313
SelectItem,
14+
Listbox,
15+
ListboxItem,
1416
} from "@heroui/react";
1517
import { 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";
1719
import { addToast } from "@heroui/toast";
1820
import { 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+
2534
interface 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
</>

internal/api/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ func (r *Router) registerRoutes() {
137137
r.router.HandleFunc("/api/tunnels/batch", r.tunnelHandler.HandleBatchDeleteTunnels).Methods("DELETE")
138138
r.router.HandleFunc("/api/tunnels/batch/action", r.tunnelHandler.HandleBatchActionTunnels).Methods("POST")
139139
r.router.HandleFunc("/api/tunnels/quick", r.tunnelHandler.HandleQuickCreateTunnel).Methods("POST")
140+
r.router.HandleFunc("/api/tunnels/quick-batch", r.tunnelHandler.HandleQuickBatchCreateTunnel).Methods("POST")
140141
r.router.HandleFunc("/api/tunnels/template", r.tunnelHandler.HandleTemplateCreate).Methods("POST")
141142
r.router.HandleFunc("/api/tunnels", r.tunnelHandler.HandlePatchTunnels).Methods("PATCH")
142143
r.router.HandleFunc("/api/tunnels/{id}", r.tunnelHandler.HandlePatchTunnels).Methods("PATCH")

0 commit comments

Comments
 (0)