const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
async fetch(request, env) {
const url = new URL(request.url);
const { pathname } = url;
// ---- Basic env checks ----
const botToken = env.BOT_TOKEN;
const chatId = env.CHAT_ID;
if (!botToken || !chatId || !env.UPLOAD_STORE) {
return new Response('Server not configured (missing BOT_TOKEN / CHAT_ID / UPLOAD_STORE).', { status: 500 });
// ======== ROUTES ========
if (request.method === 'GET' && pathname === '/') {
return noStoreHTML(getUploadFormWithProgress());
// 兼容旧路径:/files -> /admin
if (request.method === 'GET' && pathname === '/files') {
return Response.redirect(`${url.origin}/admin`, 302);
// 后台页面(Basic Auth) ——【标题一致】
if (request.method === 'GET' && pathname === '/admin') {
const auth = await requireAdmin(request, env);
if (auth instanceof Response) return auth;
return noStoreHTML(getAdminPage()); // 禁止缓存
// 后台:文件列表 JSON(Basic Auth)
if (request.method === 'GET' && pathname === '/api/files') {
const auth = await requireAdmin(request, env);
if (auth instanceof Response) return auth;
const sortBy = url.searchParams.get('sort') || 'date';
const order = url.searchParams.get('order') || 'desc';
const files = await listFiles(env.UPLOAD_STORE, sortBy, order);
return noStoreJSON({ files });
if (request.method === 'POST' && pathname === '/upload/init') {
try { payload = await request.json(); } catch { payload = {}; }
const { fileName, size, mime } = payload || {};
if (!fileName || typeof size !== 'number') {
return json({ error: 'bad init payload' }, 400);
const uploadId = crypto.randomUUID();
uploadId, fileName, mime, size,
parts: [], createdAt: new Date().toISOString(), finished: false
await env.UPLOAD_STORE.put(`upload:${uploadId}`, JSON.stringify(manifest));
return json({ uploadId, chunkSize: CHUNK_SIZE });
// 上传分片/整文件(前端可能根据规则只发 1 片)
if (request.method === 'POST' && pathname === '/upload/chunk') {
const form = await request.formData();
const uploadId = form.get('uploadId');
const index = Number(form.get('index'));
const total = Number(form.get('total'));
const chunk = form.get('chunk');
const fName = form.get('fileName') || 'file';
if (!uploadId || Number.isNaN(index) || Number.isNaN(total) || !chunk) {
return json({ error: 'bad chunk payload' }, 400);
const caption = `${fName} [part ${index + 1}/${total}] (${uploadId})`;
const tgResp = await postReq('sendDocument', [
{ chat_id: env.CHAT_ID },
{ disable_notification: 'true' }
const tgJson = await tgResp.json().catch(() => ({}));
if (!tgResp.ok || !tgJson.ok) {
return json({ error: 'telegram failed', detail: tgJson?.description || '' }, 502);
// —— 关键修复:只取“原始文件”的 file_id,避免误取到缩略图等导致合并错误/下载不完整
const msg = tgJson.result || {};
msg.animation?.file_id ||
(Array.isArray(msg.photo) && msg.photo.length ? msg.photo[msg.photo.length - 1].file_id : null);
// 兜底(非常少见):从递归遍历中取最后一个 file_id
const all = extractFileIds(tgJson);
fileIdCandidate = all[all.length - 1];
return json({ error: 'no file_id found in telegram response' }, 502);
const fileResp = await postReq('getFile', [{ file_id: fileIdCandidate }], botToken);
const fileJson = await fileResp.json().catch(() => ({}));
if (!fileResp.ok || !fileJson.ok) {
return json({ error: 'getFile failed' }, 502);
const key = `upload:${uploadId}`;
const raw = await env.UPLOAD_STORE.get(key);
if (!raw) return json({ error: 'upload not found' }, 404);
const manifest = JSON.parse(raw);
if (manifest.finished) return json({ error: 'already finished' }, 409);
file_id: fileIdCandidate,
file_path: fileJson.result.file_path,
size: chunk.size // 记录真实片大小,后续用于 Range 映射
const existingIdx = manifest.parts.findIndex(p => p.index === index);
if (existingIdx >= 0) manifest.parts[existingIdx] = partRecord;
else manifest.parts.push(partRecord);
await env.UPLOAD_STORE.put(key, JSON.stringify(manifest));
return json({ ok: true });
if (request.method === 'POST' && pathname === '/upload/complete') {
try { payload = await request.json(); } catch { payload = {}; }
const { uploadId, total } = payload || {};
const key = `upload:${uploadId}`;
const raw = await env.UPLOAD_STORE.get(key);
if (!raw) return json({ error: 'upload not found' }, 404);
const manifest = JSON.parse(raw);
if (manifest.parts.length !== total) {
return json({ error: 'parts mismatch', have: manifest.parts.length, expect: total }, 409);
manifest.parts.sort((a, b) => a.index - b.index);
manifest.finished = true;
await env.UPLOAD_STORE.put(key, JSON.stringify(manifest));
const recordKey = `file:${uploadId}`;
const totalSize = calcTotalSize(manifest);
title: manifest.fileName,
fileName: manifest.fileName,
fileType: getFileType(manifest.fileName || ''),
downloadUrl: `${url.origin}/download/${uploadId}`,
previewUrl: `${url.origin}/preview/${uploadId}`,
uploadDate: new Date().toISOString(),
chunked: manifest.parts.length > 1,
parts: manifest.parts.length,
mime: manifest.mime || 'application/octet-stream'
await env.UPLOAD_STORE.put(recordKey, JSON.stringify(display));
return json({ ok: true, downloadUrl: display.downloadUrl, previewUrl: display.previewUrl });
// 下载(attachment)——【完整下载 + 保留原文件名;支持 Range/断点】
if (request.method === 'GET' && pathname.startsWith('/download/')) {
const uploadId = pathname.split('/')[2];
return mergeAndRespond(uploadId, env, botToken, 'attachment', request);
// 预览(inline,支持视频播放器 Range 播放)
if (request.method === 'GET' && pathname.startsWith('/preview/')) {
const uploadId = pathname.split('/')[2];
return mergeAndRespond(uploadId, env, botToken, 'inline', request);
if (request.method === 'DELETE' && pathname.startsWith('/delete/')) {
const auth = await requireAdmin(request, env);
if (auth instanceof Response) return auth;
const key = decodeURIComponent(pathname.slice('/delete/'.length));
await env.UPLOAD_STORE.delete(key);
if (key.startsWith('file:')) {
const uploadId = key.slice('file:'.length);
await env.UPLOAD_STORE.delete(`upload:${uploadId}`);
return noStoreJSON({ success: true });
return new Response('Not Found', { status: 404 });
// ============= Helpers =============
function json(obj, status = 200) {
return new Response(JSON.stringify(obj), {
headers: { 'Content-Type': 'application/json; charset=utf-8' }
function noStoreJSON(obj, status = 200) {
return new Response(JSON.stringify(obj), {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
function noStoreHTML(html, status = 200) {
return new Response(html, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
// Basic Auth 校验:设置了 ADMIN_USER/ADMIN_PASS 才启用;未设置则放行
async function requireAdmin(request, env) {
if (!env.ADMIN_USER || !env.ADMIN_PASS) return true;
const hdr = request.headers.get('Authorization') || '';
const need = new Response('Unauthorized', {
headers: { 'WWW-Authenticate': 'Basic realm="Admin", charset="UTF-8"' }
if (!hdr.startsWith('Basic ')) return need;
const decoded = atob(hdr.slice(6));
const idx = decoded.indexOf(':');
if (idx < 0) return need;
const user = decoded.slice(0, idx);
const pass = decoded.slice(idx + 1);
if (user === env.ADMIN_USER && pass === env.ADMIN_PASS) return true;
function getFileType(fileName) {
const ext = (fileName.split('.').pop() || '').toLowerCase();
xls: 'Excel Spreadsheet',
xlsx: 'Excel Spreadsheet',
jpg: 'Image', jpeg: 'Image', png: 'Image', gif: 'Image', webp: 'Image',
zip: 'Archive', rar: 'Archive', '7z': 'Archive',
txt: 'Text File', md: 'Text File',
mp4: 'Video', webm: 'Video', mov: 'Video',
mp3: 'Audio', wav: 'Audio', m4a: 'Audio'
return map[ext] || 'Other';
async function listFiles(kv, sortBy = 'date', order = 'desc') {
const list = await kv.list();
for (const key of list.keys) {
if (!key.name.startsWith('file:')) continue;
const value = await kv.get(key.name);
if (value) files.push({ ...JSON.parse(value), key: key.name });
case 'name': return order === 'asc' ? a.title.localeCompare(b.title) : b.title.localeCompare(a.title);
case 'type': return order === 'asc' ? a.fileType.localeCompare(b.fileType) : b.fileType.localeCompare(a.fileType);
case 'size': return order === 'asc' ? (a.size - b.size) : (b.size - a.size);
? new Date(a.uploadDate) - new Date(b.uploadDate)
: new Date(b.uploadDate) - new Date(a.uploadDate);
async function postReq(url, fields, botToken) {
const formData = new FormData();
fields.forEach(obj => { for (let k in obj) formData.append(k, obj[k]); });
return fetch(`https://api.telegram.org/bot${botToken}/${url}`, { method: 'POST', body: formData });
function extractFileIds(obj) {
if (x && typeof x === 'object') {
if (x.file_id) out.push(x.file_id);
Object.values(x).forEach(dfs);
return [...new Set(out)];
// === 核心:合并并响应,支持 Range(在线播放/断点下载) ===
async function mergeAndRespond(uploadId, env, botToken, disposition, request) {
const manRaw = await env.UPLOAD_STORE.get(`upload:${uploadId}`);
if (!manRaw) return new Response('Not Found', { status: 404 });
const manifest = JSON.parse(manRaw);
if (!manifest.finished || !manifest.parts?.length) {
return new Response('File not ready', { status: 409 });
manifest.parts.sort((a, b) => a.index - b.index);
const totalSize = calcTotalSize(manifest);
const filename = manifest.fileName || `file-${uploadId}`;
const encoded = encodeRFC5987ValueChars(filename);
const mime = manifest.mime || 'application/octet-stream';
const rangeHeader = request.headers.get('Range');
let start = 0, end = totalSize ? totalSize - 1 : undefined;
if (rangeHeader && totalSize) {
const m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
const s = m[1] ? parseInt(m[1], 10) : 0;
const e = m[2] ? parseInt(m[2], 10) : (totalSize - 1);
if (Number.isFinite(s) && Number.isFinite(e) && s <= e && e < totalSize) {
start = s; end = e; status = 206;
const stream = new ReadableStream({
async start(controller) {
await pipeRangeFromParts(controller, manifest.parts, botToken, start, end);
const headers = new Headers({
'Accept-Ranges': 'bytes',
'Content-Disposition': `${disposition}; filename="${sanitizeFilename(filename)}"; filename*=UTF-8''${encoded}`,
'Cache-Control': disposition === 'inline' ? 'public, max-age=3600, immutable' : 'no-store'
if (typeof totalSize === 'number') {
headers.set('Content-Range', `bytes ${start}-${end}/${totalSize}`);
headers.set('Content-Length', String(end - start + 1));
headers.set('Content-Length', String(totalSize));
return new Response(stream, { status, headers });
// 将指定区间 [start, end] 从多个分片顺序写入 controller
async function pipeRangeFromParts(controller, parts, botToken, start, end) {
const ranges = parts.map(p => {
const size = Number(p.size) || 0;
const r = { part: p, start: offset, end: offset + size - 1, size };
for (const r of ranges) {
if (r.size <= 0) continue;
if (r.end < start) continue;
if (r.start > end) break;
const localStart = Math.max(0, start - r.start);
const localEnd = Math.min(r.size - 1, end - r.start);
const needBytes = localEnd - localStart + 1;
if (needBytes <= 0) continue;
const tgUrl = `https://api.telegram.org/file/bot${botToken}/${r.part.file_path}`;
// —— 优先尝试 Range 请求上游,若不支持则回退到 200 全量再本地跳过
const tryRange = localStart > 0 || localEnd < r.size - 1;
const rangeHeaders = tryRange ? { 'Range': `bytes=${localStart}-${localEnd}` } : undefined;
let resp = await fetch(tgUrl, { headers: rangeHeaders });
if (!resp.ok || !resp.body) {
resp = await fetch(tgUrl);
if (!resp.ok || !resp.body) throw new Error('fetch part failed');
const reader = resp.body.getReader();
let toSkip = resp.status === 206 ? 0 : localStart; // 若上游已 Range,就不需要本地跳过
// 跳过前缀(当上游 200 未 Range 时)
const { value, done } = await reader.read();
if (value.byteLength <= toSkip) {
toSkip -= value.byteLength;
const slice = value.slice(toSkip);
const toSend = Math.min(slice.byteLength, needBytes - sent);
if (toSend > 0) controller.enqueue(slice.slice(0, toSend));
try { await reader.cancel(); } catch {}
while (sent < needBytes) {
const { value, done } = await reader.read();
const toSend = Math.min(value.byteLength, needBytes - sent);
if (toSend > 0) controller.enqueue(value.slice(0, toSend));
if (toSend < value.byteLength || sent >= needBytes) {
try { await reader.cancel(); } catch {}
function calcTotalSize(manifest) {
if (manifest?.parts?.length) {
const s = manifest.parts.reduce((n, p) => n + (Number(p.size) || 0), 0);
return s || Number(manifest.size) || undefined;
return Number(manifest.size) || undefined;
function sanitizeFilename(name) {
// 保留原名用于 filename(尽量不转义),仅去除换行与引号
return String(name).replace(/[\r\n"]/g, '_');
function encodeRFC5987ValueChars(str) {
return encodeURIComponent(str)
.replace(/['()*]/g, c => '%' + c.charCodeAt(0).toString(16))
.replace(/%(7C|60|5E)/g, (m, hex) => '%' + hex.toLowerCase());
// ============= Frontend (HTML) =============
// 前台上传页(多文件 + 并发 + 智能分片;标题与站名一致)
function getUploadFormWithProgress() {
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>无限制容量网盘-更多项目:www.l42.cn</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
.upload-area { border: 2px dashed #4F46E5; }
.upload-area.dragging { background: rgba(79,70,229,0.1); }
.icon-btn { display:inline-flex; align-items:center; gap:.5rem; }
.ext-link { text-decoration: underline dotted; }
.file-row { border:1px solid rgba(255,255,255,.08); }
<body class="bg-gray-900 min-h-screen">
<div class="container mx-auto px-4 py-8">
<div class="max-w-3xl mx-auto bg-gray-800 rounded-lg shadow-xl p-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">无限制容量网盘-更多项目:www.l42.cn</h1>
<div class="flex items-center gap-3">
<a class="icon-btn text-gray-200 hover:text-white" href="https://github.com/ddnsmax/Unlimited-cloud-storage" target="_blank" rel="noopener">
${svgGithub()} <span class="hidden sm:inline ext-link">GitHub</span>
<a class="icon-btn text-gray-200 hover:text-white" href="https://www.l42.cn" target="_blank" rel="noopener">
${svgLink()} <span class="hidden sm:inline ext-link">开发者博客</span>
<form class="space-y-6" id="uploadForm">
<div class="upload-area rounded-lg p-8 text-center cursor-pointer" id="dropZone">
<span class="text-4xl">📁</span>
<p class="text-white text-lg">拖拽文件到这里,或点击选择(可多选)</p>
<p class="text-gray-400 text-sm">智能分片:视频≤50MB、图片≤10MB不分片;其余/超限按10MB分片</p>
<input type="file" name="file" id="fileInput" multiple class="hidden">
<div id="filesList" class="space-y-3"></div>
<div class="flex flex-wrap justify-between gap-3">
<button type="submit" id="uploadBtn" class="bg-indigo-600 text-white px-6 py-2 rounded hover:bg-indigo-700 transition disabled:opacity-50" disabled>
<a href="/admin" class="bg-gray-600 text-white px-6 py-2 rounded hover:bg-gray-700 transition">后台管理</a>
<div id="copyLinkSection" class="hidden mt-4">
<p class="text-white mb-2">上传完成的文件链接:</p>
<div id="linksWrap" class="grid gap-3"></div>
const CHUNK_SIZE = ${CHUNK_SIZE};
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const filesList = document.getElementById('filesList');
const uploadBtn = document.getElementById('uploadBtn');
const uploadForm = document.getElementById('uploadForm');
const copyLinkSection = document.getElementById('copyLinkSection');
const linksWrap = document.getElementById('linksWrap');
let selected = []; // {file, id, status, rowEl, progressEl, speedEl, done:false}
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragging'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragging'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault(); dropZone.classList.remove('dragging');
if (e.dataTransfer.files.length) {
addFiles([...e.dataTransfer.files]);
fileInput.addEventListener('change', (e) => { if (e.target.files.length) addFiles([...e.target.files]); });
function addFiles(files){
const id = crypto.randomUUID();
const row = document.createElement('div');
row.className = 'file-row bg-gray-700 p-4 rounded-lg';
<div class="flex items-center justify-between gap-3">
<div class="text-white font-medium truncate">\${f.name}</div>
<div class="text-gray-300 text-xs mt-1">\${formatFileSize(f.size)} · \${f.type || 'application/octet-stream'}</div>
<button type="button" class="text-red-300 hover:text-red-200 shrink-0" data-act="remove">移除</button>
<div class="w-full bg-gray-600 rounded h-3 overflow-hidden">
<div class="h-3 bg-indigo-500" style="width:0%"></div>
<div class="flex justify-between text-xs text-gray-300 mt-1">
<span class="status">待上传</span>
<span class="speed"></span>
filesList.appendChild(row);
const progressEl = row.querySelector('.h-3');
const statusEl = row.querySelector('.status');
const speedEl = row.querySelector('.speed');
row.addEventListener('click',(ev)=>{
if(ev.target && ev.target.getAttribute('data-act')==='remove'){
const idx = selected.findIndex(x=>x.id===id);
if(idx>=0 && !selected[idx].uploading){
uploadBtn.disabled = selected.filter(x=>!x.done).length===0;
selected.push({ file:f, id, rowEl:row, progressEl, statusEl, speedEl, done:false, uploading:false });
uploadBtn.disabled = selected.filter(x=>!x.done).length===0;
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024, sizes = ['Bytes','KB','MB','GB','TB'];
const i = Math.floor(Math.log(bytes)/Math.log(k));
return (bytes/Math.pow(k,i)).toFixed(2) + ' ' + sizes[i];
function humanSpeed(bps) {
const units = ['B/s','KB/s','MB/s','GB/s'];
while (bps >= 1024 && idx < units.length-1) { bps /= 1024; idx++; }
return bps.toFixed(1) + ' ' + units[idx];
function needChunk(file){
// 规则:视频≤50MB不分片;图片≤10MB不分片;其余/超限按10MB分片
const isVideo = (file.type||'').startsWith('video/');
const isImage = (file.type||'').startsWith('image/');
if (isVideo) return file.size > 50*1024*1024;
if (isImage) return file.size > 10*1024*1024;
async function uploadOne(entry){
entry.statusEl.textContent = '初始化...';
const initResp = await fetch('/upload/init', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: file.name, size: file.size, mime: file.type })
const initData = await initResp.json().catch(()=> ({}));
if (!initResp.ok || !initData.uploadId) throw new Error('初始化失败');
const uploadId = initData.uploadId;
const chunking = needChunk(file);
const chunkSize = chunking ? (initData.chunkSize || CHUNK_SIZE) : file.size; // 不分片:整个文件一次发送
const total = Math.ceil(file.size / chunkSize);
let lastTime = Date.now();
for (let i = 0; i < total; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const blob = file.slice(start, end);
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload/chunk');
if (xhr.status >= 200 && xhr.status < 300) {
uploadedBytes += blob.size;
} else reject(new Error('分片上传失败'));
xhr.onerror = () => reject(new Error('网络错误'));
xhr.upload.onprogress = (ev) => {
const currentSent = ev.lengthComputable ? ev.loaded : 0;
const overall = Math.min((uploadedBytes + currentSent) / file.size * 100, 100);
entry.progressEl.style.width = overall.toFixed(2) + '%';
entry.statusEl.textContent = \`\${overall.toFixed(2)}%\`;
if (now - lastTime >= 500) {
const bytesSince = (uploadedBytes + currentSent) - lastUploaded;
const bps = bytesSince / ((now - lastTime)/1000);
entry.speedEl.textContent = humanSpeed(bps);
lastTime = now; lastUploaded = uploadedBytes + currentSent;
const form = new FormData();
form.append('uploadId', uploadId);
form.append('index', String(i));
form.append('total', String(total));
form.append('fileName', file.name);
form.append('chunk', blob, chunking ? \`\${file.name}.part\${i}\` : file.name);
entry.statusEl.textContent = '合并登记...';
const completeResp = await fetch('/upload/complete', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, total })
const completeData = await completeResp.json().catch(()=> ({}));
if (!completeResp.ok || !completeData.downloadUrl) throw new Error('完成失败');
const item = document.createElement('div');
item.className = 'grid gap-1';
<div class="text-gray-200 text-sm">文件:<span class="font-medium">\${file.name}</span></div>
<div class="flex items-center gap-2">
<input type="text" value="\${completeData.downloadUrl}" readonly class="flex-1 bg-gray-700 text-white p-2 rounded">
<button type="button" class="copyBtn bg-indigo-600 text-white px-3 py-2 rounded hover:bg-indigo-700" data-val="\${completeData.downloadUrl}">复制下载</button>
<div class="flex items-center gap-2">
<input type="text" value="\${completeData.previewUrl}" readonly class="flex-1 bg-gray-700 text-white p-2 rounded">
<button type="button" class="copyBtn bg-indigo-600 text-white px-3 py-2 rounded hover:bg-indigo-700" data-val="\${completeData.previewUrl}">复制预览</button>
linksWrap.appendChild(item);
copyLinkSection.classList.remove('hidden');
item.querySelectorAll('.copyBtn').forEach(b=>b.addEventListener('click',()=>navigator.clipboard.writeText(b.getAttribute('data-val')).then(()=>alert('已复制到剪贴板'))));
entry.statusEl.textContent = '完成';
entry.speedEl.textContent = '';
function limitConcurrency(tasks, limit=3){
return new Promise((resolve,reject)=>{
if(i>=tasks.length && running===0) return resolve(results);
while(running<limit && i<tasks.length){
tasks[cur]().then(r=>{ results[cur]=r; running--; next(); }).catch(e=>{ running--; console.error(e); next(); });
uploadForm.addEventListener('submit', async (e) => {
if (uploadBtn.dataset.running === '1') return;
const pending = selected.filter(x=>!x.done && !x.uploading);
if (!pending.length) return;
uploadBtn.dataset.running = '1';
uploadBtn.disabled = true;
const tasks = pending.map(entry => async ()=> {
try { await uploadOne(entry); } catch(e){ entry.statusEl.textContent = '失败'; entry.uploading=false; console.error(e); }
await limitConcurrency(tasks, 3);
// 允许再次手动点击重新上传未完成/失败的条目;不自动清空已选文件
uploadBtn.dataset.running = '0';
uploadBtn.disabled = selected.filter(x=>!x.done).length===0;
document.addEventListener('click',(e)=>{
const tgt = e.target.closest('[data-target]');
const id = tgt.getAttribute('data-target');
const val = document.getElementById(id)?.value;
if(val) navigator.clipboard.writeText(val).then(()=>alert('已复制到剪贴板'));
// 后台管理页(尺寸统一;标题与模块左边对齐在一条线上)
function getAdminPage() {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>无限制容量网盘-更多项目:www.l42.cn</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
.upload-area { border: 2px dashed #4F46E5; }
.upload-area.dragging { background: rgba(79,70,229,0.1); }
.card { max-width: 64rem; } /* 统一模块最大宽度 */
/* —— 新增:让标题容器与卡片同宽,并补齐与卡片相同的水平内边距,实现左侧完全对齐 */
.title-wrap { max-width: 64rem; margin: 0 auto; padding-left: 1.5rem; padding-right: 1.5rem; }
<body class="bg-gray-900 min-h-screen">
<div class="container mx-auto px-4 py-8">
<div class="title-wrap flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">无限制容量网盘-更多项目:www.l42.cn</h1>
<a class="inline-flex items-center gap-2 text-gray-200 hover:text-white" href="/" title="前台上传">前台上传</a>
<!-- 管理上传(支持多选并发;尺寸统一) -->
<div class="card bg-gray-800 rounded-lg shadow p-6 mb-8 mx-auto">
<h2 class="text-white font-semibold mb-4">上传文件(可多选)</h2>
<form class="space-y-4" id="uploadForm">
<div class="upload-area rounded-lg p-6 text-center cursor-pointer" id="dropZone">
<span class="text-3xl">📁</span>
<p class="text-white">拖拽或点击选择文件(可多选,智能分片)</p>
<p class="text-gray-400 text-xs">视频≤50MB、图片≤10MB不分片;其余/超限按10MB分片</p>
<input type="file" id="fileInput" multiple class="hidden">
<div id="filesList" class="space-y-3"></div>
<button type="submit" id="uploadBtn" class="bg-indigo-600 text-white px-5 py-2 rounded hover:bg-indigo-700 disabled:opacity-50" disabled>开始上传</button>
<button type="button" id="refreshBtn" class="bg-gray-700 text-white px-4 py-2 rounded hover:bg-gray-600">刷新列表</button>
<div id="links" class="hidden text-sm text-gray-300 mt-3"></div>
<div class="card bg-gray-800 rounded-lg shadow p-6 mb-4 mx-auto">
<div class="flex flex-wrap gap-4 items-center">
<div class="text-white font-semibold">文件列表</div>
<div class="text-sm text-gray-300">排序:
<select id="sortBy" class="bg-gray-700 text-white rounded px-2 py-1">
<option value="date">日期</option>
<option value="name">名称</option>
<option value="type">类型</option>
<option value="size">大小</option>
<select id="order" class="bg-gray-700 text-white rounded px-2 py-1">
<option value="desc">↓</option>
<option value="asc">↑</option>
<div class="card mx-auto">
<div id="list" class="grid gap-4"></div>
<div id="empty" class="hidden text-center text-gray-300 py-12">暂无文件</div>
const listEl = document.getElementById('list');
const emptyEl = document.getElementById('empty');
const sortByEl = document.getElementById('sortBy');
const orderEl = document.getElementById('order');
const refreshBtn = document.getElementById('refreshBtn');
sortByEl.addEventListener('change', load);
orderEl.addEventListener('change', load);
refreshBtn.addEventListener('click', load);
const sort = sortByEl.value;
const order = orderEl.value;
const noCache = 't=' + Date.now(); // 强制避开缓存
const resp = await fetch(\`/api/files?sort=\${sort}&order=\${order}&\${noCache}\`, { cache: 'no-store' });
const data = await resp.json().catch(()=>({files:[]}));
const files = data.files || [];
if(!files.length){ emptyEl.classList.remove('hidden'); return; }
emptyEl.classList.add('hidden');
listEl.innerHTML = files.map(renderItem).join('');
const size = formatFileSize(f.size||0);
const uploaded = new Date(f.uploadDate).toLocaleString();
const icon = getIcon(f.fileType);
const previewUrl = f.previewUrl || ('/preview/'+f.uploadId);
<div class="bg-gray-800 p-5 rounded-lg shadow">
<div class="text-4xl">\${icon}</div>
<div class="flex-1 min-w-0">
<div class="text-white font-semibold truncate">\${f.title}</div>
<div class="text-gray-400 text-sm mt-1 space-y-1">
<div>类型:\${f.fileType}</div>
<div>时间:\${uploaded}</div>
<div>分片:\${f.parts || 1}</div>
<div class="mt-3 flex flex-wrap gap-2">
<a class="bg-indigo-600 text-white px-3 py-1 rounded hover:bg-indigo-700" href="\${previewUrl}" target="_blank" rel="noopener">预览</a>
<a class="bg-gray-700 text-white px-3 py-1 rounded hover:bg-gray-600" href="\${f.downloadUrl}" target="_blank" rel="noopener">下载</a>
<button class="bg-green-700 text-white px-3 py-1 rounded hover:bg-green-600" onclick="copy('\${f.downloadUrl}')">复制下载链接</button>
<button class="bg-red-700 text-white px-3 py-1 rounded hover:bg-red-600" onclick="del('\${encodeURIComponent(f.key)}')">删除</button>
'PDF Document':'📄','Word Document':'📝','Excel Spreadsheet':'📊','Image':'🖼️',
'Archive':'📦','Text File':'📃','Video':'🎥','Audio':'🎵','Other':'📎'
function formatFileSize(bytes){
if (!bytes) return '0 Bytes';
const k = 1024; const sizes = ['Bytes','KB','MB','GB','TB'];
const i = Math.floor(Math.log(bytes)/Math.log(k));
return (bytes/Math.pow(k,i)).toFixed(2) + ' ' + sizes[i];
if(!confirm('确认删除该文件及其上传记录?')) return;
await fetch('/delete/'+key, { method:'DELETE', cache:'no-store' });
function copy(text){ navigator.clipboard.writeText(text).then(()=>alert('已复制')); }
// ========== 管理页多文件并发上传(与前台一致的规则,不清空选择,防重复提交) ==========
const dz = document.getElementById('dropZone');
const fi = document.getElementById('fileInput');
const form = document.getElementById('uploadForm');
const filesList = document.getElementById('filesList');
const ub = document.getElementById('uploadBtn');
const links = document.getElementById('links');
dz.addEventListener('click', ()=>fi.click());
dz.addEventListener('dragover', e=>{ e.preventDefault(); dz.classList.add('dragging'); });
dz.addEventListener('dragleave', ()=>dz.classList.remove('dragging'));
dz.addEventListener('drop', e=>{
e.preventDefault(); dz.classList.remove('dragging');
if(e.dataTransfer.files.length){ addFiles([...e.dataTransfer.files]); }
fi.addEventListener('change', ()=>{ if(fi.files.length) addFiles([...fi.files]); });
function addFiles(files){
const id = crypto.randomUUID();
const row = document.createElement('div');
row.className = 'bg-gray-700 p-4 rounded-lg file-row';
<div class="flex items-center justify-between gap-3">
<div class="text-white font-medium truncate">\${f.name}</div>
<div class="text-gray-300 text-xs mt-1">\${formatFileSize(f.size)} · \${f.type || 'application/octet-stream'}</div>
<button type="button" class="text-red-300 hover:text-red-200 shrink-0" data-act="remove">移除</button>
<div class="w-full bg-gray-600 rounded h-3 overflow-hidden">
<div class="h-3 bg-indigo-500" style="width:0%"></div>
<div class="flex justify-between text-xs text-gray-300 mt-1">
<span class="status">待上传</span>
<span class="speed"></span>
filesList.appendChild(row);
const progressEl = row.querySelector('.h-3');
const statusEl = row.querySelector('.status');
const speedEl = row.querySelector('.speed');
row.addEventListener('click',(ev)=>{
if(ev.target && ev.target.getAttribute('data-act')==='remove'){
const idx = selected.findIndex(x=>x.id===id);
if(idx>=0 && !selected[idx].uploading){
ub.disabled = selected.filter(x=>!x.done).length===0;
selected.push({ file:f, id, rowEl:row, progressEl, statusEl, speedEl, done:false, uploading:false });
ub.disabled = selected.filter(x=>!x.done).length===0;
function needChunk(file){
const isVideo = (file.type||'').startsWith('video/');
const isImage = (file.type||'').startsWith('image/');
if (isVideo) return file.size > 50*1024*1024;
if (isImage) return file.size > 10*1024*1024;
async function uploadOne(entry){
entry.statusEl.textContent = '初始化...';
const initResp = await fetch('/upload/init',{ method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ fileName:file.name, size:file.size, mime:file.type })});
const initData = await initResp.json().catch(()=> ({}));
if(!initResp.ok || !initData.uploadId) throw new Error('初始化失败');
const uploadId = initData.uploadId;
const chunking = needChunk(file);
const chunkSize = chunking ? (initData.chunkSize || ${CHUNK_SIZE}) : file.size;
const total = Math.ceil(file.size/chunkSize);
let uploadedBytes = 0, lastTime=Date.now(), lastUploaded=0;
for(let i=0;i<total;i++){
const start=i*chunkSize, end=Math.min(start+chunkSize, file.size);
const blob=file.slice(start,end);
await new Promise((resolve,reject)=>{
const xhr=new XMLHttpRequest();
xhr.open('POST','/upload/chunk');
xhr.onload=()=>{ if(xhr.status>=200 && xhr.status<300){ uploadedBytes+=blob.size; resolve(); } else reject(new Error('chunk failed')); }
xhr.onerror=()=>reject(new Error('network'));
xhr.upload.onprogress=(ev)=>{
const current = ev.lengthComputable ? ev.loaded : 0;
const pct = Math.min((uploadedBytes + current)/file.size*100, 100);
entry.progressEl.style.width = pct.toFixed(2)+'%'; entry.statusEl.textContent = pct.toFixed(2)+'%';
const now=Date.now(); if(now-lastTime>=500){ const bps=((uploadedBytes+current)-lastUploaded)/((now-lastTime)/1000); entry.speedEl.textContent=humanSpeed(bps); lastTime=now; lastUploaded=uploadedBytes+current; }
const form=new FormData();
form.append('uploadId', uploadId);
form.append('index', String(i));
form.append('total', String(total));
form.append('fileName', file.name);
form.append('chunk', blob, chunking ? \`\${file.name}.part\${i}\` : file.name);
entry.statusEl.textContent = '合并登记...';
const completeResp = await fetch('/upload/complete', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ uploadId, total })});
const done = await completeResp.json().catch(()=> ({}));
if(!completeResp.ok || !done.downloadUrl){ entry.statusEl.textContent='失败'; entry.uploading=false; throw new Error('完成失败'); }
links.classList.remove('hidden');
const line = document.createElement('div');
line.innerHTML = \`<div class="mt-2">下载:<a class="text-indigo-400 underline" href="\${done.downloadUrl}" target="_blank" rel="noopener">\${done.downloadUrl}</a></div>
<div>预览:<a class="text-indigo-400 underline" href="\${done.previewUrl}" target="_blank" rel="noopener">\${done.previewUrl}</a></div>\`;
entry.statusEl.textContent = '完成';
entry.speedEl.textContent = '';
function humanSpeed(bps){
const units=['B/s','KB/s','MB/s','GB/s']; let i=0;
while(bps>=1024 && i<units.length-1){ bps/=1024; i++; }
return bps.toFixed(1)+' '+units[i];
function limitConcurrency(tasks, limit=3){
return new Promise((resolve)=>{
if(i>=tasks.length && running===0) return resolve();
while(running<limit && i<tasks.length){
const cur=i++; running++;
tasks[cur]().finally(()=>{ running--; next(); });
form.addEventListener('submit', async (e)=>{
if(ub.dataset.running==='1') return;
const pending = selected.filter(x=>!x.done && !x.uploading);
if(!pending.length) return;
ub.dataset.running='1'; ub.disabled = true;
const tasks = pending.map(entry=>()=>uploadOne(entry));
await limitConcurrency(tasks, 3);
ub.disabled = selected.filter(x=>!x.done).length===0;
window.addEventListener('load', load);
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#e5e7eb" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .5A11.5 11.5 0 0 0 .5 12.4c0 5.26 3.41 9.72 8.15 11.3.6.11.82-.27.82-.6l-.01-2.1c-3.32.74-4.02-1.6-4.02-1.6-.55-1.43-1.35-1.8-1.35-1.8-1.1-.78.08-.76.08-.76 1.22.09 1.86 1.29 1.86 1.29 1.08 1.86 2.83 1.32 3.52 1.01.11-.8.42-1.33.77-1.63-2.65-.31-5.44-1.36-5.44-6.07 0-1.34.47-2.43 1.25-3.29-.13-.31-.54-1.56.12-3.26 0 0 1.01-.33 3.3 1.26.96-.27 1.99-.41 3.01-.42 1.02.01 2.05.15 3.01.42 2.29-1.59 3.3-1.26 3.3-1.26.66 1.7.25 2.95.12 3.26.78.86 1.25 1.95 1.25 3.29 0 4.72-2.8 5.75-5.47 6.06.43.37.82 1.1.82 2.22l-.01 3.29c0 .33.22.72.83.6A11.51 11.51 0 0 0 23.5 12.4 11.5 11.5 0 0 0 12 .5z"/></svg>`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#e5e7eb" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.9 12a5 5 0 0 1 5-5h3v2h-3a3 3 0 0 0 0 6h3v2h-3a5 5 0 0 1-5-5Zm7-1h3a3 3 0 0 1 0 6h-3v-2h3a1 1 0 1 0 0-2h-3v-2Zm0-2V7h3a5 5 0 0 1 0 10h-3v-2"/></svg>`;