Spaces:
Running
Running
| """用户文件管理页面 HTML:/u | |
| 面向终端用户的文件管理 UI: | |
| - 注册 / 登录 | |
| - 文件列表(仅当前账号) | |
| - 上传新文件(≤10MB) | |
| - 下载 / 删除文件 | |
| 页面所有交互通过 fetch 调 /u/api/* 接口。 | |
| """ | |
| from __future__ import annotations | |
| from fastapi import APIRouter | |
| from fastapi.responses import HTMLResponse | |
| from .api._common import CORS_HEADERS | |
| router = APIRouter(tags=["user-html"]) | |
| async def user_page() -> HTMLResponse: | |
| return HTMLResponse(content=_HTML, headers=CORS_HEADERS) | |
| _HTML = """<!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>我的文件</title> | |
| <style> | |
| :root { | |
| --bg: #0f1117; --panel: #1a1d27; --panel2: #232734; --border: #2d3142; | |
| --text: #e4e6ed; --muted: #8b8fa3; --accent: #4f8cff; --accent2: #6aa1ff; | |
| --ok: #4ade80; --warn: #fbbf24; --err: #f87171; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; | |
| background: var(--bg); color: var(--text); line-height: 1.5; | |
| } | |
| header { | |
| padding: 14px 24px; background: var(--panel); border-bottom: 1px solid var(--border); | |
| display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; | |
| } | |
| header h1 { margin: 0; font-size: 18px; font-weight: 600; } | |
| header .badge { font-size: 12px; color: var(--muted); } | |
| main { max-width: 920px; margin: 0 auto; padding: 24px 16px 64px; } | |
| .card { | |
| background: var(--panel); border: 1px solid var(--border); border-radius: 8px; | |
| padding: 16px 20px; margin-bottom: 16px; | |
| } | |
| .card h2 { margin: 0 0 12px; font-size: 15px; } | |
| input, button, select, textarea { | |
| font: inherit; color: var(--text); background: var(--panel2); | |
| border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; | |
| } | |
| input, textarea { width: 100%; } | |
| button { cursor: pointer; background: var(--accent); border-color: var(--accent); } | |
| button:hover { background: var(--accent2); } | |
| button.ghost { background: transparent; } | |
| button.danger { background: var(--err); border-color: var(--err); } | |
| button.small { padding: 4px 10px; font-size: 12px; } | |
| button:disabled { opacity: .5; cursor: not-allowed; } | |
| .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; } | |
| .row > label { flex: 1; min-width: 180px; } | |
| .muted { color: var(--muted); font-size: 13px; } | |
| .err-text { color: var(--err); font-size: 13px; } | |
| .ok-text { color: var(--ok); font-size: 13px; } | |
| table { width: 100%; border-collapse: collapse; } | |
| th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; } | |
| th { color: var(--muted); font-weight: 500; } | |
| td.name { word-break: break-all; } | |
| .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; background: var(--panel2); color: var(--muted); font-size: 12px; } | |
| .empty { padding: 32px; text-align: center; color: var(--muted); } | |
| .hidden { display: none !important; } | |
| .upload-box { border: 2px dashed var(--border); border-radius: 8px; padding: 18px; text-align: center; } | |
| .upload-box.has-file { border-color: var(--accent); } | |
| .progress { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } | |
| .progress-bar { height: 100%; background: var(--accent); width: 0; transition: width .2s; } | |
| .tabs { display: flex; gap: 8px; margin-bottom: 12px; } | |
| .tabs button { flex: 1; } | |
| .tabs button.active { background: var(--accent2); } | |
| a.link { color: var(--accent); text-decoration: none; } | |
| a.link:hover { text-decoration: underline; } | |
| a.link.danger { color: var(--err); } | |
| /* 拖拽高亮 */ | |
| .upload-box.drag-over { | |
| border-color: var(--accent); | |
| background: rgba(79, 140, 255, 0.10); | |
| } | |
| /* input 字号 ≥16px 防止 iOS 聚焦自动放大 */ | |
| input, textarea, select { font-size: 16px; } | |
| @media (min-width: 641px) { | |
| input, textarea, select { font-size: 14px; } | |
| } | |
| /* 响应式:小屏适配 */ | |
| @media (max-width: 640px) { | |
| header { padding: 12px 14px; gap: 8px; } | |
| header h1 { font-size: 16px; } | |
| header .badge { font-size: 11px; width: 100%; } | |
| main { padding: 16px 12px 80px; } | |
| .card { padding: 12px 14px; margin-bottom: 12px; } | |
| .row > label { min-width: 0; flex-basis: 100%; } | |
| .tabs button { min-height: 40px; } | |
| /* table 转卡片式 */ | |
| table, thead, tbody, tr, th, td { display: block; width: 100%; } | |
| thead { display: none; } | |
| tr { | |
| background: var(--panel2); | |
| border-radius: 8px; | |
| padding: 10px 12px; | |
| margin-bottom: 10px; | |
| border: 1px solid var(--border); | |
| } | |
| td { | |
| border: none; | |
| padding: 6px 0; | |
| display: flex; | |
| justify-content: space-between; | |
| gap: 12px; | |
| align-items: center; | |
| } | |
| td::before { | |
| content: attr(data-label); | |
| color: var(--muted); | |
| font-size: 12px; | |
| flex-shrink: 0; | |
| } | |
| td.name { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| padding-bottom: 8px; | |
| border-bottom: 1px solid var(--border); | |
| margin-bottom: 4px; | |
| } | |
| td.name::before { display: none; } | |
| td.actions { justify-content: flex-end; } | |
| td.actions::before { display: none; } | |
| /* 按钮触摸友好 */ | |
| button { min-height: 40px; padding: 10px 16px; } | |
| button.small { min-height: 36px; padding: 8px 12px; } | |
| .upload-box { padding: 24px 16px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>我的文件</h1> | |
| <div id="headerRight" class="badge"></div> | |
| </header> | |
| <main> | |
| <!-- 登录/注册视图 --> | |
| <div id="authView"> | |
| <div class="card" style="max-width:420px;margin:0 auto"> | |
| <div class="tabs"> | |
| <button id="tabLogin" type="button" class="active" onclick="switchTab('login')">登录</button> | |
| <button id="tabRegister" type="button" onclick="switchTab('register')">注册</button> | |
| </div> | |
| <div class="row" style="flex-direction:column;align-items:stretch;gap:10px"> | |
| <label>用户名 | |
| <input id="username" type="text" autocomplete="username" placeholder="2-32 位:字母/数字/_-. /中文"> | |
| </label> | |
| <label>密码 | |
| <input id="password" type="password" autocomplete="current-password" placeholder="6 位以上"> | |
| </label> | |
| <button id="authBtn" type="button" onclick="doAuth()">登录</button> | |
| <div id="authMsg" class="err-text"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 文件管理视图 --> | |
| <div id="filesView" class="hidden"> | |
| <div class="card"> | |
| <h2>上传文件</h2> | |
| <div id="uploadBox" class="upload-box"> | |
| <input id="fileInput" type="file" style="display:none" onchange="onFilePicked(event)"> | |
| <div id="uploadHint"> | |
| <div style="margin-bottom:8px">点击选择或拖拽文件到此处,单个文件 ≤ 10MB</div> | |
| <button type="button" class="ghost" onclick="document.getElementById('fileInput').click()">选择文件</button> | |
| </div> | |
| <div id="uploadFile" class="hidden"> | |
| <div id="uploadFileName"></div> | |
| <div class="row" style="justify-content:center;margin-top:10px"> | |
| <button type="button" id="uploadBtn" onclick="doUpload()">上传</button> | |
| <button type="button" class="ghost" onclick="resetUpload()">取消</button> | |
| </div> | |
| <div id="uploadProgress" class="progress hidden"><div class="progress-bar" id="progressBar"></div></div> | |
| <div id="uploadMsg" class="err-text" style="margin-top:8px"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>我的文件 <span id="fileCount" class="muted"></span></h2> | |
| <div id="fileList"><div class="empty">加载中...</div></div> | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| const TOKEN_KEY = 'xtc_user_file_token'; | |
| const USER_KEY = 'xtc_user_file_user'; | |
| let pendingFile = null; | |
| // ===== 工具 ===== | |
| function $(id) { return document.getElementById(id); } | |
| function esc(s) { | |
| if (s === null || s === undefined) return ''; | |
| return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| } | |
| function fmtSize(n) { | |
| n = Number(n) || 0; | |
| if (n < 1024) return n + ' B'; | |
| if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB'; | |
| if (n < 1024*1024*1024) return (n/1024/1024).toFixed(2) + ' MB'; | |
| return (n/1024/1024/1024).toFixed(2) + ' GB'; | |
| } | |
| function fmtTime(ts) { | |
| if (!ts) return ''; | |
| const d = new Date(ts * 1000); | |
| return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0') | |
| + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0'); | |
| } | |
| function getToken() { return localStorage.getItem(TOKEN_KEY) || ''; } | |
| function setToken(t) { localStorage.setItem(TOKEN_KEY, t); } | |
| function clearToken() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); } | |
| function getUser() { | |
| try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null'); } catch(e) { return null; } | |
| } | |
| function setUser(u) { localStorage.setItem(USER_KEY, JSON.stringify(u||null)); } | |
| function authHeaders() { | |
| const t = getToken(); | |
| return t ? { 'Authorization': 'Bearer ' + t } : {}; | |
| } | |
| async function api(path, opts) { | |
| opts = opts || {}; | |
| opts.headers = Object.assign({}, authHeaders(), opts.headers || {}); | |
| const res = await fetch(path, opts); | |
| let data = null; | |
| try { data = await res.json(); } catch(e) {} | |
| if (!res.ok || (data && data.ok === false)) { | |
| const msg = (data && data.error && (data.error.message || data.error.code)) || ('HTTP ' + res.status); | |
| const err = new Error(msg); | |
| err.status = res.status; | |
| err.code = data && data.error && data.error.code; | |
| throw err; | |
| } | |
| return data; | |
| } | |
| // ===== 视图切换 ===== | |
| function showAuth() { | |
| $('authView').classList.remove('hidden'); | |
| $('filesView').classList.add('hidden'); | |
| $('headerRight').innerHTML = ''; | |
| } | |
| function showFiles() { | |
| $('authView').classList.add('hidden'); | |
| $('filesView').classList.remove('hidden'); | |
| const u = getUser(); | |
| $('headerRight').innerHTML = '已登录:<b>' + esc(u && u.username || '') + '</b> · <a href="#" class="link" onclick="doLogout();return false;">退出</a> · <a href="#" class="link danger" onclick="doDeleteAccount();return false;">注销账号</a>'; | |
| loadFiles(); | |
| } | |
| // ===== 登录/注册 ===== | |
| let authMode = 'login'; | |
| function switchTab(mode) { | |
| authMode = mode; | |
| $('tabLogin').classList.toggle('active', mode === 'login'); | |
| $('tabRegister').classList.toggle('active', mode === 'register'); | |
| $('authBtn').textContent = mode === 'login' ? '登录' : '注册'; | |
| $('authMsg').textContent = ''; | |
| } | |
| async function doAuth() { | |
| const username = $('username').value.trim(); | |
| const password = $('password').value; | |
| $('authMsg').textContent = ''; | |
| if (!username || !password) { $('authMsg').textContent = '请填写用户名和密码'; return; } | |
| $('authBtn').disabled = true; | |
| try { | |
| const data = await api('/u/api/' + authMode, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username, password }), | |
| }); | |
| setToken(data.token); | |
| setUser(data.user); | |
| showFiles(); | |
| } catch (e) { | |
| $('authMsg').textContent = e.message || '操作失败'; | |
| if (e.status === 401) clearToken(); | |
| } finally { | |
| $('authBtn').disabled = false; | |
| } | |
| } | |
| function doLogout() { | |
| clearToken(); | |
| showAuth(); | |
| $('username').value = ''; | |
| $('password').value = ''; | |
| } | |
| async function doDeleteAccount() { | |
| const u = getUser(); | |
| if (!u || !u.username) { showAuth(); return; } | |
| // 第一次确认 | |
| if (!confirm('注销账号将永久删除你的账号和所有已上传文件,不可恢复!\\n确认要注销账号「' + u.username + '」吗?')) return; | |
| // 输入密码二次确认 | |
| const password = prompt('请输入密码以确认注销账号:'); | |
| if (password === null) return; | |
| if (!password) { alert('密码不能为空'); return; } | |
| try { | |
| await api('/u/api/account', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ password }), | |
| }); | |
| clearToken(); | |
| setUser(null); | |
| showAuth(); | |
| $('username').value = ''; | |
| $('password').value = ''; | |
| alert('账号已注销,所有文件已删除'); | |
| } catch (e) { | |
| alert('注销失败:' + (e.message || '未知错误')); | |
| } | |
| } | |
| // ===== 自动登录 ===== | |
| async function autoLogin() { | |
| const token = getToken(); | |
| if (!token) { showAuth(); return; } | |
| try { | |
| const data = await api('/u/api/me'); | |
| setUser(data.user); | |
| showFiles(); | |
| } catch (e) { | |
| clearToken(); | |
| showAuth(); | |
| } | |
| } | |
| // ===== 文件列表 ===== | |
| async function loadFiles() { | |
| const el = $('fileList'); | |
| el.innerHTML = '<div class="empty">加载中...</div>'; | |
| $('fileCount').textContent = ''; | |
| try { | |
| const data = await api('/u/api/files'); | |
| const items = data.items || []; | |
| $('fileCount').textContent = '(共 ' + items.length + ' 个)'; | |
| if (!items.length) { el.innerHTML = '<div class="empty">还没有文件,上传一个吧</div>'; return; } | |
| el.innerHTML = '<table><thead><tr><th>文件名</th><th>大小</th><th>上传时间</th><th style="width:160px">操作</th></tr></thead><tbody>' | |
| + items.map(f => { | |
| // 优先显示 alias(原始名,可能含中文),回退 filename | |
| const dispName = f.alias || f.filename || 'unnamed'; | |
| return '<tr>' | |
| + '<td class="name" data-label="文件名">' + esc(dispName) + '<br><span class="tag">' + esc(f.mime || '') + '</span></td>' | |
| + '<td data-label="大小">' + fmtSize(f.size) + '</td>' | |
| + '<td data-label="上传时间">' + fmtTime(f.uploaded_at) + '</td>' | |
| + '<td class="actions" data-label="操作"><button class="small" data-act="dl" data-key="' + esc(f.key) + '" data-name="' + esc(dispName) + '">下载</button> ' | |
| + '<button class="small danger" data-act="del" data-key="' + esc(f.key) + '" data-name="' + esc(dispName) + '">删除</button></td>' | |
| + '</tr>'; | |
| }).join('') | |
| + '</tbody></table>'; | |
| // 事件委托,避免内联 onclick 的引号转义问题 | |
| el.querySelectorAll('button[data-act]').forEach(btn => { | |
| btn.addEventListener('click', function() { | |
| const act = this.getAttribute('data-act'); | |
| const key = this.getAttribute('data-key'); | |
| const name = this.getAttribute('data-name'); | |
| if (act === 'dl') downloadFile(key, name); | |
| else if (act === 'del') deleteFile(key, name); | |
| }); | |
| }); | |
| } catch (e) { | |
| el.innerHTML = '<div class="err-text">加载失败:' + esc(e.message) + '</div>'; | |
| if (e.status === 401) { clearToken(); showAuth(); } | |
| } | |
| } | |
| // ===== 上传 ===== | |
| function setPendingFile(f) { | |
| pendingFile = f; | |
| $('uploadHint').classList.add('hidden'); | |
| $('uploadFile').classList.remove('hidden'); | |
| $('uploadFileName').textContent = f.name + ' · ' + fmtSize(f.size); | |
| $('uploadBox').classList.add('has-file'); | |
| $('uploadMsg').textContent = ''; | |
| if (f.size > 10 * 1024 * 1024) { | |
| $('uploadMsg').textContent = '文件超过 10MB 限制'; | |
| } | |
| $('uploadBtn').disabled = (f.size > 10 * 1024 * 1024); | |
| } | |
| function onFilePicked(e) { | |
| const f = e.target.files && e.target.files[0]; | |
| if (!f) return; | |
| setPendingFile(f); | |
| } | |
| function resetUpload() { | |
| pendingFile = null; | |
| $('fileInput').value = ''; | |
| $('uploadHint').classList.remove('hidden'); | |
| $('uploadFile').classList.add('hidden'); | |
| $('uploadBox').classList.remove('has-file'); | |
| $('uploadMsg').textContent = ''; | |
| $('uploadProgress').classList.add('hidden'); | |
| $('progressBar').style.width = '0'; | |
| } | |
| // ===== 拖拽上传 ===== | |
| function setupDragDrop() { | |
| const box = $('uploadBox'); | |
| // 阻止整个文档的拖拽默认行为(防止误把文件在浏览器中打开) | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function(ev) { | |
| document.addEventListener(ev, function(e) { | |
| if (!box.contains(e.target)) e.preventDefault(); | |
| }); | |
| }); | |
| document.addEventListener('drop', function(e) { | |
| if (!box.contains(e.target)) e.preventDefault(); | |
| }); | |
| // dragover 必须 preventDefault 才能触发 drop | |
| box.addEventListener('dragenter', function(e) { | |
| e.preventDefault(); e.stopPropagation(); | |
| box.classList.add('drag-over'); | |
| }); | |
| box.addEventListener('dragover', function(e) { | |
| e.preventDefault(); e.stopPropagation(); | |
| if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; | |
| box.classList.add('drag-over'); | |
| }); | |
| box.addEventListener('dragleave', function(e) { | |
| e.preventDefault(); e.stopPropagation(); | |
| // 仅当离开 box 本身时取消高亮,避免子元素切换抖动 | |
| if (e.target === box) box.classList.remove('drag-over'); | |
| }); | |
| box.addEventListener('drop', function(e) { | |
| e.preventDefault(); e.stopPropagation(); | |
| box.classList.remove('drag-over'); | |
| const files = e.dataTransfer && e.dataTransfer.files; | |
| if (!files || !files.length) return; | |
| setPendingFile(files[0]); | |
| }); | |
| } | |
| async function doUpload() { | |
| if (!pendingFile) return; | |
| if (pendingFile.size > 10 * 1024 * 1024) { $('uploadMsg').textContent = '文件超过 10MB 限制'; return; } | |
| $('uploadBtn').disabled = true; | |
| $('uploadMsg').textContent = ''; | |
| $('uploadProgress').classList.remove('hidden'); | |
| $('progressBar').style.width = '0'; | |
| const fd = new FormData(); | |
| fd.append('file', pendingFile, pendingFile.name); | |
| // 用 XMLHttpRequest 以便显示进度 | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open('POST', '/u/api/upload'); | |
| const tok = getToken(); | |
| if (tok) xhr.setRequestHeader('Authorization', 'Bearer ' + tok); | |
| xhr.upload.onprogress = function(ev) { | |
| if (ev.lengthComputable) { | |
| const pct = Math.round(ev.loaded / ev.total * 100); | |
| $('progressBar').style.width = pct + '%'; | |
| } | |
| }; | |
| xhr.onload = function() { | |
| let data = null; | |
| try { data = JSON.parse(xhr.responseText); } catch(e) {} | |
| if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) { | |
| resetUpload(); | |
| loadFiles(); | |
| } else { | |
| const msg = (data && data.error && (data.error.message || data.error.code)) || ('上传失败 (HTTP ' + xhr.status + ')'); | |
| $('uploadMsg').textContent = msg; | |
| $('uploadBtn').disabled = false; | |
| if (xhr.status === 401) { clearToken(); showAuth(); } | |
| } | |
| }; | |
| xhr.onerror = function() { | |
| $('uploadMsg').textContent = '网络错误,上传失败'; | |
| $('uploadBtn').disabled = false; | |
| }; | |
| xhr.send(fd); | |
| } | |
| // ===== 下载 ===== | |
| async function downloadFile(key, filename) { | |
| try { | |
| const res = await fetch('/u/api/files/' + encodeURIComponent(key), { | |
| headers: authHeaders(), | |
| }); | |
| if (!res.ok) { | |
| let data = null; try { data = await res.json(); } catch(e) {} | |
| const msg = (data && data.error && data.error.message) || ('下载失败 (HTTP ' + res.status + ')'); | |
| alert(msg); | |
| if (res.status === 401) { clearToken(); showAuth(); } | |
| return; | |
| } | |
| const blob = await res.blob(); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename || key; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| } catch (e) { | |
| alert('下载失败:' + e.message); | |
| } | |
| } | |
| // ===== 删除 ===== | |
| async function deleteFile(key, filename) { | |
| if (!confirm('确定删除文件「' + filename + '」?此操作不可恢复。')) return; | |
| try { | |
| await api('/u/api/files/' + encodeURIComponent(key), { method: 'DELETE' }); | |
| loadFiles(); | |
| } catch (e) { | |
| alert('删除失败:' + e.message); | |
| if (e.status === 401) { clearToken(); showAuth(); } | |
| } | |
| } | |
| // ===== 初始化 ===== | |
| window.addEventListener('DOMContentLoaded', function() { | |
| setupDragDrop(); | |
| autoLogin(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |