xtc-backend / app /user_html.py
a3216's picture
sync from GitHub 231252d: feat: 增加文件别名映射支持,优化备份下载与恢复流程
a3f0e3f verified
Raw
History Blame Contribute Delete
20 kB
"""用户文件管理页面 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"])
@router.get("/u", response_class=HTMLResponse)
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
"""