BirkhoffLee commited on
Commit
cf2569a
·
unverified ·
1 Parent(s): 4c031a8

feat: 增加了静态页面用于生成订阅 URL

Browse files
Files changed (4) hide show
  1. .gitignore +1 -0
  2. app/main.py +54 -3
  3. app/static/index.html +466 -0
  4. entrypoint.sh +1 -1
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ uv.lock
app/main.py CHANGED
@@ -1,8 +1,59 @@
 
 
 
1
  from fastapi import FastAPI
 
 
2
 
3
  app = FastAPI()
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- @app.get("/")
7
- def root():
8
- return {"status": "ok"}
 
1
+ from pathlib import Path
2
+ from urllib.parse import urlencode, quote
3
+
4
  from fastapi import FastAPI
5
+ from fastapi.responses import HTMLResponse
6
+ from pydantic import BaseModel
7
 
8
  app = FastAPI()
9
 
10
+ STATIC_DIR = Path(__file__).parent / "static"
11
+
12
+
13
+ class ConvertRequest(BaseModel):
14
+ # 必填
15
+ target: str
16
+ url: str
17
+ # 可选文本
18
+ config: str | None = None
19
+ filename: str | None = None
20
+ include: str | None = None
21
+ exclude: str | None = None
22
+ group: str | None = None
23
+ ver: str | None = None
24
+ # bool 开关(None = 不传,由 subconverter 使用默认值)
25
+ emoji: bool | None = None
26
+ udp: bool | None = None
27
+ tfo: bool | None = None
28
+ sort: bool | None = None
29
+ fdn: bool | None = None
30
+ expand: bool | None = None
31
+ append_type: bool | None = None
32
+ list: bool | None = None
33
+ new_name: bool | None = None
34
+ scv: bool | None = None
35
+ append_info: bool | None = None
36
+
37
+
38
+ @app.get("/", response_class=HTMLResponse)
39
+ def index():
40
+ return HTMLResponse(content=(STATIC_DIR / "index.html").read_text())
41
+
42
+
43
+ @app.post("/api/convert")
44
+ def convert(req: ConvertRequest):
45
+ # 需要 URL 编码的字段
46
+ encode_fields = {"url", "config", "include", "exclude"}
47
+
48
+ params: list[tuple[str, str]] = []
49
+ for field, value in req.model_dump().items():
50
+ if value is None:
51
+ continue
52
+ if isinstance(value, bool):
53
+ params.append((field, "true" if value else "false"))
54
+ elif field in encode_fields:
55
+ params.append((field, quote(str(value), safe="")))
56
+ else:
57
+ params.append((field, str(value)))
58
 
