| import os |
| import tempfile |
| from flask import Flask, request, jsonify, send_file |
| from huggingface_hub import ( |
| batch_bucket_files, |
| download_bucket_files, |
| list_bucket_tree |
| ) |
|
|
| app = Flask(__name__) |
|
|
| HF_TOKEN = os.environ.get("HF_TOKEN") |
| if not HF_TOKEN: |
| raise ValueError("HF_TOKEN environment variable not set. Please add it to Space Secrets.") |
|
|
| BUCKET_ID = "nagose/filebed" |
|
|
| HTML_CONTENT = """<!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>HF Bucket 文件管理器 · 支持目录导航</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| background: linear-gradient(145deg, #f0f4fa 0%, #e6ecf4 100%); |
| min-height: 100vh; |
| padding: 2rem 1rem; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| } |
| .container { |
| max-width: 1200px; |
| width: 100%; |
| margin: 0 auto; |
| } |
| .grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 1.5rem; |
| } |
| .card { |
| background: rgba(255, 255, 255, 0.8); |
| backdrop-filter: blur(10px); |
| -webkit-backdrop-filter: blur(10px); |
| border: 1px solid rgba(255, 255, 255, 0.5); |
| border-radius: 28px; |
| box-shadow: 0 20px 40px -12px rgba(0, 20, 40, 0.25); |
| padding: 1.8rem; |
| transition: transform 0.2s ease, box-shadow 0.2s ease; |
| } |
| .card:hover { |
| transform: translateY(-4px); |
| box-shadow: 0 30px 50px -15px rgba(0, 30, 60, 0.3); |
| } |
| h2 { |
| font-size: 1.8rem; |
| font-weight: 600; |
| margin-bottom: 1.5rem; |
| color: #1a2b3c; |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| } |
| .upload-area { |
| display: flex; |
| flex-direction: column; |
| gap: 1.2rem; |
| margin-bottom: 2rem; |
| } |
| .file-label { |
| background: #f1f5f9; |
| border-radius: 60px; |
| padding: 0.8rem 1.5rem; |
| display: inline-flex; |
| align-items: center; |
| gap: 0.8rem; |
| border: 2px dashed #b0c4de; |
| cursor: pointer; |
| transition: all 0.2s; |
| width: fit-content; |
| font-weight: 500; |
| color: #1e293b; |
| } |
| .file-label:hover { |
| background: #e2eaf3; |
| border-color: #3b82f6; |
| } |
| input[type="file"] { display: none; } |
| .dir-input { |
| padding: 0.8rem 1.2rem; |
| border-radius: 40px; |
| border: 1px solid #cbd5e1; |
| font-size: 1rem; |
| background: white; |
| width: 100%; |
| } |
| .button { |
| background: #3b82f6; |
| border: none; |
| color: white; |
| font-weight: 600; |
| padding: 0.8rem 2rem; |
| border-radius: 40px; |
| font-size: 1rem; |
| cursor: pointer; |
| box-shadow: 0 6px 14px rgba(59, 130, 246, 0.3); |
| transition: all 0.15s ease; |
| width: fit-content; |
| border: 1px solid rgba(255,255,255,0.2); |
| } |
| .button:hover { |
| background: #2563eb; |
| transform: scale(1.02); |
| box-shadow: 0 10px 20px rgba(37, 99, 235, 0.4); |
| } |
| .button.secondary { |
| background: #64748b; |
| box-shadow: 0 6px 14px rgba(100, 116, 139, 0.3); |
| } |
| .button.secondary:hover { |
| background: #475569; |
| } |
| .button.danger { |
| background: #ef4444; |
| box-shadow: 0 6px 14px rgba(239, 68, 68, 0.25); |
| } |
| .button.danger:hover { |
| background: #dc2626; |
| } |
| .button.small { |
| padding: 0.4rem 1rem; |
| font-size: 0.9rem; |
| } |
| .log { |
| background: #1e293b; |
| color: #b9e6f0; |
| padding: 1.2rem; |
| border-radius: 24px; |
| font-family: 'JetBrains Mono', monospace; |
| font-size: 0.9rem; |
| border-left: 6px solid #3b82f6; |
| white-space: pre-wrap; |
| max-height: 250px; |
| overflow: auto; |
| margin-top: 1.5rem; |
| } |
| .log p { margin: 0.2rem 0; } |
| .timestamp { color: #94a3b8; margin-right: 0.5rem; } |
| .file-list { |
| display: flex; |
| flex-direction: column; |
| gap: 0.6rem; |
| margin-top: 1rem; |
| max-height: 400px; |
| overflow-y: auto; |
| padding-right: 0.5rem; |
| } |
| .file-item { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| background: #ffffffd6; |
| backdrop-filter: blur(4px); |
| padding: 0.8rem 1.5rem; |
| border-radius: 40px; |
| box-shadow: 0 4px 8px rgba(0,0,0,0.02); |
| border: 1px solid rgba(255,255,255,0.6); |
| } |
| .file-link { |
| color: #1e3a8a; |
| font-weight: 500; |
| text-decoration: none; |
| word-break: break-all; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| } |
| .file-link:hover { |
| text-decoration: underline; |
| color: #2563eb; |
| } |
| .directory-link { |
| color: #b45309; |
| font-weight: 500; |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| } |
| .directory-link:hover { |
| text-decoration: underline; |
| color: #d97706; |
| } |
| .empty-message { |
| text-align: center; |
| color: #64748b; |
| padding: 2rem; |
| font-style: italic; |
| } |
| .api-test { |
| display: flex; |
| flex-direction: column; |
| gap: 1.5rem; |
| } |
| .api-section { |
| border-top: 1px solid #cbd5e1; |
| padding-top: 1rem; |
| } |
| .api-row { |
| display: flex; |
| align-items: center; |
| gap: 1rem; |
| flex-wrap: wrap; |
| margin: 0.8rem 0; |
| } |
| .api-input { |
| flex: 1; |
| padding: 0.8rem 1.2rem; |
| border-radius: 40px; |
| border: 1px solid #cbd5e1; |
| font-size: 1rem; |
| background: white; |
| } |
| .api-result { |
| background: #1e293b; |
| color: #b9e6f0; |
| padding: 1rem; |
| border-radius: 20px; |
| font-family: monospace; |
| font-size: 0.9rem; |
| white-space: pre-wrap; |
| max-height: 200px; |
| overflow: auto; |
| } |
| .api-docs { |
| background: #f8fafc; |
| border-radius: 20px; |
| padding: 1rem; |
| margin-top: 1rem; |
| } |
| .api-docs h3 { |
| font-size: 1.2rem; |
| margin-bottom: 0.8rem; |
| color: #0f172a; |
| } |
| .api-docs ul { |
| list-style: none; |
| padding-left: 0; |
| } |
| .api-docs li { |
| margin: 0.8rem 0; |
| padding: 0.8rem; |
| background: white; |
| border-radius: 16px; |
| border-left: 4px solid #3b82f6; |
| } |
| .api-docs code { |
| background: #000000; |
| color: white; |
| padding: 0.2rem 0.4rem; |
| border-radius: 6px; |
| font-family: monospace; |
| } |
| .nav-bar { |
| display: flex; |
| gap: 0.5rem; |
| margin-bottom: 1rem; |
| align-items: center; |
| } |
| .nav-bar input { |
| flex: 1; |
| } |
| footer { |
| margin-top: 2rem; |
| text-align: center; |
| color: #64748b; |
| font-size: 0.9rem; |
| } |
| @media (max-width: 768px) { |
| .grid { grid-template-columns: 1fr; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1 style="font-size: 2.5rem; margin-bottom: 2rem; color: #0f172a;">📦 HF Bucket 文件管理器 (目录导航)</h1> |
| <div class="grid"> |
| <!-- 左侧:文件上传与浏览 --> |
| <div class="card"> |
| <h2>📤 文件上传</h2> |
| <div class="upload-area"> |
| <label for="fileInput" class="file-label" id="fileLabel">📎 选择文件</label> |
| <input type="file" id="fileInput"> |
| <input type="text" id="uploadDir" class="dir-input" placeholder="目标目录 (可选,如 images/ 或 logs/2026/)" value=""> |
| <button class="button" id="uploadBtn">⬆️ 上传到 Bucket</button> |
| </div> |
| <h2 style="margin-top: 2rem;">📁 浏览</h2> |
| <div class="nav-bar"> |
| <input type="text" id="currentDir" class="dir-input" placeholder="当前目录 (留空为根目录)" value=""> |
| <button class="button secondary small" id="listBtn">列出文件</button> |
| <button class="button secondary small" id="goUpBtn" title="返回上级">⬆️ 上级</button> |
| </div> |
| <div id="fileList" class="file-list"> |
| <div class="empty-message">加载中...</div> |
| </div> |
| <div class="log" id="log">就绪</div> |
| </div> |
| |
| <!-- 右侧:API 测试面板 + 使用说明 --> |
| <div class="card"> |
| <h2>🧪 API 测试</h2> |
| <div class="api-test"> |
| <div class="api-section"> |
| <h3>📤 上传文件 (POST /upload) 支持目录</h3> |
| <div class="api-row"> |
| <input type="file" id="apiFileInput" style="flex:1; padding:0.5rem;"> |
| <span style="color:#64748b;">(留空则使用左侧文件)</span> |
| </div> |
| <div class="api-row"> |
| <input type="text" id="apiUploadDir" class="api-input" placeholder="目标目录 (可选,如 images/)" value=""> |
| <button class="button secondary small" id="apiUploadBtn">上传测试</button> |
| </div> |
| </div> |
| |
| <div class="api-section"> |
| <h3>📋 列出文件 (GET /list?dir=...)</h3> |
| <div class="api-row"> |
| <input type="text" id="apiListDir" class="api-input" placeholder="目录 (留空为根目录)" value=""> |
| <button class="button secondary small" id="apiListBtn">获取列表</button> |
| </div> |
| </div> |
| |
| <div class="api-section"> |
| <h3>📥 获取文件信息 (HEAD /file/<filename>)</h3> |
| <div class="api-row"> |
| <input type="text" class="api-input" id="apiFilename" placeholder="输入文件名(可包含目录)"> |
| <button class="button secondary small" id="apiGetBtn">检查</button> |
| </div> |
| </div> |
| |
| <div class="api-section"> |
| <h3>🗑️ 删除文件 (DELETE /delete/<filename>)</h3> |
| <div class="api-row"> |
| <input type="text" class="api-input" id="apiDeleteFilename" placeholder="要删除的文件名(可包含目录)"> |
| <button class="button danger small" id="apiDeleteBtn">删除</button> |
| </div> |
| </div> |
| |
| <div class="api-result" id="apiResult">点击按钮查看结果</div> |
| |
| <div class="api-docs"> |
| <h3>📚 API 使用说明 (JavaScript 示例)</h3> |
| <p style="color: #334155; margin-bottom: 1rem;">所有请求均需在 Hugging Face Space 内调用(同源),无需额外认证。</p> |
| |
| <!-- 上传文件 --> |
| <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #3b82f6;"> |
| <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">📤 上传文件</h4> |
| <p><code>POST /upload</code></p> |
| <p><strong>请求格式:</strong> <code>multipart/form-data</code></p> |
| <p><strong>参数:</strong></p> |
| <ul style="margin-left: 1.5rem; margin-bottom: 0.5rem;"> |
| <li><code>file</code> (必填) - 要上传的文件</li> |
| <li><code>dir</code> (可选) - 目标目录路径,例如 <code>"images/"</code> 或 <code>"logs/2026/"</code>。留空则上传到根目录。</li> |
| </ul> |
| <p><strong>成功响应:</strong> <code>{"success": true, "filename": "远程完整路径"}</code></p> |
| <p><strong>失败响应:</strong> <code>{"error": "错误信息"}</code> (HTTP 4xx/5xx)</p> |
| <!-- 修改后的代码框样式 --> |
| <pre style="background: #000000; color: white; padding: 1rem; border-radius: 12px; overflow-x: auto; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9rem; line-height: 1.5;"><code>// 使用 FormData 上传 |
| const fileInput = document.getElementById('fileInput'); // 文件选择 input |
| const file = fileInput.files[0]; |
| const dir = "images/"; // 可选目录 |
| |
| const formData = new FormData(); |
| formData.append('file', file); |
| formData.append('dir', dir); |
| |
| fetch('/upload', { |
| method: 'POST', |
| body: formData |
| }) |
| .then(res => res.json()) |
| .then(data => { |
| if (data.error) throw new Error(data.error); |
| console.log('上传成功:', data.filename); |
| }) |
| .catch(err => console.error('上传失败:', err.message));</code></pre> |
| </div> |
| |
| <!-- 列出文件 --> |
| <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #3b82f6;"> |
| <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">📋 列出文件</h4> |
| <p><code>GET /list?dir=...</code></p> |
| <p><strong>查询参数:</strong></p> |
| <ul style="margin-left: 1.5rem; margin-bottom: 0.5rem;"> |
| <li><code>dir</code> (可选) - 目录路径,例如 <code>"images/"</code>。留空则列出根目录下的第一层内容。</li> |
| </ul> |
| <p><strong>成功响应:</strong> 字符串数组,每个元素是文件或目录的完整路径(目录路径以 <code>/</code> 结尾)。例如:<code>["file.txt", "images/", "logs/"]</code></p> |
| <p><strong>失败响应:</strong> <code>{"error": "错误信息"}</code> (HTTP 5xx)</p> |
| <pre style="background: #000000; color: white; padding: 1rem; border-radius: 12px; overflow-x: auto; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9rem; line-height: 1.5;"><code>// 列出根目录内容 |
| fetch('/list') |
| .then(res => res.json()) |
| .then(files => { |
| files.forEach(path => { |
| if (path.endsWith('/')) { |
| console.log('目录:', path); |
| } else { |
| console.log('文件:', path); |
| } |
| }); |
| }); |
| |
| // 列出指定目录(如 images/)内容 |
| const dir = "images/"; |
| fetch(`/list?dir=${encodeURIComponent(dir)}`) |
| .then(res => res.json()) |
| .then(files => console.log(files));</code></pre> |
| </div> |
| |
| <!-- 下载文件 --> |
| <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #3b82f6;"> |
| <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">📥 下载文件</h4> |
| <p><code>GET /file/<filename></code></p> |
| <p><strong>路径参数:</strong></p> |
| <ul style="margin-left: 1.5rem; margin-bottom: 0.5rem;"> |
| <li><code>filename</code> - 文件的完整路径(可包含目录),例如 <code>"images/avatar.png"</code>。</li> |
| </ul> |
| <p><strong>成功响应:</strong> 文件内容(作为附件下载)。</p> |
| <p><strong>失败响应:</strong> <code>{"error": "错误信息"}</code> (HTTP 4xx/5xx)</p> |
| <pre style="background: #000000; color: white; padding: 1rem; border-radius: 12px; overflow-x: auto; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9rem; line-height: 1.5;"><code>// 触发浏览器下载 |
| const filename = "images/avatar.png"; |
| window.location.href = `/file/${encodeURIComponent(filename)}`; |
| |
| // 或者用 fetch 获取文件 Blob |
| fetch(`/file/${encodeURIComponent(filename)}`) |
| .then(res => { |
| if (!res.ok) throw new Error('文件不存在'); |
| return res.blob(); |
| }) |
| .then(blob => { |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = filename.split('/').pop(); // 提取文件名 |
| a.click(); |
| }) |
| .catch(err => console.error('下载失败:', err.message));</code></pre> |
| </div> |
| |
| <!-- 检查文件存在性 --> |
| <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #3b82f6;"> |
| <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">🔍 检查文件是否存在</h4> |
| <p><code>HEAD /file/<filename></code></p> |
| <p><strong>路径参数:</strong> 同下载接口的 <code>filename</code>。</p> |
| <p><strong>成功响应:</strong> HTTP 200,无响应体。</p> |
| <p><strong>失败响应:</strong> HTTP 404 并返回 JSON 错误信息。</p> |
| <pre style="background: #000000; color: white; padding: 1rem; border-radius: 12px; overflow-x: auto; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9rem; line-height: 1.5;"><code>const filename = "config.json"; |
| |
| fetch(`/file/${encodeURIComponent(filename)}`, { method: 'HEAD' }) |
| .then(res => { |
| if (res.ok) { |
| console.log('文件存在'); |
| } else { |
| return res.json().then(err => { throw new Error(err.error); }); |
| } |
| }) |
| .catch(err => console.error('检查失败:', err.message));</code></pre> |
| </div> |
| |
| <!-- 删除文件 --> |
| <div style="background: white; border-radius: 16px; padding: 1rem; margin-bottom: 1.5rem; border-left: 4px solid #ef4444;"> |
| <h4 style="margin: 0 0 0.5rem 0; color: #0f172a;">🗑️ 删除文件</h4> |
| <p><code>DELETE /delete/<filename></code></p> |
| <p><strong>路径参数:</strong> 同下载接口的 <code>filename</code>。</p> |
| <p><strong>成功响应:</strong> <code>{"success": true}</code></p> |
| <p><strong>失败响应:</strong> <code>{"error": "错误信息"}</code> (HTTP 4xx/5xx)</p> |
| <pre style="background: #1e1e1e; color: white; padding: 1rem; border-radius: 12px; overflow-x: auto; font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.9rem; line-height: 1.5;"><code>const filename = "temp.log"; |
| |
| fetch(`/delete/${encodeURIComponent(filename)}`, { method: 'DELETE' }) |
| .then(res => res.json()) |
| .then(data => { |
| if (data.success) { |
| console.log('删除成功'); |
| } else { |
| throw new Error(data.error); |
| } |
| }) |
| .catch(err => console.error('删除失败:', err.message));</code></pre> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| <footer>Powered by Hugging Face Buckets · 支持多级目录导航</footer> |
| </div> |
| |
| <script> |
| (function() { |
| // DOM 元素 |
| const fileInput = document.getElementById('fileInput'); |
| const uploadBtn = document.getElementById('uploadBtn'); |
| const fileLabel = document.getElementById('fileLabel'); |
| const uploadDir = document.getElementById('uploadDir'); |
| const currentDir = document.getElementById('currentDir'); |
| const listBtn = document.getElementById('listBtn'); |
| const goUpBtn = document.getElementById('goUpBtn'); |
| const logDiv = document.getElementById('log'); |
| const fileListDiv = document.getElementById('fileList'); |
| const apiResult = document.getElementById('apiResult'); |
| const apiListBtn = document.getElementById('apiListBtn'); |
| const apiListDir = document.getElementById('apiListDir'); |
| const apiGetBtn = document.getElementById('apiGetBtn'); |
| const apiDeleteBtn = document.getElementById('apiDeleteBtn'); |
| const apiFilename = document.getElementById('apiFilename'); |
| const apiDeleteFilename = document.getElementById('apiDeleteFilename'); |
| const apiFileInput = document.getElementById('apiFileInput'); |
| const apiUploadDir = document.getElementById('apiUploadDir'); |
| const apiUploadBtn = document.getElementById('apiUploadBtn'); |
| |
| // 辅助函数:添加日志 |
| function addLog(msg, isErr = false) { |
| const p = document.createElement('p'); |
| const ts = new Date().toLocaleTimeString(); |
| p.innerHTML = '<span class="timestamp">[' + ts + ']</span> ' + (isErr ? '❌ ' : '') + msg; |
| if (isErr) p.style.color = '#f87171'; |
| logDiv.appendChild(p); |
| logDiv.scrollTop = logDiv.scrollHeight; |
| } |
| function clearLog() { logDiv.innerHTML = ''; } |
| |
| // 显示 API 结果 |
| function showApiResult(data, isError = false) { |
| if (typeof data === 'object') { |
| apiResult.textContent = JSON.stringify(data, null, 2); |
| } else { |
| apiResult.textContent = data; |
| } |
| if (isError) apiResult.style.color = '#f87171'; |
| else apiResult.style.color = '#b9e6f0'; |
| } |
| |
| // 构建远程路径:目录 + 文件名 |
| function buildRemotePath(dir, filename) { |
| dir = dir.trim(); |
| if (!dir) return filename; |
| if (!dir.endsWith('/')) dir += '/'; |
| if (dir.startsWith('/')) dir = dir.substring(1); |
| return dir + filename; |
| } |
| |
| // 获取用于上传的文件 |
| function getUploadFile() { |
| if (apiFileInput.files.length > 0) { |
| return apiFileInput.files[0]; |
| } else if (fileInput.files.length > 0) { |
| return fileInput.files[0]; |
| } |
| return null; |
| } |
| |
| // 加载文件列表 |
| async function loadList(dir) { |
| dir = dir || ''; |
| currentDir.value = dir; // 同步输入框 |
| try { |
| fileListDiv.innerHTML = '<div class="empty-message">加载中...</div>'; |
| let url = '/list'; |
| if (dir) url += '?dir=' + encodeURIComponent(dir); |
| const res = await fetch(url); |
| if (!res.ok) { |
| const err = await res.text(); |
| throw new Error(`HTTP ${res.status}: ${err}`); |
| } |
| const items = await res.json(); // 数组,每个元素是路径字符串 |
| if (items.length === 0) { |
| fileListDiv.innerHTML = '<div class="empty-message">📭 空目录</div>'; |
| } else { |
| // 根据路径是否以 / 结尾判断是目录还是文件 |
| const html = items.map(path => { |
| const isDir = path.endsWith('/'); |
| if (isDir) { |
| const dirName = path.slice(0, -1); // 去掉末尾斜杠,用于显示 |
| return ` |
| <div class="file-item"> |
| <span class="directory-link" onclick="navigateTo('${path}')"> |
| 📁 ${dirName} |
| </span> |
| <!-- 目录暂不提供删除按钮,可后续扩展 --> |
| </div> |
| `; |
| } else { |
| // 文件:显示下载链接和删除按钮 |
| return ` |
| <div class="file-item"> |
| <a href="/file/${encodeURIComponent(path)}" target="_blank" class="file-link"> |
| 📄 ${path} |
| </a> |
| <button class="button danger small" onclick="deleteFile('${path.replace(/'/g, "\\\\'")}')">删除</button> |
| </div> |
| `; |
| } |
| }).join(''); |
| fileListDiv.innerHTML = html; |
| } |
| } catch (err) { |
| fileListDiv.innerHTML = '<div class="empty-message">❌ 加载失败</div>'; |
| addLog('列表加载错误: ' + err.message, true); |
| } |
| } |
| |
| // 导航到目录 |
| window.navigateTo = function(dirPath) { |
| // dirPath 以 / 结尾,例如 "subdir/" |
| const dir = dirPath; // 直接使用,loadList 会处理 |
| loadList(dir); |
| }; |
| |
| // 删除文件 |
| window.deleteFile = async function(filename) { |
| if (!confirm(`确定删除 ${filename} 吗?`)) return; |
| try { |
| addLog('正在删除: ' + filename); |
| const res = await fetch('/delete/' + encodeURIComponent(filename), { method: 'DELETE' }); |
| const text = await res.text(); |
| if (!res.ok) throw new Error(`删除失败 (${res.status}): ${text}`); |
| addLog('✅ 删除成功: ' + filename); |
| // 刷新当前目录 |
| loadList(currentDir.value); |
| } catch (err) { |
| addLog(err.message, true); |
| } |
| }; |
| |
| // 返回上级目录 |
| function goUp() { |
| let dir = currentDir.value.trim(); |
| if (!dir) return; // 已在根目录 |
| // 移除末尾的 / |
| dir = dir.endsWith('/') ? dir.slice(0, -1) : dir; |
| const parts = dir.split('/'); |
| parts.pop(); // 去掉最后一级 |
| const parent = parts.length ? parts.join('/') + '/' : ''; |
| loadList(parent); |
| } |
| |
| // 左侧文件选择显示 |
| fileInput.addEventListener('change', function() { |
| if (fileInput.files.length > 0) { |
| fileLabel.textContent = '📄 ' + fileInput.files[0].name; |
| } else { |
| fileLabel.textContent = '📎 选择文件'; |
| } |
| }); |
| |
| // 左侧上传按钮 |
| uploadBtn.addEventListener('click', async () => { |
| const file = fileInput.files[0]; |
| if (!file) { alert('请选择文件'); return; } |
| |
| const dir = uploadDir.value; |
| const remotePath = buildRemotePath(dir, file.name); |
| |
| clearLog(); |
| addLog('开始上传到: ' + remotePath); |
| uploadBtn.disabled = true; |
| |
| const formData = new FormData(); |
| formData.append('file', file); |
| formData.append('dir', dir); |
| |
| try { |
| const res = await fetch('/upload', { method: 'POST', body: formData }); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); |
| addLog('✅ 上传成功!路径: ' + data.filename); |
| fileInput.value = ''; |
| fileLabel.textContent = '📎 选择文件'; |
| uploadDir.value = ''; |
| // 上传后刷新当前目录 |
| loadList(currentDir.value); |
| } catch (err) { |
| addLog('❌ ' + err.message, true); |
| } finally { |
| uploadBtn.disabled = false; |
| } |
| }); |
| |
| // 列出文件按钮 |
| listBtn.addEventListener('click', () => { |
| loadList(currentDir.value.trim()); |
| }); |
| |
| // 返回上级按钮 |
| goUpBtn.addEventListener('click', goUp); |
| |
| // API 测试:上传文件 |
| apiUploadBtn.addEventListener('click', async () => { |
| const file = getUploadFile(); |
| if (!file) { |
| alert('请选择文件(在左侧或右侧上传区域选择)'); |
| return; |
| } |
| |
| const dir = apiUploadDir.value; |
| const remotePath = buildRemotePath(dir, file.name); |
| |
| const formData = new FormData(); |
| formData.append('file', file); |
| formData.append('dir', dir); |
| |
| try { |
| showApiResult('正在上传到 ' + remotePath + ' ...'); |
| const res = await fetch('/upload', { method: 'POST', body: formData }); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); |
| showApiResult(data); |
| loadList(currentDir.value); |
| } catch (err) { |
| showApiResult(err.message, true); |
| } |
| }); |
| |
| // API 测试:列出文件 |
| apiListBtn.addEventListener('click', async () => { |
| const dir = apiListDir.value.trim(); |
| try { |
| showApiResult('正在请求 /list?dir=' + dir + ' ...'); |
| let url = '/list'; |
| if (dir) url += '?dir=' + encodeURIComponent(dir); |
| const res = await fetch(url); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); |
| showApiResult(data); |
| // 同时更新左侧浏览目录为相同目录并刷新 |
| loadList(dir); |
| } catch (err) { |
| showApiResult(err.message, true); |
| } |
| }); |
| |
| // API 测试:获取文件信息 |
| apiGetBtn.addEventListener('click', async () => { |
| const filename = apiFilename.value.trim(); |
| if (!filename) { alert('请输入文件名'); return; } |
| try { |
| showApiResult('正在检查 /file/' + filename + ' ...'); |
| const res = await fetch('/file/' + encodeURIComponent(filename), { method: 'HEAD' }); |
| if (res.ok) { |
| showApiResult({ status: 'OK', message: '文件存在,可下载' }); |
| } else { |
| const text = await res.text(); |
| throw new Error(`HTTP ${res.status}: ${text}`); |
| } |
| } catch (err) { |
| showApiResult(err.message, true); |
| } |
| }); |
| |
| // API 测试:删除文件 |
| apiDeleteBtn.addEventListener('click', async () => { |
| const filename = apiDeleteFilename.value.trim(); |
| if (!filename) { alert('请输入文件名'); return; } |
| if (!confirm(`确定通过 API 删除 ${filename} 吗?`)) return; |
| try { |
| showApiResult('正在删除 ' + filename + ' ...'); |
| const res = await fetch('/delete/' + encodeURIComponent(filename), { method: 'DELETE' }); |
| const text = await res.text(); |
| if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`); |
| showApiResult({ success: true, message: '文件已删除' }); |
| loadList(currentDir.value); |
| } catch (err) { |
| showApiResult(err.message, true); |
| } |
| }); |
| |
| // 初始化:加载根目录 |
| loadList(''); |
| })(); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| @app.route('/') |
| def index(): |
| return HTML_CONTENT |
|
|
|
|
| @app.route('/upload', methods=['POST']) |
| def upload(): |
| if 'file' not in request.files: |
| return jsonify({'error': 'No file part'}), 400 |
| file = request.files['file'] |
| if file.filename == '': |
| return jsonify({'error': 'No selected file'}), 400 |
|
|
| target_dir = request.form.get('dir', '').strip() |
| filename = file.filename |
| if target_dir: |
| |
| if target_dir.startswith('/'): |
| target_dir = target_dir[1:] |
| if not target_dir.endswith('/'): |
| target_dir += '/' |
| remote_path = target_dir + filename |
| else: |
| remote_path = filename |
|
|
| with tempfile.NamedTemporaryFile(delete=False) as tmp: |
| file.save(tmp.name) |
| try: |
| batch_bucket_files( |
| bucket_id=BUCKET_ID, |
| add=[(tmp.name, remote_path)], |
| token=HF_TOKEN |
| ) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
| finally: |
| os.unlink(tmp.name) |
|
|
| |
| base_url = "https://" + request.host + "/" |
| file_url = base_url + "file/" + remote_path |
| return jsonify({'success': True, 'filename': file_url}) |
|
|
|
|
| @app.route('/list') |
| def list_files(): |
| """ |
| 列出 bucket 中指定目录下的第一层内容(文件和目录)。 |
| 若未提供 dir 参数或 dir 为空,则列出根目录下的第一层内容。 |
| 返回的列表元素为路径字符串,目录路径以 '/' 结尾,文件路径不以 '/' 结尾。 |
| """ |
| dir_param = request.args.get('dir', '').strip() |
| try: |
| |
| if dir_param: |
| if dir_param.startswith('/'): |
| dir_param = dir_param[1:] |
| if not dir_param.endswith('/'): |
| dir_param += '/' |
| |
| items = list_bucket_tree( |
| bucket_id=BUCKET_ID, |
| prefix=dir_param, |
| recursive=False, |
| token=HF_TOKEN |
| ) |
| |
| paths = [] |
| for item in items: |
| if item.type == 'directory': |
| paths.append(item.path + '/') |
| else: |
| paths.append(item.path) |
| return jsonify(paths) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
|
|
| @app.route('/file/<path:filename>') |
| def get_file(filename): |
| try: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| local_path = os.path.join(tmpdir, filename) |
| download_bucket_files( |
| bucket_id=BUCKET_ID, |
| files=[(filename, local_path)], |
| token=HF_TOKEN |
| ) |
| return send_file(local_path, as_attachment=True, download_name=filename) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
|
|
| @app.route('/delete/<path:filename>', methods=['DELETE']) |
| def delete_file(filename): |
| try: |
| batch_bucket_files( |
| bucket_id=BUCKET_ID, |
| delete=[filename], |
| token=HF_TOKEN |
| ) |
| return jsonify({'success': True}) |
| except Exception as e: |
| return jsonify({'error': str(e)}), 500 |
|
|
|
|
| if __name__ == '__main__': |
| app.run(host='0.0.0.0', port=7860) |