Files
labweb/supabase-stack/examples/upload.html

924 lines
35 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>大文件上传 - Supabase Storage + TUS修复版</title>
<!-- 优先使用本地缓存的 Supabase JS避免 CDN 不可用CDN 作为兜底 -->
<script src="supabase.js"></script>
<script>
if (!window.supabase) {
document.write('<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"><\/script>');
}
</script>
<!-- 同样本地优先,再回退 CDN -->
<script src="tus.min.js"></script>
<script>
if (typeof tus === 'undefined') {
document.write('<script src="https://cdn.jsdelivr.net/npm/tus-js-client@3.1.1/dist/tus.min.js"><\/script>');
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
margin-bottom: 20px;
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
}
.header h1 {
font-size: 28px;
margin-bottom: 8px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.content {
padding: 30px;
}
.user-info {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.user-info .info {
font-size: 14px;
color: #495057;
}
.user-info strong {
color: #212529;
}
.btn-logout {
background: #dc3545;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.btn-logout:hover {
background: #c82333;
}
.upload-area {
border: 3px dashed #667eea;
border-radius: 12px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 20px;
}
.upload-area:hover {
background: #f8f9ff;
border-color: #764ba2;
}
.upload-area.dragover {
background: #e8ebff;
border-color: #764ba2;
}
.upload-icon {
font-size: 48px;
margin-bottom: 16px;
}
.upload-text {
font-size: 16px;
color: #333;
margin-bottom: 8px;
}
.upload-hint {
font-size: 13px;
color: #6c757d;
}
input[type="file"] {
display: none;
}
.file-list {
margin-top: 20px;
}
.file-item {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
margin-bottom: 12px;
}
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.file-name {
font-weight: 600;
color: #212529;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 12px;
}
.file-size {
font-size: 13px;
color: #6c757d;
white-space: nowrap;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
}
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #6c757d;
}
.status {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.status.uploading {
background: #cfe2ff;
color: #084298;
}
.status.success {
background: #d1e7dd;
color: #0f5132;
}
.status.error {
background: #f8d7da;
color: #842029;
}
.btn-cancel {
background: #6c757d;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-left: 8px;
}
.btn-cancel:hover {
background: #5a6268;
}
.message {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="header">
<h1>📤 大文件上传(修复版)</h1>
<p>支持断点续传 · TUS协议 · 中文文件名优化</p>
</div>
<div class="content">
<!-- 用户信息 -->
<div class="user-info">
<div class="info">
<p>👤 <strong id="userEmail">未登录</strong></p>
<p style="font-size: 12px; margin-top: 4px;">🔑 User ID: <span id="userId">N/A</span></p>
</div>
<button class="btn-logout" style="background:#2563eb;" title="查看我的文件(下载/删除)" onclick="window.location.href='manage.html'">📂 文件管理</button>
<button class="btn-logout" onclick="logout()">🚪 退出</button>
</div>
<!-- 上传区域 -->
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📁</div>
<div class="upload-text">点击选择或拖拽文件到此处</div>
<div class="upload-hint">支持各种格式 · 无大小限制 · 已修复中文文件名问题</div>
</div>
<input type="file" id="fileInput" multiple>
<!-- 存储桶选择 -->
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #333; font-size: 14px;">
📦 上传到存储桶 (固定 test-public):
</label>
<select id="bucketSelect" style="width: 100%; padding: 10px; border: 2px solid #e1e8ed; border-radius: 8px; font-size: 14px;">
<option value="">test-public</option>
</select>
</div>
<!-- 文件列表 -->
<div class="file-list" id="fileList"></div>
</div>
</div>
</div>
<script>
// ========================================
// 工具函数 - Unicode安全的Base64编码
// ========================================
function safeBtoa(str) {
try {
// 处理Unicode字符
const utf8Bytes = new TextEncoder().encode(str);
const base64 = btoa(String.fromCharCode(...utf8Bytes));
return base64;
} catch (e) {
// 降级方案
return btoa(unescape(encodeURIComponent(str)));
}
}
// 验证Base64编码是否正确
function validateBase64(original, encoded) {
try {
const decoded = atob(encoded);
const originalUtf8 = new TextEncoder().encode(original);
const decodedUtf8 = new TextEncoder().encode(decoded);
return JSON.stringify([...originalUtf8]) === JSON.stringify([...decodedUtf8]);
} catch (e) {
return false;
}
}
// 清理文件名 - 移除TUS不支持的字符
function sanitizeFileName(fileName) {
// 移除或替换可能导致TUS验证失败的字符
return fileName
.replace(/[^\u0000-\u007F]/g, '') // 移除非ASCII字符
.replace(/[^a-zA-Z0-9._-]/g, '-') // 将其他字符替换为连字符
.replace(/-+/g, '-') // 合并多个连字符
.replace(/^-|-$/g, '') // 移除开头和结尾的连字符
.toLowerCase(); // 转换为小写
}
// 生成安全的文件路径
function generateSafeFilePath(userId, fileName) {
// 清理文件名
const sanitizedName = sanitizeFileName(fileName);
// 如果清理后文件名为空,使用时间戳
const finalName = sanitizedName || `file-${Date.now()}.bin`;
// 构建路径
return `${userId}/${finalName}`;
}
// ========================================
// 配置区域 - 支持本地测试和生产环境
// ========================================
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const SUPABASE_URL = 'https://amiap.hzau.edu.cn/supa';
const STORAGE_ENDPOINT = `${SUPABASE_URL}/storage/v1/upload/resumable`;
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzYzODAyNjY5LCJleHAiOjIwNzkxNjI2Njl9.ltGXvQKpguLaf8Vzomn310hLgOZbrjqZT-F3rR00ulg';
const DEFAULT_BUCKET = 'test-public';
console.log('🌐 当前环境:', isLocalhost ? '本地测试' : '生产环境');
console.log('🔗 Supabase URL:', SUPABASE_URL);
// 初始化 Supabase 客户端
const supabase = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
let currentUser = null;
let uploads = new Map(); // 存储上传实例
// ========================================
// 跨域配置检测
// ========================================
function checkCorsSupport() {
if (isLocalhost) {
console.log('🌐 本地测试模式将通过HTTPS访问生产API');
return true;
}
if (window.location.protocol !== 'https:') {
console.warn('⚠️ 当前页面未使用HTTPS可能影响某些功能');
}
console.log('✅ CORS配置检测完成');
return true;
}
// 清理浏览器缓存的 TUS 指纹,避免指向旧端点(含 8000 端口)
function clearTusCache() {
try {
if (!window.localStorage) return;
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('tus::')) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(k => localStorage.removeItem(k));
if (keysToRemove.length) {
console.log('🧹 已清理 TUS 指纹缓存:', keysToRemove.length);
}
} catch (e) {
console.warn('⚠️ 清理 TUS 指纹缓存失败:', e);
}
}
// 修正 TUS Location移除 8000 端口和重复斜杠
function sanitizeLocation(rawLocation) {
try {
const base = SUPABASE_URL.endsWith('/') ? SUPABASE_URL : `${SUPABASE_URL}/`;
const url = rawLocation.startsWith('http') ? new URL(rawLocation) : new URL(rawLocation, base);
url.port = '';
// 确保最终路径形如 /supa/storage/v1/upload/resumable/<id>
let path = url.pathname.replace(/\/+/g, '/');
if (path.startsWith('/supa/upload/resumable')) {
path = path.replace('/supa/upload/resumable', '/supa/storage/v1/upload/resumable');
} else if (path.startsWith('/upload/resumable')) {
path = path.replace('/upload/resumable', '/supa/storage/v1/upload/resumable');
} else if (!path.startsWith('/supa/storage/v1/upload/resumable')) {
path = '/supa/storage/v1/upload/resumable' + (path.startsWith('/') ? '' : '/') + path;
}
url.pathname = path;
return url.toString();
} catch (e) {
console.warn('⚠️ 无法修正 TUS Location:', rawLocation, e);
return null;
}
}
// Fallback: 使用 Supabase Storage 直传(非 TUS解决 Location 端口异常时的上传
async function fallbackDirectUpload(file, bucketName, filePath, fileId, sessionToken) {
try {
console.warn('⚠️ TUS 失败,尝试直接上传');
const { data, error } = await supabase.storage.from(bucketName).upload(
filePath,
file,
{
upsert: true,
cacheControl: '3600',
contentType: file.type || 'application/octet-stream',
duplex: 'half',
headers: {
Authorization: `Bearer ${sessionToken || SUPABASE_ANON_KEY}`,
apikey: SUPABASE_ANON_KEY
}
}
);
if (error) throw error;
updateProgress(fileId, 100, file.size, file.size);
updateFileStatus(fileId, 'success', '✅ 上传完成(直传)');
const { data: urlData } = supabase.storage.from(bucketName).getPublicUrl(filePath);
if (urlData?.publicUrl) {
showMessage('info', `✅ 文件上传成功!<a href="${urlData.publicUrl}" target="_blank">点击查看</a>`);
}
} catch (err) {
console.error('❌ 直传失败:', err);
updateFileStatus(fileId, 'error', `❌ 直传失败: ${err.message || err}`);
throw err;
}
}
// ========================================
// 页面初始化
// ========================================
window.addEventListener('load', async () => {
checkCorsSupport();
clearTusCache();
await checkAuth();
await loadBuckets();
setupDragDrop();
setupFileInput();
});
// ========================================
// 检查登录状态
// ========================================
async function checkAuth() {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
if (isLocalhost) {
console.warn('⚠️ 本地测试模式:未检测到登录状态,使用匿名模式');
showMessage('info', ' 本地测试模式:使用匿名上传模式');
currentUser = {
id: '03680c29-ced5-4218-ad80-f9617ad2d7dc',
email: 'anonymous@localhost'
};
document.getElementById('userEmail').textContent = currentUser.email;
document.getElementById('userId').textContent = currentUser.id.substring(0, 8) + '...';
return;
}
alert('⚠️ 请先登录!即将跳转到登录页面...');
window.location.href = 'login.html';
return;
}
currentUser = session.user;
document.getElementById('userEmail').textContent = currentUser.email;
document.getElementById('userId').textContent = currentUser.id.substring(0, 8) + '...';
console.log('✅ 用户已登录:', currentUser.email);
console.log('🔑 Access Token:', session.access_token.substring(0, 20) + '...');
// 存储token供后续使用
localStorage.setItem('supabase_token', session.access_token);
localStorage.setItem('supabase_user', JSON.stringify(currentUser));
} catch (error) {
console.error('❌ 检查登录状态失败:', error);
if (isLocalhost) {
showMessage('warning', '⚠️ 本地测试模式:登录状态检查失败,使用匿名模式');
currentUser = {
id: '03680c29-ced5-4218-ad80-f9617ad2d7dc',
email: 'anonymous@localhost'
};
document.getElementById('userEmail').textContent = currentUser.email;
document.getElementById('userId').textContent = currentUser.id.substring(0, 8) + '...';
} else {
alert('❌ 登录状态检查失败,请重新登录');
window.location.href = 'login.html';
}
}
}
// ========================================
// 加载存储桶列表
// ========================================
async function loadBuckets() {
try {
console.log('📂 设置固定存储桶: test-public');
const select = document.getElementById('bucketSelect');
select.innerHTML = '';
const option = document.createElement('option');
option.value = DEFAULT_BUCKET;
option.textContent = `${DEFAULT_BUCKET} (固定)`;
select.appendChild(option);
select.value = DEFAULT_BUCKET;
select.disabled = true;
} catch (error) {
console.error('加载存储桶失败:', error);
showMessage('error', '❌ 无法加载存储桶: ' + error.message);
}
}
// ========================================
// 设置拖拽上传
// ========================================
function setupDragDrop() {
const uploadArea = document.getElementById('uploadArea');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
uploadArea.addEventListener(eventName, () => {
uploadArea.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, () => {
uploadArea.classList.remove('dragover');
});
});
uploadArea.addEventListener('drop', handleDrop);
}
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
handleFiles(files);
}
// ========================================
// 设置文件选择
// ========================================
function setupFileInput() {
document.getElementById('fileInput').addEventListener('change', (e) => {
handleFiles(e.target.files);
});
}
// ========================================
// 处理选择的文件
// ========================================
function handleFiles(files) {
const bucket = document.getElementById('bucketSelect').value;
if (!bucket) {
alert('❌ 请先选择存储桶');
return;
}
Array.from(files).forEach(file => {
uploadFile(file, bucket);
});
}
// ========================================
// TUS 上传文件(核心功能 - 修复版)
// ========================================
async function uploadFile(file, bucketName) {
console.log('🚀 开始上传文件:', file.name, '到存储桶:', bucketName);
const fileId = Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// 创建文件显示
const fileItem = createFileItem(file, fileId);
document.getElementById('fileList').appendChild(fileItem);
// 验证存储桶是否已选择
if (!bucketName) {
console.error('❌ 未选择存储桶');
updateFileStatus(fileId, 'error', '❌ 请先选择存储桶');
return;
}
console.log('📋 上传配置:', { fileName: file.name, bucketName, fileId });
// 获取当前 session token
let sessionToken = null;
// 统一获取token逻辑
try {
const { data: { session } } = await supabase.auth.getSession();
if (session && session.access_token) {
sessionToken = session.access_token;
console.log('🔑 使用JWT Token认证');
} else {
// 无有效session使用匿名模式
sessionToken = SUPABASE_ANON_KEY;
console.log('🔓 使用匿名认证模式');
}
} catch (error) {
console.log('⚠️ 获取token失败使用匿名模式');
sessionToken = SUPABASE_ANON_KEY;
}
// 使用修复后的文件路径生成函数
const filePath = generateSafeFilePath(currentUser.id, file.name);
// 准备元数据 - TUS库会自动进行Base64编码我们只需要提供原始字符串
const metadata = {
bucketName: bucketName,
objectName: filePath,
contentType: file.type || 'application/octet-stream',
cacheControl: '3600'
};
console.log('🔍 TUS元数据准备:', {
original: { bucketName, filePath, contentType: file.type || 'application/octet-stream', cacheControl: '3600' },
note: 'TUS库会自动进行Base64编码无需手动编码',
filePathSanitized: filePath,
originalFileName: file.name
});
console.log('📤 准备上传:', {
file: file.name,
size: formatBytes(file.size),
bucket: bucketName,
path: filePath,
tokenType: sessionToken === SUPABASE_ANON_KEY ? '匿名' : 'JWT',
isLocalhost: isLocalhost,
metadataReady: true
});
// TUS 上传配置
const upload = new tus.Upload(file, {
// TUS endpoint - 通过 Supabase Storage
endpoint: STORAGE_ENDPOINT,
storeFingerprintForResuming: false, // 不持久化指纹
removeFingerprintOnSuccess: true,
urlStorage: tus.defaultUrlStorage ? new tus.MemoryStorage() : undefined, // 内存存储指纹
onBeforeRequest: function(req) {
// 每次请求前强制修正 URL避免 :8000 以及缺少 /storage/v1 的路径
const raw = req.getURL && req.getURL();
const fixed = raw ? sanitizeLocation(raw) : null;
if (fixed && fixed !== raw) {
// tus-js-client 的 XHR 栈没有 setURL这里直接重写内部字段并重新 open
if (req._url !== undefined) req._url = fixed;
if (req._xhr && req._method) {
try {
// 重新 open 后 tus 之前设置的 Header 会丢失,手动补回
req._xhr.open(req._method, fixed, true);
const headers = req._headers || {};
Object.entries(headers).forEach(([k, v]) => {
try {
req._xhr.setRequestHeader(k, v);
} catch (e) {
console.warn('⚠️ 重新设置请求头失败:', k, e);
}
});
} catch (err) {
console.warn('⚠️ 重新绑定请求 URL 失败:', err);
}
}
upload.url = fixed; // 覆盖实例上的 url后续 PATCH 使用正确路径
console.log('🔧 已修正请求 URL:', fixed);
}
},
onAfterResponse: function(req, res) {
// 修正 Location 里可能带的错误前缀(如 /supa/upload/resumable
if (req.getMethod && req.getMethod() === 'POST') {
const rawLocation = res.getHeader && res.getHeader('Location');
if (rawLocation) {
const fixed = sanitizeLocation(rawLocation);
if (fixed) {
upload.url = fixed;
if (upload._url !== undefined) {
upload._url = fixed; // 覆盖内部 url确保后续 PATCH 使用正确路径
}
console.log('🔗 修正后的 TUS Location:', fixed);
}
}
}
},
// 重试配置
retryDelays: [0, 1000, 3000, 5000],
// 元数据 - TUS协议要求Base64编码
metadata: metadata,
// 上传分块大小6MB
chunkSize: 6 * 1024 * 1024,
// 请求头 - 统一使用Bearer认证方式
headers: {
'Authorization': `Bearer ${sessionToken || SUPABASE_ANON_KEY}`,
'apikey': SUPABASE_ANON_KEY,
'x-upsert': 'true',
'Content-Type': 'application/offset+octet-stream'
},
// 事件回调
onError: function(error) {
console.error('❌ 上传失败:', error);
console.error('📋 错误详情:', {
originalRequest: error.originalRequest,
originalResponse: error.originalResponse,
causingError: error.causingError,
message: error.message
});
let errorMessage = '上传失败';
if (error.originalResponse) {
const status = error.originalResponse.getStatus && error.originalResponse.getStatus();
const statusText = error.originalResponse.getStatusText && error.originalResponse.getStatusText();
errorMessage = `❌ 上传失败 (${status || '未知'}: ${statusText || error.message})`;
} else {
errorMessage = `${error.message || '未知错误'}`;
}
console.error('🎯 最终错误信息:', errorMessage);
// 尝试降级为 Supabase 直传(非 TUS避免 8000 端口问题
fallbackDirectUpload(file, bucketName, filePath, fileId, sessionToken)
.catch(() => {
updateFileStatus(fileId, 'error', errorMessage);
});
},
onProgress: function(bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(1);
updateProgress(fileId, percentage, bytesUploaded, bytesTotal);
},
onSuccess: function() {
console.log('✅ 上传完成:', filePath);
console.log('📊 上传成功详情:', {
bucket: bucketName,
path: filePath,
fileName: file.name,
originalFileName: file.name,
sanitizedPath: filePath
});
updateFileStatus(fileId, 'success', '✅ 上传完成');
// 获取文件 URL
try {
const { data } = supabase.storage
.from(bucketName)
.getPublicUrl(filePath);
console.log('📎 文件 URL:', data.publicUrl);
// 显示文件链接
showMessage('info', `✅ 文件上传成功!<a href="${data.publicUrl}" target="_blank">点击查看</a>`);
} catch (urlError) {
console.warn('⚠️ 获取文件URL失败:', urlError);
}
}
});
// 保存上传实例以便取消
uploads.set(fileId, upload);
// 开始上传
upload.start();
}
// ========================================
// 创建文件显示项
// ========================================
function createFileItem(file, fileId) {
const div = document.createElement('div');
div.className = 'file-item';
div.id = `file-${fileId}`;
div.innerHTML = `
<div class="file-header">
<div class="file-name" title="${file.name}">📄 ${file.name}</div>
<div class="file-size">${formatBytes(file.size)}</div>
<button class="btn-cancel" onclick="cancelUpload('${fileId}')">取消</button>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-${fileId}" style="width: 0%"></div>
</div>
<div class="progress-info">
<span id="status-${fileId}" class="status uploading">⏳ 准备中...</span>
<span id="speed-${fileId}">0 MB/s</span>
</div>
`;
return div;
}
// ========================================
// 更新上传进度
// ========================================
let lastUpdate = {};
function updateProgress(fileId, percentage, bytesUploaded, bytesTotal) {
document.getElementById(`progress-${fileId}`).style.width = percentage + '%';
document.getElementById(`status-${fileId}`).textContent = `⏳ 上传中 ${percentage}%`;
// 计算上传速度
const now = Date.now();
if (!lastUpdate[fileId]) {
lastUpdate[fileId] = { time: now, bytes: 0 };
}
const timeDiff = (now - lastUpdate[fileId].time) / 1000;
if (timeDiff >= 1) {
const bytesDiff = bytesUploaded - lastUpdate[fileId].bytes;
const speed = bytesDiff / timeDiff;
document.getElementById(`speed-${fileId}`).textContent = formatBytes(speed) + '/s';
lastUpdate[fileId] = { time: now, bytes: bytesUploaded };
}
}
// ========================================
// 更新文件状态
// ========================================
function updateFileStatus(fileId, type, message) {
const statusEl = document.getElementById(`status-${fileId}`);
statusEl.className = `status ${type}`;
statusEl.textContent = message;
if (type === 'success' || type === 'error') {
const btn = document.querySelector(`#file-${fileId} .btn-cancel`);
if (btn) btn.remove();
}
if (type === 'error') {
const speedEl = document.getElementById(`speed-${fileId}`);
if (speedEl) speedEl.textContent = '0 MB/s';
console.error('❌ 状态更新为错误:', message);
}
}
// ========================================
// 取消上传
// ========================================
function cancelUpload(fileId) {
const upload = uploads.get(fileId);
if (upload) {
upload.abort();
uploads.delete(fileId);
updateFileStatus(fileId, 'error', '❌ 已取消');
}
}
// ========================================
// 退出登录
// ========================================
async function logout() {
await supabase.auth.signOut();
localStorage.removeItem('supabase_token');
localStorage.removeItem('supabase_user');
window.location.href = 'login.html';
}
// ========================================
// 工具函数
// ========================================
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function showMessage(type, message) {
const div = document.createElement('div');
div.className = `message ${type}`;
div.textContent = message;
document.querySelector('.content').insertBefore(div, document.querySelector('.upload-area'));
setTimeout(() => div.remove(), 5000);
}
</script>
</body>
</html>