59
+ return {"url": "/sub?" + "&".join(f"{k}={v}" for k, v in params)}
 
 
app/static/index.html ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Sublink — 订阅转换</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
12
+ background: #0f1117;
13
+ color: #e2e8f0;
14
+ min-height: 100vh;
15
+ display: flex;
16
+ align-items: flex-start;
17
+ justify-content: center;
18
+ padding: 2rem 1rem;
19
+ }
20
+
21
+ .container {
22
+ width: 100%;
23
+ max-width: 680px;
24
+ }
25
+
26
+ h1 {
27
+ font-size: 1.5rem;
28
+ font-weight: 600;
29
+ margin-bottom: 0.25rem;
30
+ color: #f8fafc;
31
+ }
32
+
33
+ .subtitle {
34
+ font-size: 0.875rem;
35
+ color: #64748b;
36
+ margin-bottom: 2rem;
37
+ }
38
+
39
+ .card {
40
+ background: #1e2130;
41
+ border: 1px solid #2d3348;
42
+ border-radius: 12px;
43
+ padding: 1.5rem;
44
+ }
45
+
46
+ .field {
47
+ margin-bottom: 1.25rem;
48
+ }
49
+
50
+ label {
51
+ display: block;
52
+ font-size: 0.8125rem;
53
+ font-weight: 500;
54
+ color: #94a3b8;
55
+ margin-bottom: 0.4rem;
56
+ text-transform: uppercase;
57
+ letter-spacing: 0.04em;
58
+ }
59
+
60
+ textarea, select, input[type="text"] {
61
+ width: 100%;
62
+ background: #131620;
63
+ border: 1px solid #2d3348;
64
+ border-radius: 8px;
65
+ color: #e2e8f0;
66
+ font-size: 0.9rem;
67
+ padding: 0.625rem 0.75rem;
68
+ outline: none;
69
+ transition: border-color 0.15s;
70
+ font-family: inherit;
71
+ }
72
+
73
+ textarea:focus, select:focus, input[type="text"]:focus {
74
+ border-color: #6366f1;
75
+ }
76
+
77
+ textarea {
78
+ resize: vertical;
79
+ min-height: 80px;
80
+ }
81
+
82
+ select {
83
+ cursor: pointer;
84
+ appearance: none;
85
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
86
+ background-repeat: no-repeat;
87
+ background-position: right 0.75rem center;
88
+ padding-right: 2rem;
89
+ }
90
+
91
+ select option { background: #1e2130; }
92
+
93
+ /* 高级选项折叠 */
94
+ .advanced-toggle {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 0.5rem;
98
+ cursor: pointer;
99
+ font-size: 0.8125rem;
100
+ color: #64748b;
101
+ user-select: none;
102
+ margin-bottom: 1rem;
103
+ border: none;
104
+ background: none;
105
+ padding: 0;
106
+ }
107
+
108
+ .advanced-toggle:hover { color: #94a3b8; }
109
+
110
+ .toggle-icon {
111
+ display: inline-block;
112
+ transition: transform 0.2s;
113
+ font-style: normal;
114
+ }
115
+
116
+ .toggle-icon.open { transform: rotate(90deg); }
117
+
118
+ #advanced-section { display: none; }
119
+ #advanced-section.open { display: block; }
120
+
121
+ /* Bool 开关行 */
122
+ .bool-grid {
123
+ display: flex;
124
+ flex-wrap: wrap;
125
+ gap: 0.5rem;
126
+ margin-top: 0.4rem;
127
+ }
128
+
129
+ .bool-item {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 0.375rem;
133
+ background: #131620;
134
+ border: 1px solid #2d3348;
135
+ border-radius: 6px;
136
+ padding: 0.3rem 0.6rem;
137
+ cursor: pointer;
138
+ font-size: 0.8125rem;
139
+ color: #94a3b8;
140
+ transition: border-color 0.15s, color 0.15s;
141
+ }
142
+
143
+ .bool-item:hover { border-color: #6366f1; color: #e2e8f0; }
144
+
145
+ .bool-item input[type="checkbox"] {
146
+ width: auto;
147
+ accent-color: #6366f1;
148
+ cursor: pointer;
149
+ }
150
+
151
+ /* 两列布局 */
152
+ .row-2 {
153
+ display: grid;
154
+ grid-template-columns: 1fr 1fr;
155
+ gap: 1rem;
156
+ }
157
+
158
+ /* 按钮 */
159
+ .btn {
160
+ display: inline-flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ gap: 0.4rem;
164
+ border: none;
165
+ border-radius: 8px;
166
+ font-size: 0.9rem;
167
+ font-weight: 500;
168
+ cursor: pointer;
169
+ transition: opacity 0.15s, background 0.15s;
170
+ font-family: inherit;
171
+ }
172
+
173
+ .btn:hover { opacity: 0.85; }
174
+ .btn:active { opacity: 0.7; }
175
+
176
+ .btn-primary {
177
+ background: #6366f1;
178
+ color: #fff;
179
+ padding: 0.65rem 1.5rem;
180
+ width: 100%;
181
+ }
182
+
183
+ .btn-sm {
184
+ background: #2d3348;
185
+ color: #e2e8f0;
186
+ padding: 0.4rem 0.85rem;
187
+ font-size: 0.8rem;
188
+ }
189
+
190
+ /* 结果区域 */
191
+ #result-section {
192
+ margin-top: 1.25rem;
193
+ display: none;
194
+ }
195
+
196
+ #result-section.show { display: block; }
197
+
198
+ .result-box {
199
+ background: #131620;
200
+ border: 1px solid #2d3348;
201
+ border-radius: 8px;
202
+ padding: 0.75rem 1rem;
203
+ word-break: break-all;
204
+ font-size: 0.8375rem;
205
+ color: #a5b4fc;
206
+ line-height: 1.5;
207
+ margin-bottom: 0.75rem;
208
+ }
209
+
210
+ .result-actions {
211
+ display: flex;
212
+ gap: 0.5rem;
213
+ }
214
+
215
+ /* 错误提示 */
216
+ #error-msg {
217
+ display: none;
218
+ background: #3b1f1f;
219
+ border: 1px solid #7f1d1d;
220
+ color: #fca5a5;
221
+ border-radius: 8px;
222
+ padding: 0.6rem 0.9rem;
223
+ font-size: 0.8375rem;
224
+ margin-top: 0.75rem;
225
+ }
226
+
227
+ #error-msg.show { display: block; }
228
+
229
+ /* Toast */
230
+ #toast {
231
+ position: fixed;
232
+ bottom: 1.5rem;
233
+ right: 1.5rem;
234
+ background: #22c55e;
235
+ color: #fff;
236
+ padding: 0.5rem 1rem;
237
+ border-radius: 8px;
238
+ font-size: 0.875rem;
239
+ opacity: 0;
240
+ transition: opacity 0.3s;
241
+ pointer-events: none;
242
+ }
243
+
244
+ #toast.show { opacity: 1; }
245
+ </style>
246
+ </head>
247
+ <body>
248
+ <div class="container">
249
+ <h1>Sublink</h1>
250
+ <p class="subtitle">订阅转换工具</p>
251
+
252
+ <div class="card">
253
+ <!-- 订阅链接 -->
254
+ <div class="field">
255
+ <label>订阅链接 <span style="color:#6366f1">*</span></label>
256
+ <textarea id="url" placeholder="输入订阅链接,多个链接用 | 分隔"></textarea>
257
+ </div>
258
+
259
+ <!-- 目标格式 -->
260
+ <div class="field">
261
+ <label>目标格式 <span style="color:#6366f1">*</span></label>
262
+ <select id="target-select">
263
+ <option value="clash">Clash</option>
264
+ <option value="surge4">Surge 4</option>
265
+ <option value="quanx">QuantumultX</option>
266
+ <option value="loon">Loon</option>
267
+ <option value="v2ray">V2Ray</option>
268
+ <option value="ss">SS</option>
269
+ <option value="ssr">SSR</option>
270
+ <option value="trojan">Trojan</option>
271
+ <option value="mixed">Mixed</option>
272
+ <option value="clash-list">Node List (Clash)</option>
273
+ </select>
274
+ </div>
275
+
276
+ <!-- 高级选项折叠 -->
277
+ <button class="advanced-toggle" type="button" id="advanced-toggle">
278
+ <i class="toggle-icon" id="toggle-icon">▶</i>
279
+ 高级选项
280
+ </button>
281
+
282
+ <div id="advanced-section">
283
+ <!-- 外部配置 -->
284
+ <div class="field">
285
+ <label>外部配置 (config URL)</label>
286
+ <input type="text" id="config" placeholder="留空使用 subconverter 默认配置">
287
+ </div>
288
+
289
+ <!-- 文件名 -->
290
+ <div class="field">
291
+ <label>文件名 (filename)</label>
292
+ <input type="text" id="filename" placeholder="下载时的文件名">
293
+ </div>
294
+
295
+ <!-- 包含 / 排除 -->
296
+ <div class="row-2">
297
+ <div class="field">
298
+ <label>包含节点 (include)</label>
299
+ <input type="text" id="include" placeholder="正则表达式">
300
+ </div>
301
+ <div class="field">
302
+ <label>排除节点 (exclude)</label>
303
+ <input type="text" id="exclude" placeholder="正则表达式">
304
+ </div>
305
+ </div>
306
+
307
+ <!-- 分组 -->
308
+ <div class="field">
309
+ <label>代理分组 (group)</label>
310
+ <input type="text" id="group" placeholder="自定义分组名">
311
+ </div>
312
+
313
+ <!-- Bool 开关 -->
314
+ <div class="field">
315
+ <label>开关选项</label>
316
+ <div class="bool-grid">
317
+ <label class="bool-item"><input type="checkbox" id="emoji" checked> emoji</label>
318
+ <label class="bool-item"><input type="checkbox" id="udp"> udp</label>
319
+ <label class="bool-item"><input type="checkbox" id="tfo"> tfo</label>
320
+ <label class="bool-item"><input type="checkbox" id="sort"> sort</label>
321
+ <label class="bool-item"><input type="checkbox" id="fdn"> fdn</label>
322
+ <label class="bool-item"><input type="checkbox" id="expand" checked> expand</label>
323
+ <label class="bool-item"><input type="checkbox" id="append_type"> append_type</label>
324
+ <label class="bool-item"><input type="checkbox" id="scv"> scv</label>
325
+ <label class="bool-item"><input type="checkbox" id="append_info" checked> append_info</label>
326
+ <label class="bool-item"><input type="checkbox" id="new_name" checked> new_name</label>
327
+ </div>
328
+ </div>
329
+ </div>
330
+
331
+ <!-- 生成按钮 -->
332
+ <button class="btn btn-primary" id="gen-btn" type="button">生成订阅链接</button>
333
+
334
+ <!-- 错误 -->
335
+ <div id="error-msg"></div>
336
+
337
+ <!-- 结果 -->
338
+ <div id="result-section">
339
+ <div class="result-box" id="result-url"></div>
340
+ <div class="result-actions">
341
+ <button class="btn btn-sm" id="copy-btn" type="button">复制</button>
342
+ <button class="btn btn-sm" id="open-btn" type="button">在新标签打开</button>
343
+ </div>
344
+ </div>
345
+ </div>
346
+ </div>
347
+
348
+ <div id="toast">已复制</div>
349
+
350
+ <script>
351
+ // 高级选项折叠/展开
352
+ const advancedToggle = document.getElementById('advanced-toggle');
353
+ const advancedSection = document.getElementById('advanced-section');
354
+ const toggleIcon = document.getElementById('toggle-icon');
355
+
356
+ advancedToggle.addEventListener('click', () => {
357
+ const open = advancedSection.classList.toggle('open');
358
+ toggleIcon.classList.toggle('open', open);
359
+ toggleIcon.textContent = open ? '▼' : '▶';
360
+ });
361
+
362
+ // 生成链接
363
+ document.getElementById('gen-btn').addEventListener('click', async () => {
364
+ const url = document.getElementById('url').value.trim();
365
+ if (!url) { showError('请填写订阅链接'); return; }
366
+
367
+ const targetVal = document.getElementById('target-select').value;
368
+
369
+ // target 映射
370
+ const targetMap = {
371
+ 'clash': { target: 'clash' },
372
+ 'surge4': { target: 'surge', ver: '4' },
373
+ 'quanx': { target: 'quanx' },
374
+ 'loon': { target: 'loon' },
375
+ 'v2ray': { target: 'v2ray' },
376
+ 'ss': { target: 'ss' },
377
+ 'ssr': { target: 'ssr' },
378
+ 'trojan': { target: 'trojan' },
379
+ 'mixed': { target: 'mixed' },
380
+ 'clash-list': { target: 'clash', list: true },
381
+ };
382
+
383
+ const extra = targetMap[targetVal] || { target: targetVal };
384
+
385
+ // 读取 bool 开关(仅将被勾选的 checkbox 传给后端)
386
+ const boolFields = ['emoji','udp','tfo','sort','fdn','expand','append_type','scv','append_info','new_name'];
387
+ const boolValues = {};
388
+ boolFields.forEach(id => {
389
+ const el = document.getElementById(id);
390
+ // 只把勾选状态传递(后端处理 None = 不传)
391
+ boolValues[id] = el.checked;
392
+ });
393
+
394
+ // list 来自 extra(clash-list 模式)
395
+ if (extra.list !== undefined) {
396
+ boolValues.list = extra.list;
397
+ delete extra.list;
398
+ }
399
+
400
+ const body = {
401
+ url,
402
+ ...extra,
403
+ config: document.getElementById('config').value.trim() || null,
404
+ filename: document.getElementById('filename').value.trim() || null,
405
+ include: document.getElementById('include').value.trim() || null,
406
+ exclude: document.getElementById('exclude').value.trim() || null,
407
+ group: document.getElementById('group').value.trim() || null,
408
+ ...boolValues,
409
+ };
410
+
411
+ // 去掉 undefined/null 的 bool(后端会过滤 None,但前端先清理)
412
+ // bool 字段全部传,后端自己判断
413
+
414
+ hideError();
415
+
416
+ try {
417
+ const res = await fetch('/api/convert', {
418
+ method: 'POST',
419
+ headers: { 'Content-Type': 'application/json' },
420
+ body: JSON.stringify(body),
421
+ });
422
+
423
+ if (!res.ok) {
424
+ const err = await res.json().catch(() => ({}));
425
+ showError(err.detail || `请求失败 (${res.status})`);
426
+ return;
427
+ }
428
+
429
+ const data = await res.json();
430
+ const fullUrl = window.location.origin + data.url;
431
+ document.getElementById('result-url').textContent = fullUrl;
432
+ document.getElementById('result-section').classList.add('show');
433
+
434
+ // 绑定按钮(每次生成都重新绑,避免重复监听器)
435
+ const copyBtn = document.getElementById('copy-btn');
436
+ const openBtn = document.getElementById('open-btn');
437
+
438
+ copyBtn.onclick = () => {
439
+ navigator.clipboard.writeText(fullUrl).then(() => showToast());
440
+ };
441
+
442
+ openBtn.onclick = () => window.open(fullUrl, '_blank');
443
+
444
+ } catch (e) {
445
+ showError('网络错误:' + e.message);
446
+ }
447
+ });
448
+
449
+ function showError(msg) {
450
+ const el = document.getElementById('error-msg');
451
+ el.textContent = msg;
452
+ el.classList.add('show');
453
+ }
454
+
455
+ function hideError() {
456
+ document.getElementById('error-msg').classList.remove('show');
457
+ }
458
+
459
+ function showToast() {
460
+ const t = document.getElementById('toast');
461
+ t.classList.add('show');
462
+ setTimeout(() => t.classList.remove('show'), 2000);
463
+ }
464
+ </script>
465
+ </body>
466
+ </html>
entrypoint.sh CHANGED
@@ -5,7 +5,7 @@ set -e
5
  /opt/subconverter/subconverter &
6
 
7
  # Start FastAPI via uv
8
- cd /app && uv run uvicorn main:app --host 127.0.0.1 --port 8000 &
9
 
10
  # Start Caddy (foreground, as PID 1 child)
11
  exec caddy run --config /etc/caddy/Caddyfile
 
5
  /opt/subconverter/subconverter &
6
 
7
  # Start FastAPI via uv
8
+ cd /app && uv run python -m uvicorn main:app --host 127.0.0.1 --port 8000 &
9
 
10
  # Start Caddy (foreground, as PID 1 child)
11
  exec caddy run --config /etc/caddy/Caddyfile