chatyou commited on
Commit
452c6b0
·
verified ·
1 Parent(s): e6ba9ec

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +748 -129
app.py CHANGED
@@ -1,138 +1,757 @@
1
- <div class="api-docs">
2
- <h3>📚 API 使用说明 (JavaScript 示例)</h3>
3
- <p style="color: #334155; margin-bottom: 1rem;">所有请求均需在 Hugging Face Space 内调用(同源),无需额外认证。</p>
4
-
5
- <!-- 上传文件 -->
6
- <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #3b82f6;">
7
- <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">📤 上传文件</h4>
8
- <p><code>POST /upload</code></p>
9
- <p><strong>请求格式:</strong> <code>multipart/form-data</code></p>
10
- <p><strong>参数:</strong></p>
11
- <ul style="margin-left: 1.5rem; margin-bottom: 0.5rem;">
12
- <li><code>file</code> (必填) - 要上传的文件</li>
13
- <li><code>dir</code> (可选) - 目标目录路径,例如 <code>"images/"</code> 或 <code>"logs/2026/"</code>。留空则上传到根目录。</li>
14
- </ul>
15
- <p><strong>成功响应:</strong> <code>{"success": true, "filename": "远程完整路径"}</code></p>
16
- <p><strong>失败响应:</strong> <code>{"error": "错误信息"}</code> (HTTP 4xx/5xx)</p>
17
- <pre style="background: #1e293b; color: #b9e6f0; padding: 1rem; border-radius: 12px; overflow-x: auto; font-family: 'JetBrains Mono', monospace; font-size: 0.9rem;"><code>// 使用 FormData 上传
18
- const fileInput = document.getElementById('fileInput'); // 文件选择 input
19
- const file = fileInput.files[0];
20
- const dir = "images/"; // 可选目录
21
-
22
- const formData = new FormData();
23
- formData.append('file', file);
24
- formData.append('dir', dir);
25
-
26
- fetch('/upload', {
27
- method: 'POST',
28
- body: formData
29
- })
30
- .then(res => res.json())
31
- .then(data => {
32
- if (data.error) throw new Error(data.error);
33
- console.log('上传成功:', data.filename);
34
- })
35
- .catch(err => console.error('上传失败:', err.message));</code></pre>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  </div>
 
 
37
 
38
- <!-- 列出文件 -->
39
- <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #3b82f6;">
40
- <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">📋 列出文件</h4>
41
- <p><code>GET /list?dir=...</code></p>
42
- <p><strong>查询参数:</strong></p>
43
- <ul style="margin-left: 1.5rem; margin-bottom: 0.5rem;">
44
- <li><code>dir</code> (可选) - 目录路径,例如 <code>"images/"</code>。留空则列出根目录下的第一层内容。</li>
45
- </ul>
46
- <p><strong>成功响应:</strong> 字符串数组,每个元素是文件或目录的完整路径(目录路径以 <code>/</code> 结尾)。例如:<code>["file.txt", "images/", "logs/"]</code></p>
47
- <p><strong>失败响应:</strong> <code>{"error": "错误信息"}</code> (HTTP 5xx)</p>
48
- <pre style="background: #1e293b; color: #b9e6f0; padding: 1rem; border-radius: 12px; overflow-x: auto;"><code>// 列出根目录内容
49
- fetch('/list')
50
- .then(res => res.json())
51
- .then(files => {
52
- files.forEach(path => {
53
- if (path.endsWith('/')) {
54
- console.log('目录:', path);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  } else {
56
- console.log('文件:', path);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  }
58
  });
59
- });
60
 
61
- // 列出指定目录(如 images/)内容
62
- const dir = "images/";
63
- fetch(`/list?dir=${encodeURIComponent(dir)}`)
64
- .then(res => res.json())
65
- .then(files => console.log(files));</code></pre>
66
- </div>
67
 
68
- <!-- 下载文件 -->
69
- <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #3b82f6;">
70
- <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">📥 下载文件</h4>
71
- <p><code>GET /file/&lt;filename&gt;</code></p>
72
- <p><strong>路径参数:</strong></p>
73
- <ul style="margin-left: 1.5rem; margin-bottom: 0.5rem;">
74
- <li><code>filename</code> - 文件的完整路径(可包含目录),例如 <code>"images/avatar.png"</code>。</li>
75
- </ul>
76
- <p><strong>成功响应:</strong> 文件内容(作为附件下载)。</p>
77
- <p><strong>失败响应:</strong> <code>{"error": "错误信息"}</code> (HTTP 4xx/5xx)</p>
78
- <pre style="background: #1e293b; color: #b9e6f0; padding: 1rem; border-radius: 12px; overflow-x: auto;"><code>// 触发浏览器下载
79
- const filename = "images/avatar.png";
80
- window.location.href = `/file/${encodeURIComponent(filename)}`;
81
-
82
- // 或者用 fetch 获取文件 Blob
83
- fetch(`/file/${encodeURIComponent(filename)}`)
84
- .then(res => {
85
- if (!res.ok) throw new Error('文件不存在');
86
- return res.blob();
87
- })
88
- .then(blob => {
89
- const url = URL.createObjectURL(blob);
90
- const a = document.createElement('a');
91
- a.href = url;
92
- a.download = filename.split('/').pop(); // 提取文件名
93
- a.click();
94
- })
95
- .catch(err => console.error('下��失败:', err.message));</code></pre>
96
- </div>
97
 
