924 lines
35 KiB
HTML
924 lines
35 KiB
HTML
<!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>
|