98
- <!-- 检查文件存在性 -->
99
- <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #3b82f6;">
100
- <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">🔍 检查文件是否存在</h4>
101
- <p><code>HEAD /file/&lt;filename&gt;</code></p>
102
- <p><strong>路径参数:</strong> 同下载接口的 <code>filename</code>。</p>
103
- <p><strong>成功响应:</strong> HTTP 200,无响应体。</p>
104
- <p><strong>失败响应:</strong> HTTP 404 并返回 JSON 错误信息。</p>
105
- <pre style="background: #1e293b; color: #b9e6f0; padding: 1rem; border-radius: 12px; overflow-x: auto;"><code>const filename = "config.json";
106
-
107
- fetch(`/file/${encodeURIComponent(filename)}`, { method: 'HEAD' })
108
- .then(res => {
109
- if (res.ok) {
110
- console.log('文件存在');
111
- } else {
112
- return res.json().then(err => { throw new Error(err.error); });
113
- }
114
- })
115
- .catch(err => console.error('检查失败:', err.message));</code></pre>
116
- </div>
117
 
118
- <!-- 删除文件 -->
119
- <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #ef4444;">
120
- <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">🗑️ 删除文件</h4>
121
- <p><code>DELETE /delete/&lt;filename&gt;</code></p>
122
- <p><strong>路径参数:</strong> 同下载接口的 <code>filename</code>。</p>
123
- <p><strong>成功响应:</strong> <code>{"success": true}</code></p>
124
- <p><strong>失败响应:</strong> <code>{"error": "错误信息"}</code> (HTTP 4xx/5xx)</p>
125
- <pre style="background: #1e293b; color: #b9e6f0; padding: 1rem; border-radius: 12px; overflow-x: auto;"><code>const filename = "temp.log";
126
-
127
- fetch(`/delete/${encodeURIComponent(filename)}`, { method: 'DELETE' })
128
- .then(res => res.json())
129
- .then(data => {
130
- if (data.success) {
131
- console.log('删除成功');
132
- } else {
133
- throw new Error(data.error);
134
- }
135
- })
136
- .catch(err => console.error('删除失败:', err.message));</code></pre>
137
- </div>
138
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ from flask import Flask, request, jsonify, send_file
4
+ from huggingface_hub import (
5
+ batch_bucket_files,
6
+ download_bucket_files,
7
+ list_bucket_tree
8
+ )
9
+
10
+ app = Flask(__name__)
11
+
12
+ HF_TOKEN = os.environ.get("HF_TOKEN")
13
+ if not HF_TOKEN:
14
+ raise ValueError("HF_TOKEN environment variable not set. Please add it to Space Secrets.")
15
+
16
+ BUCKET_ID = "nagose/filebed"
17
+
18
+ HTML_CONTENT = """<!DOCTYPE html>
19
+ <html lang="zh-CN">
20
+ <head>
21
+ <meta charset="UTF-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
23
+ <title>HF Bucket 文件管理器 · 支持目录导航</title>
24
+ <style>
25
+ * { margin: 0; padding: 0; box-sizing: border-box; }
26
+ body {
27
+ font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
28
+ background: linear-gradient(145deg, #f0f4fa 0%, #e6ecf4 100%);
29
+ min-height: 100vh;
30
+ padding: 2rem 1rem;
31
+ display: flex;
32
+ justify-content: center;
33
+ align-items: center;
34
+ }
35
+ .container {
36
+ max-width: 1200px;
37
+ width: 100%;
38
+ margin: 0 auto;
39
+ }
40
+ .grid {
41
+ display: grid;
42
+ grid-template-columns: 1fr 1fr;
43
+ gap: 1.5rem;
44
+ }
45
+ .card {
46
+ background: rgba(255, 255, 255, 0.8);
47
+ backdrop-filter: blur(10px);
48
+ -webkit-backdrop-filter: blur(10px);
49
+ border: 1px solid rgba(255, 255, 255, 0.5);
50
+ border-radius: 28px;
51
+ box-shadow: 0 20px 40px -12px rgba(0, 20, 40, 0.25);
52
+ padding: 1.8rem;
53
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
54
+ }
55
+ .card:hover {
56
+ transform: translateY(-4px);
57
+ box-shadow: 0 30px 50px -15px rgba(0, 30, 60, 0.3);
58
+ }
59
+ h2 {
60
+ font-size: 1.8rem;
61
+ font-weight: 600;
62
+ margin-bottom: 1.5rem;
63
+ color: #1a2b3c;
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 0.5rem;
67
+ }
68
+ .upload-area {
69
+ display: flex;
70
+ flex-direction: column;
71
+ gap: 1.2rem;
72
+ margin-bottom: 2rem;
73
+ }
74
+ .file-label {
75
+ background: #f1f5f9;
76
+ border-radius: 60px;
77
+ padding: 0.8rem 1.5rem;
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 0.8rem;
81
+ border: 2px dashed #b0c4de;
82
+ cursor: pointer;
83
+ transition: all 0.2s;
84
+ width: fit-content;
85
+ font-weight: 500;
86
+ color: #1e293b;
87
+ }
88
+ .file-label:hover {
89
+ background: #e2eaf3;
90
+ border-color: #3b82f6;
91
+ }
92
+ input[type="file"] { display: none; }
93
+ .dir-input {
94
+ padding: 0.8rem 1.2rem;
95
+ border-radius: 40px;
96
+ border: 1px solid #cbd5e1;
97
+ font-size: 1rem;
98
+ background: white;
99
+ width: 100%;
100
+ }
101
+ .button {
102
+ background: #3b82f6;
103
+ border: none;
104
+ color: white;
105
+ font-weight: 600;
106
+ padding: 0.8rem 2rem;
107
+ border-radius: 40px;
108
+ font-size: 1rem;
109
+ cursor: pointer;
110
+ box-shadow: 0 6px 14px rgba(59, 130, 246, 0.3);
111
+ transition: all 0.15s ease;
112
+ width: fit-content;
113
+ border: 1px solid rgba(255,255,255,0.2);
114
+ }
115
+ .button:hover {
116
+ background: #2563eb;
117
+ transform: scale(1.02);
118
+ box-shadow: 0 10px 20px rgba(37, 99, 235, 0.4);
119
+ }
120
+ .button.secondary {
121
+ background: #64748b;
122
+ box-shadow: 0 6px 14px rgba(100, 116, 139, 0.3);
123
+ }
124
+ .button.secondary:hover {
125
+ background: #475569;
126
+ }
127
+ .button.danger {
128
+ background: #ef4444;
129
+ box-shadow: 0 6px 14px rgba(239, 68, 68, 0.25);
130
+ }
131
+ .button.danger:hover {
132
+ background: #dc2626;
133
+ }
134
+ .button.small {
135
+ padding: 0.4rem 1rem;
136
+ font-size: 0.9rem;
137
+ }
138
+ .log {
139
+ background: #1e293b;
140
+ color: #b9e6f0;
141
+ padding: 1.2rem;
142
+ border-radius: 24px;
143
+ font-family: 'JetBrains Mono', monospace;
144
+ font-size: 0.9rem;
145
+ border-left: 6px solid #3b82f6;
146
+ white-space: pre-wrap;
147
+ max-height: 250px;
148
+ overflow: auto;
149
+ margin-top: 1.5rem;
150
+ }
151
+ .log p { margin: 0.2rem 0; }
152
+ .timestamp { color: #94a3b8; margin-right: 0.5rem; }
153
+ .file-list {
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 0.6rem;
157
+ margin-top: 1rem;
158
+ max-height: 400px;
159
+ overflow-y: auto;
160
+ padding-right: 0.5rem;
161
+ }
162
+ .file-item {
163
+ display: flex;
164
+ justify-content: space-between;
165
+ align-items: center;
166
+ background: #ffffffd6;
167
+ backdrop-filter: blur(4px);
168
+ padding: 0.8rem 1.5rem;
169
+ border-radius: 40px;
170
+ box-shadow: 0 4px 8px rgba(0,0,0,0.02);
171
+ border: 1px solid rgba(255,255,255,0.6);
172
+ }
173
+ .file-link {
174
+ color: #1e3a8a;
175
+ font-weight: 500;
176
+ text-decoration: none;
177
+ word-break: break-all;
178
+ cursor: pointer;
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 0.5rem;
182
+ }
183
+ .file-link:hover {
184
+ text-decoration: underline;
185
+ color: #2563eb;
186
+ }
187
+ .directory-link {
188
+ color: #b45309;
189
+ font-weight: 500;
190
+ cursor: pointer;
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 0.5rem;
194
+ }
195
+ .directory-link:hover {
196
+ text-decoration: underline;
197
+ color: #d97706;
198
+ }
199
+ .empty-message {
200
+ text-align: center;
201
+ color: #64748b;
202
+ padding: 2rem;
203
+ font-style: italic;
204
+ }
205
+ .api-test {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 1.5rem;
209
+ }
210
+ .api-section {
211
+ border-top: 1px solid #cbd5e1;
212
+ padding-top: 1rem;
213
+ }
214
+ .api-row {
215
+ display: flex;
216
+ align-items: center;
217
+ gap: 1rem;
218
+ flex-wrap: wrap;
219
+ margin: 0.8rem 0;
220
+ }
221
+ .api-input {
222
+ flex: 1;
223
+ padding: 0.8rem 1.2rem;
224
+ border-radius: 40px;
225
+ border: 1px solid #cbd5e1;
226
+ font-size: 1rem;
227
+ background: white;
228
+ }
229
+ .api-result {
230
+ background: #1e293b;
231
+ color: #b9e6f0;
232
+ padding: 1rem;
233
+ border-radius: 20px;
234
+ font-family: monospace;
235
+ font-size: 0.9rem;
236
+ white-space: pre-wrap;
237
+ max-height: 200px;
238
+ overflow: auto;
239
+ }
240
+ .api-docs {
241
+ background: #f8fafc;
242
+ border-radius: 20px;
243
+ padding: 1rem;
244
+ margin-top: 1rem;
245
+ }
246
+ .api-docs h3 {
247
+ font-size: 1.2rem;
248
+ margin-bottom: 0.8rem;
249
+ color: #0f172a;
250
+ }
251
+ .api-docs ul {
252
+ list-style: none;
253
+ padding-left: 0;
254
+ }
255
+ .api-docs li {
256
+ margin: 0.8rem 0;
257
+ padding: 0.8rem;
258
+ background: white;
259
+ border-radius: 16px;
260
+ border-left: 4px solid #3b82f6;
261
+ }
262
+ .api-docs code {
263
+ background: #e2e8f0;
264
+ padding: 0.2rem 0.4rem;
265
+ border-radius: 6px;
266
+ font-family: monospace;
267
+ }
268
+ .nav-bar {
269
+ display: flex;
270
+ gap: 0.5rem;
271
+ margin-bottom: 1rem;
272
+ align-items: center;
273
+ }
274
+ .nav-bar input {
275
+ flex: 1;
276
+ }
277
+ footer {
278
+ margin-top: 2rem;
279
+ text-align: center;
280
+ color: #64748b;
281
+ font-size: 0.9rem;
282
+ }
283
+ @media (max-width: 768px) {
284
+ .grid { grid-template-columns: 1fr; }
285
+ }
286
+ </style>
287
+ </head>
288
+ <body>
289
+ <div class="container">
290
+ <h1 style="font-size: 2.5rem; margin-bottom: 2rem; color: #0f172a;">📦 HF Bucket 文件管理器 (目录导航)</h1>
291
+ <div class="grid">
292
+ <!-- 左侧:文件上传与浏览 -->
293
+ <div class="card">
294
+ <h2>📤 文件上传</h2>
295
+ <div class="upload-area">
296
+ <label for="fileInput" class="file-label" id="fileLabel">📎 选择文件</label>
297
+ <input type="file" id="fileInput">
298
+ <input type="text" id="uploadDir" class="dir-input" placeholder="目标目录 (可选,如 images/ 或 logs/2026/)" value="">
299
+ <button class="button" id="uploadBtn">⬆️ 上传到 Bucket</button>
300
+ </div>
301
+ <h2 style="margin-top: 2rem;">📁 浏览</h2>
302
+ <div class="nav-bar">
303
+ <input type="text" id="currentDir" class="dir-input" placeholder="当前目录 (留空为根目录)" value="">
304
+ <button class="button secondary small" id="listBtn">列出文件</button>
305
+ <button class="button secondary small" id="goUpBtn" title="返回上级">⬆️ 上级</button>
306
+ </div>
307
+ <div id="fileList" class="file-list">
308
+ <div class="empty-message">加载中...</div>
309
+ </div>
310
+ <div class="log" id="log">就绪</div>
311
+ </div>
312
+
313
+ <!-- 右侧:API 测试面板 + 使用说明 -->
314
+ <div class="card">
315
+ <h2>🧪 API 测试</h2>
316
+ <div class="api-test">
317
+ <div class="api-section">
318
+ <h3>📤 上传文件 (POST /upload) 支持目录</h3>
319
+ <div class="api-row">
320
+ <input type="file" id="apiFileInput" style="flex:1; padding:0.5rem;">
321
+ <span style="color:#64748b;">(留空则使用左侧文件)</span>
322
+ </div>
323
+ <div class="api-row">
324
+ <input type="text" id="apiUploadDir" class="api-input" placeholder="目标目录 (可选,如 images/)" value="">
325
+ <button class="button secondary small" id="apiUploadBtn">上传测试</button>
326
+ </div>
327
+ </div>
328
+
329
+ <div class="api-section">
330
+ <h3>📋 列出文件 (GET /list?dir=...)</h3>
331
+ <div class="api-row">
332
+ <input type="text" id="apiListDir" class="api-input" placeholder="目录 (留空为根目录)" value="">
333
+ <button class="button secondary small" id="apiListBtn">获取列表</button>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="api-section">
338
+ <h3>📥 获取文件信息 (HEAD /file/&lt;filename&gt;)</h3>
339
+ <div class="api-row">
340
+ <input type="text" class="api-input" id="apiFilename" placeholder="输入文件名(可包含目录)">
341
+ <button class="button secondary small" id="apiGetBtn">检查</button>
342
+ </div>
343
+ </div>
344
+
345
+ <div class="api-section">
346
+ <h3>🗑️ 删除文件 (DELETE /delete/&lt;filename&gt;)</h3>
347
+ <div class="api-row">
348
+ <input type="text" class="api-input" id="apiDeleteFilename" placeholder="要删除的文件名(可包含目录)">
349
+ <button class="button danger small" id="apiDeleteBtn">删除</button>
350
+ </div>
351
+ </div>
352
+
353
+ <div class="api-result" id="apiResult">点击按钮查看结果</div>
354
+
355
+ <div class="api-docs">
356
+ <h3>📚 API 使用说明 (支持目录导航)</h3>
357
+ <ul>
358
+ <li><code><strong>POST /upload</strong></code> - 上传文件到指定目录</li>
359
+ <li><code><strong>GET /list?dir=...</strong></code> - 列出指定目录下的第一层文件和目录(目录路径以 / 结尾)</li>
360
+ <li><code><strong>GET /file/&lt;filename&gt;</strong></code> - 下载文件</li>
361
+ <li><code><strong>HEAD /file/&lt;filename&gt;</strong></code> - 检查文件是否存在</li>
362
+ <li><code><strong>DELETE /delete/&lt;filename&gt;</strong></code> - 删除文件(不支持目录)</li>
363
+ </ul>
364
+ </div>
365
+ </div>
366
+ </div>
367
  </div>
368
+ <footer>Powered by Hugging Face Buckets · 支持多级目录导航</footer>
369
+ </div>
370
 
371
+ <script>
372
+ (function() {
373
+ // DOM 元素
374
+ const fileInput = document.getElementById('fileInput');
375
+ const uploadBtn = document.getElementById('uploadBtn');
376
+ const fileLabel = document.getElementById('fileLabel');
377
+ const uploadDir = document.getElementById('uploadDir');
378
+ const currentDir = document.getElementById('currentDir');
379
+ const listBtn = document.getElementById('listBtn');
380
+ const goUpBtn = document.getElementById('goUpBtn');
381
+ const logDiv = document.getElementById('log');
382
+ const fileListDiv = document.getElementById('fileList');
383
+ const apiResult = document.getElementById('apiResult');
384
+ const apiListBtn = document.getElementById('apiListBtn');
385
+ const apiListDir = document.getElementById('apiListDir');
386
+ const apiGetBtn = document.getElementById('apiGetBtn');
387
+ const apiDeleteBtn = document.getElementById('apiDeleteBtn');
388
+ const apiFilename = document.getElementById('apiFilename');
389
+ const apiDeleteFilename = document.getElementById('apiDeleteFilename');
390
+ const apiFileInput = document.getElementById('apiFileInput');
391
+ const apiUploadDir = document.getElementById('apiUploadDir');
392
+ const apiUploadBtn = document.getElementById('apiUploadBtn');
393
+
394
+ // 辅助函数:添加日志
395
+ function addLog(msg, isErr = false) {
396
+ const p = document.createElement('p');
397
+ const ts = new Date().toLocaleTimeString();
398
+ p.innerHTML = '<span class="timestamp">[' + ts + ']</span> ' + (isErr ? '❌ ' : '') + msg;
399
+ if (isErr) p.style.color = '#f87171';
400
+ logDiv.appendChild(p);
401
+ logDiv.scrollTop = logDiv.scrollHeight;
402
+ }
403
+ function clearLog() { logDiv.innerHTML = ''; }
404
+
405
+ // 显示 API 结果
406
+ function showApiResult(data, isError = false) {
407
+ if (typeof data === 'object') {
408
+ apiResult.textContent = JSON.stringify(data, null, 2);
409
  } else {
410
+ apiResult.textContent = data;
411
+ }
412
+ if (isError) apiResult.style.color = '#f87171';
413
+ else apiResult.style.color = '#b9e6f0';
414
+ }
415
+
416
+ // 构建远程路径:目录 + 文件名
417
+ function buildRemotePath(dir, filename) {
418
+ dir = dir.trim();
419
+ if (!dir) return filename;
420
+ if (!dir.endsWith('/')) dir += '/';
421
+ if (dir.startsWith('/')) dir = dir.substring(1);
422
+ return dir + filename;
423
+ }
424
+
425
+ // 获取用于上传的文件
426
+ function getUploadFile() {
427
+ if (apiFileInput.files.length > 0) {
428
+ return apiFileInput.files[0];
429
+ } else if (fileInput.files.length > 0) {
430
+ return fileInput.files[0];
431
+ }
432
+ return null;
433
+ }
434
+
435
+ // 加载文件列表
436
+ async function loadList(dir) {
437
+ dir = dir || '';
438
+ currentDir.value = dir; // 同步输入框
439
+ try {
440
+ fileListDiv.innerHTML = '<div class="empty-message">加载中...</div>';
441
+ let url = '/list';
442
+ if (dir) url += '?dir=' + encodeURIComponent(dir);
443
+ const res = await fetch(url);
444
+ if (!res.ok) {
445
+ const err = await res.text();
446
+ throw new Error(`HTTP ${res.status}: ${err}`);
447
+ }
448
+ const items = await res.json(); // 数组,每个元素是路径字符串
449
+ if (items.length === 0) {
450
+ fileListDiv.innerHTML = '<div class="empty-message">📭 空目录</div>';
451
+ } else {
452
+ // 根据路径是否以 / 结尾判断是目录还是文件
453
+ const html = items.map(path => {
454
+ const isDir = path.endsWith('/');
455
+ if (isDir) {
456
+ const dirName = path.slice(0, -1); // 去掉末尾斜杠,用于显示
457
+ return `
458
+ <div class="file-item">
459
+ <span class="directory-link" onclick="navigateTo('${path}')">
460
+ 📁 ${dirName}
461
+ </span>
462
+ <!-- 目录暂不提供删除按钮,可后续扩展 -->
463
+ </div>
464
+ `;
465
+ } else {
466
+ // 文件:显示下载链接和删除按钮
467
+ return `
468
+ <div class="file-item">
469
+ <a href="/file/${encodeURIComponent(path)}" target="_blank" class="file-link">
470
+ 📄 ${path}
471
+ </a>
472
+ <button class="button danger small" onclick="deleteFile('${path.replace(/'/g, "\\\\'")}')">删除</button>
473
+ </div>
474
+ `;
475
+ }
476
+ }).join('');
477
+ fileListDiv.innerHTML = html;
478
+ }
479
+ } catch (err) {
480
+ fileListDiv.innerHTML = '<div class="empty-message">❌ 加载失败</div>';
481
+ addLog('列表加载错误: ' + err.message, true);
482
+ }
483
+ }
484
+
485
+ // 导航到目录
486
+ window.navigateTo = function(dirPath) {
487
+ // dirPath 以 / 结尾,例如 "subdir/"
488
+ const dir = dirPath; // 直接使用,loadList 会处理
489
+ loadList(dir);
490
+ };
491
+
492
+ // 删除文件
493
+ window.deleteFile = async function(filename) {
494
+ if (!confirm(`确定删除 ${filename} 吗?`)) return;
495
+ try {
496
+ addLog('正在删除: ' + filename);
497
+ const res = await fetch('/delete/' + encodeURIComponent(filename), { method: 'DELETE' });
498
+ const text = await res.text();
499
+ if (!res.ok) throw new Error(`删除失败 (${res.status}): ${text}`);
500
+ addLog('✅ 删除成功: ' + filename);
501
+ // 刷新当前目录
502
+ loadList(currentDir.value);
503
+ } catch (err) {
504
+ addLog(err.message, true);
505
+ }
506
+ };
507
+
508
+ // 返回上级目录
509
+ function goUp() {
510
+ let dir = currentDir.value.trim();
511
+ if (!dir) return; // 已在根目录
512
+ // 移除末尾的 /
513
+ dir = dir.endsWith('/') ? dir.slice(0, -1) : dir;
514
+ const parts = dir.split('/');
515
+ parts.pop(); // 去掉最后一级
516
+ const parent = parts.length ? parts.join('/') + '/' : '';
517
+ loadList(parent);
518
+ }
519
+
520
+ // 左侧文件选择显示
521
+ fileInput.addEventListener('change', function() {
522
+ if (fileInput.files.length > 0) {
523
+ fileLabel.textContent = '📄 ' + fileInput.files[0].name;
524
+ } else {
525
+ fileLabel.textContent = '📎 选择文件';
526
  }
527
  });
 
528
 
529
+ // 左侧上传按钮
530
+ uploadBtn.addEventListener('click', async () => {
531
+ const file = fileInput.files[0];
532
+ if (!file) { alert('请选择文件'); return; }
 
 
533
 
534
+ const dir = uploadDir.value;
535
+ const remotePath = buildRemotePath(dir, file.name);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
 
537
+ clearLog();
538
+ addLog('开始上传到: ' + remotePath);
539
+ uploadBtn.disabled = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
 
541
+ const formData = new FormData();
542
+ formData.append('file', file);
543
+ formData.append('dir', dir);
544
+
545
+ try {
546
+ const res = await fetch('/upload', { method: 'POST', body: formData });
547
+ const data = await res.json();
548
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
549
+ addLog('✅ 上传成功!路径: ' + data.filename);
550
+ fileInput.value = '';
551
+ fileLabel.textContent = '📎 选择文件';
552
+ uploadDir.value = '';
553
+ // 上传后刷新当前目录
554
+ loadList(currentDir.value);
555
+ } catch (err) {
556
+ addLog('❌ ' + err.message, true);
557
+ } finally {
558
+ uploadBtn.disabled = false;
559
+ }
560
+ });
561
+
562
+ // 列出文件按钮
563
+ listBtn.addEventListener('click', () => {
564
+ loadList(currentDir.value.trim());
565
+ });
566
+
567
+ // 返回上级按钮
568
+ goUpBtn.addEventListener('click', goUp);
569
+
570
+ // API 测试:上传文件
571
+ apiUploadBtn.addEventListener('click', async () => {
572
+ const file = getUploadFile();
573
+ if (!file) {
574
+ alert('请选择文件(在左侧或右侧上传区域选择)');
575
+ return;
576
+ }
577
+
578
+ const dir = apiUploadDir.value;
579
+ const remotePath = buildRemotePath(dir, file.name);
580
+
581
+ const formData = new FormData();
582
+ formData.append('file', file);
583
+ formData.append('dir', dir);
584
+
585
+ try {
586
+ showApiResult('正在上传到 ' + remotePath + ' ...');
587
+ const res = await fetch('/upload', { method: 'POST', body: formData });
588
+ const data = await res.json();
589
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
590
+ showApiResult(data);
591
+ loadList(currentDir.value);
592
+ } catch (err) {
593
+ showApiResult(err.message, true);
594
+ }
595
+ });
596
+
597
+ // API 测试:列出文件
598
+ apiListBtn.addEventListener('click', async () => {
599
+ const dir = apiListDir.value.trim();
600
+ try {
601
+ showApiResult('正在请求 /list?dir=' + dir + ' ...');
602
+ let url = '/list';
603
+ if (dir) url += '?dir=' + encodeURIComponent(dir);
604
+ const res = await fetch(url);
605
+ const data = await res.json();
606
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
607
+ showApiResult(data);
608
+ // 同时更新左侧浏览目录为相同目录并刷新
609
+ loadList(dir);
610
+ } catch (err) {
611
+ showApiResult(err.message, true);
612
+ }
613
+ });
614
+
615
+ // API 测试:获取文件信息
616
+ apiGetBtn.addEventListener('click', async () => {
617
+ const filename = apiFilename.value.trim();
618
+ if (!filename) { alert('请输入文件名'); return; }
619
+ try {
620
+ showApiResult('正在检查 /file/' + filename + ' ...');
621
+ const res = await fetch('/file/' + encodeURIComponent(filename), { method: 'HEAD' });
622
+ if (res.ok) {
623
+ showApiResult({ status: 'OK', message: '文件存在,可下载' });
624
+ } else {
625
+ const text = await res.text();
626
+ throw new Error(`HTTP ${res.status}: ${text}`);
627
+ }
628
+ } catch (err) {
629
+ showApiResult(err.message, true);
630
+ }
631
+ });
632
+
633
+ // API 测试:删除文件
634
+ apiDeleteBtn.addEventListener('click', async () => {
635
+ const filename = apiDeleteFilename.value.trim();
636
+ if (!filename) { alert('请输入文件名'); return; }
637
+ if (!confirm(`确定通过 API 删除 ${filename} 吗?`)) return;
638
+ try {
639
+ showApiResult('正在删除 ' + filename + ' ...');
640
+ const res = await fetch('/delete/' + encodeURIComponent(filename), { method: 'DELETE' });
641
+ const text = await res.text();
642
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`);
643
+ showApiResult({ success: true, message: '文件已删除' });
644
+ loadList(currentDir.value);
645
+ } catch (err) {
646
+ showApiResult(err.message, true);
647
+ }
648
+ });
649
+
650
+ // 初始化:加载根目录
651
+ loadList('');
652
+ })();
653
+ </script>
654
+ </body>
655
+ </html>"""
656
+
657
+
658
+ @app.route('/')
659
+ def index():
660
+ return HTML_CONTENT
661
+
662
+
663
+ @app.route('/upload', methods=['POST'])
664
+ def upload():
665
+ if 'file' not in request.files:
666
+ return jsonify({'error': 'No file part'}), 400
667
+ file = request.files['file']
668
+ if file.filename == '':
669
+ return jsonify({'error': 'No selected file'}), 400
670
+
671
+ target_dir = request.form.get('dir', '').strip()
672
+ filename = file.filename
673
+ if target_dir:
674
+ # 规范化目录路径:去除前导 '/',确保末尾有 '/'
675
+ if target_dir.startswith('/'):
676
+ target_dir = target_dir[1:]
677
+ if not target_dir.endswith('/'):
678
+ target_dir += '/'
679
+ remote_path = target_dir + filename
680
+ else:
681
+ remote_path = filename
682
+
683
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
684
+ file.save(tmp.name)
685
+ try:
686
+ batch_bucket_files(
687
+ bucket_id=BUCKET_ID,
688
+ add=[(tmp.name, remote_path)],
689
+ token=HF_TOKEN
690
+ )
691
+ except Exception as e:
692
+ return jsonify({'error': str(e)}), 500
693
+ finally:
694
+ os.unlink(tmp.name)
695
+
696
+ return jsonify({'success': True, 'filename': remote_path})
697
+
698
+
699
+ @app.route('/list')
700
+ def list_files():
701
+ """
702
+ 列出 bucket 中指定目录下的第一层内容(文件和目录)。
703
+ 若未提供 dir 参数或 dir 为空,则列出根目录下的第一层内容。
704
+ 返回的列表元素为路径字符串,目录路径以 '/' 结尾,文件路径不以 '/' 结尾。
705
+ """
706
+ dir_param = request.args.get('dir', '').strip()
707
+ try:
708
+ # 规范化目录参数
709
+ if dir_param:
710
+ if dir_param.startswith('/'):
711
+ dir_param = dir_param[1:]
712
+ if not dir_param.endswith('/'):
713
+ dir_param += '/'
714
+ # 当 dir_param 为空时,prefix='' 表示根目录
715
+ items = list_bucket_tree(
716
+ bucket_id=BUCKET_ID,
717
+ prefix=dir_param,
718
+ recursive=False, # 不递归,只列出该目录下的直接内容
719
+ token=HF_TOKEN
720
+ )
721
+ # 直接返回所有条目的 path,不区分类型(前端通过是否以 / 结尾来判断目录)
722
+ paths = [item.path for item in items]
723
+ return jsonify(paths)
724
+ except Exception as e:
725
+ return jsonify({'error': str(e)}), 500
726
+
727
+
728
+ @app.route('/file/<path:filename>')
729
+ def get_file(filename):
730
+ try:
731
+ with tempfile.TemporaryDirectory() as tmpdir:
732
+ local_path = os.path.join(tmpdir, filename)
733
+ download_bucket_files(
734
+ bucket_id=BUCKET_ID,
735
+ files=[(filename, local_path)],
736
+ token=HF_TOKEN
737
+ )
738
+ return send_file(local_path, as_attachment=True, download_name=filename)
739
+ except Exception as e:
740
+ return jsonify({'error': str(e)}), 500
741
+
742
+
743
+ @app.route('/delete/<path:filename>', methods=['DELETE'])
744
+ def delete_file(filename):
745
+ try:
746
+ batch_bucket_files(
747
+ bucket_id=BUCKET_ID,
748
+ delete=[filename],
749
+ token=HF_TOKEN
750
+ )
751
+ return jsonify({'success': True})
752
+ except Exception as e:
753
+ return jsonify({'error': str(e)}), 500
754
+
755
+
756
+ if __name__ == '__main__':
757
+ app.run(host='0.0.0.0', port=7860)