copyparty/copyparty/web/browser.html
mengshon1-boop d5238a9776 feat(web): 改进上传进度显示和错误处理
重构上传进度UI,增加更详细的传输信息和动画效果
优化重试机制和错误处理逻辑,提升上传稳定性
添加文件大小格式化和ETA计算功能
2026-04-25 20:37:43 +08:00

760 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en" id="ht_brw">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=0.8, minimum-scale=0.6">
<meta name="theme-color" content="#{{ tcolor }}">
<link rel="stylesheet" media="screen" href="{{ r }}/.cpr/w/ui.css?_={{ ts }}">
<link rel="stylesheet" media="screen" href="{{ r }}/.cpr/w/browser.css?_={{ ts }}">
{{ html_head }}
{%- if css %}
<link rel="stylesheet" media="screen" href="{{ css }}_={{ ts }}">
{%- endif %}
</head>
<body>
<div id="ops"></div>
<div id="op_search" class="opview">
{%- if have_tags_idx %}
<div id="srch_form" class="tags opbox"></div>
{%- else %}
<div id="srch_form" class="opbox"></div>
{%- endif %}
<div id="srch_q"></div>
</div>
<div id="op_player" class="opview opbox opwide"></div>
<div id="op_bup" class="opview opbox {% if not ls0 %}act{% endif %}">
<div id="u2err"></div>
<form id="bup_form" method="post" enctype="multipart/form-data" accept-charset="utf-8" action="{{ url_suf }}">
<input type="hidden" name="act" value="bput" />
<input type="file" id="bup_files" name="f" multiple /><br />
<input type="submit" id="bup_submit" value="start upload">
</form>
<div id="bup_progress" style="display:none; margin-top:10px;">
<div id="bup_status" style="margin-bottom:5px; font-weight:bold;"></div>
<div id="bup_fileinfo" style="margin-bottom:5px; font-size:0.9em;"></div>
<div id="bup_sizefmt" style="margin-bottom:5px; font-size:0.9em; color:#aaa;"></div>
<div style="width:100%; background:#333; height:20px; border-radius:10px; overflow:hidden; position:relative;">
<div id="bup_bar" style="width:0%; height:100%; background:linear-gradient(90deg, #09d, #4b0); transition:width 0.3s ease-out;"></div>
<div id="bup_bar_animate" style="position:absolute; top:0; left:0; right:0; bottom:0; background:linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation:bup_shimmer 2s infinite; pointer-events:none;"></div>
</div>
<div id="bup_percent" style="text-align:center; margin-top:5px; font-size:0.9em; font-weight:bold;"></div>
<div id="bup_details" style="text-align:center; margin-top:5px; font-size:0.85em; color:#888;"></div>
<div id="bup_eta" style="text-align:center; margin-top:5px; font-size:0.9em; color:#0a8;"></div>
</div>
<div id="bup_result" style="display:none; margin-top:10px; padding:10px; border-radius:5px;"></div>
<a id="bbsw" href="?b=u" rel="nofollow"><br />switch to basic browser</a>
</div>
<div id="op_mkdir" class="opview opbox {% if not ls0 %}act{% endif %}">
<form method="post" enctype="multipart/form-data" accept-charset="utf-8" action="{{ url_suf }}">
<input type="hidden" name="act" value="mkdir" />
📂<input type="text" name="name" class="i" placeholder="awesome mix vol.1">
<input type="submit" value="make directory">
</form>
</div>
<div id="op_new_md" class="opview opbox">
<form method="post" enctype="multipart/form-data" accept-charset="utf-8" action="{{ url_suf }}">
<input type="hidden" name="act" value="new_md" />
📝<input type="text" name="name" class="i" placeholder="weekend-plans">
<input type="submit" value="new file">
</form>
<span id="new_mdi"></span>
</div>
<div id="op_msg" class="opview opbox {% if not ls0 %}act{% endif %}">
<form method="post" enctype="application/x-www-form-urlencoded" accept-charset="utf-8" action="{{ url_suf }}">
📟<input type="text" name="msg" class="i" placeholder="lorem ipsum dolor sit amet">
<input type="submit" value="send msg to srv log">
</form>
</div>
<div id="op_unpost" class="opview opbox"></div>
<div id="op_up2k" class="opview"></div>
<div id="op_cfg" class="opview opbox opwide"></div>
<h1 id="path">
<a href="#" id="entree">🌲</a>
{%- for n in vpnodes %}
<a href="{{ r }}/{{ n[0] }}">{{ n[1] }}</a>
{%- endfor %}
</h1>
<div id="tree"></div>
<div id="wrap">
{%- if doc %}
<div id="bdoc"><pre>{{ doc|e }}</pre></div>
{%- else %}
<div id="bdoc"></div>
{%- endif %}
<div id="pro" class="logue">{{ "" if sb_lg else logues[0] }}</div>
<table id="files">
<thead>
<tr>
<th name="lead"><span>c</span></th>
<th name="href"><span>File Name</span></th>
<th name="sz" sort="int"><span>Size</span></th>
{%- for k in taglist %}
{%- if k.startswith('.') %}
<th name="tags/{{ k }}" sort="int"><span>{{ k[1:] }}</span></th>
{%- else %}
<th name="tags/{{ k }}"><span>{{ k[0]|upper }}{{ k[1:] }}</span></th>
{%- endif %}
{%- endfor %}
<th name="ext"><span>T</span></th>
<th name="ts"><span>Date</span></th>
</tr>
</thead>
<tbody>
{%- for f in files %}
<tr><td>{{ f.lead }}</td><td><a href="{{ f.href }}">{{ f.name|e }}</a></td><td>{{ f.sz }}</td>
{%- if f.tags is defined %}
{%- for k in taglist %}<td>{{ f.tags[k]|e }}</td>{%- endfor %}
{%- endif %}<td>{{ f.ext }}</td><td>{{ f.dt }}</td></tr>
{%- endfor %}
</tbody>
</table>
<div id="epi" class="logue">{{ "" if sb_lg else logues[1] }}</div>
<h2 id="wfp"><a href="{{ r }}/?h" id="goh">control-panel</a></h2>
<a href="#" id="repl">π</a>
</div>
<div id="srv_info"><span>{{ srv_info }}</span></div>
<div id="widget"></div>
<div id="rcm" tabindex="0"></div>
<script>
var SR = "{{ r }}",
CGV1 = {{ cgv1 }},
CGV = {{ cgv|tojson }},
TS = "{{ ts }}",
dtheme = "{{ dtheme }}",
lang = "{{ lang }}",
dfavico = "{{ favico }}",
have_tags_idx = {{ have_tags_idx }},
logues = {{ logues|tojson if sb_lg else "[]" }},
ls0 = {{ ls0|tojson }};
var STG = window.localStorage;
document.documentElement.className = (STG && STG.cpp_thm) || dtheme;
</script>
<script src="{{ r }}/.cpr/w/util.js?_={{ ts }}"></script>
{%- if lang != "eng" %}
<script src="{{ r }}/.cpr/w/tl/{{ lang }}.js?_={{ ts }}"></script>
{%- endif %}
<script src="{{ r }}/.cpr/w/baguettebox.js?_={{ ts }}"></script>
<script src="{{ r }}/.cpr/w/browser.js?_={{ ts }}"></script>
<script src="{{ r }}/.cpr/w/up2k.js?_={{ ts }}"></script>
{%- if js %}
<script src="{{ js }}_={{ ts }}"></script>
{%- endif %}
<script>
Date.now();function jsldp(a,b){2!=window[a]&&alert("FATAL ERROR: cannot load "+b+".js due to unreliable network or broken reverse-proxy; try CTRL-SHIFT-R")}
jsldp("J_UTL","util");
jsldp("J_BBX","baguettebox");
jsldp("J_BRW","browser");
jsldp("J_U2K","up2k");
</script>
<script>
(function() {
var CONFIG = {
MAX_RETRIES: 3,
INITIAL_RETRY_DELAY: 1000,
MAX_RETRY_DELAY: 30000,
UPLOAD_TIMEOUT: 300000,
SPEED_WINDOW_SIZE: 10,
ETA_MIN_SAMPLES: 3,
RETRYABLE_STATUSES: [0, 408, 425, 429, 500, 502, 503, 504]
};
var state = {
progressDiv: null,
progressBar: null,
progressPercent: null,
progressDetails: null,
progressEta: null,
progressStatus: null,
progressFileinfo: null,
progressSizefmt: null,
resultDiv: null,
form: null,
submitBtn: null,
fileInput: null,
currentFiles: [],
currentFileIndex: 0,
totalFilesSize: 0,
currentXhr: null,
isCancelled: false,
uploadStartTime: 0,
currentRetry: 0,
speedSamples: [],
lastLoaded: 0,
lastSampleTime: 0,
animationFrame: null,
smoothedPercent: 0,
targetPercent: 0
};
function formatSize(bytes) {
if (bytes === 0) return '0 B';
var k = 1024,
sizes = ['B', 'KB', 'MB', 'GB', 'TB'],
i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatSpeed(bytesPerSec) {
if (bytesPerSec === 0 || !isFinite(bytesPerSec)) return '0 B/s';
var k = 1024,
sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'],
i = Math.floor(Math.log(bytesPerSec) / Math.log(k));
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatDuration(seconds) {
if (!isFinite(seconds) || seconds < 0) return 'calculating...';
if (seconds === 0) return 'done';
seconds = Math.round(seconds);
if (seconds < 60) {
return seconds + 's';
} else if (seconds < 3600) {
var m = Math.floor(seconds / 60);
var s = seconds % 60;
return m + 'm ' + s + 's';
} else {
var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60);
var s = seconds % 60;
return h + 'h ' + m + 'm ' + s + 's';
}
}
function formatTime(ms) {
var date = new Date(ms);
return date.toLocaleTimeString();
}
function isRetryableError(xhr) {
var status = xhr.status;
if (CONFIG.RETRYABLE_STATUSES.indexOf(status) !== -1) {
return true;
}
if (status === 0) {
return true;
}
if (status >= 500 && status < 600) {
return true;
}
return false;
}
function calculateRetryDelay(retryCount) {
var delay = CONFIG.INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
var jitter = delay * 0.2 * (Math.random() - 0.5);
delay = delay + jitter;
return Math.min(delay, CONFIG.MAX_RETRY_DELAY);
}
function resetSpeedTracking() {
state.speedSamples = [];
state.lastLoaded = 0;
state.lastSampleTime = 0;
}
function addSpeedSample(loaded, total, currentTime) {
if (state.lastSampleTime === 0) {
state.lastLoaded = loaded;
state.lastSampleTime = currentTime;
return;
}
var timeDiff = (currentTime - state.lastSampleTime) / 1000;
var bytesDiff = loaded - state.lastLoaded;
if (timeDiff > 0 && bytesDiff >= 0) {
var speed = bytesDiff / timeDiff;
state.speedSamples.push({
speed: speed,
time: currentTime,
bytes: bytesDiff,
seconds: timeDiff
});
while (state.speedSamples.length > CONFIG.SPEED_WINDOW_SIZE) {
state.speedSamples.shift();
}
}
state.lastLoaded = loaded;
state.lastSampleTime = currentTime;
}
function calculateAverageSpeed() {
if (state.speedSamples.length === 0) {
return 0;
}
var totalBytes = 0;
var totalSeconds = 0;
for (var i = 0; i < state.speedSamples.length; i++) {
totalBytes += state.speedSamples[i].bytes;
totalSeconds += state.speedSamples[i].seconds;
}
if (totalSeconds === 0) {
return 0;
}
return totalBytes / totalSeconds;
}
function calculateETA(loaded, total) {
if (total === 0) return 0;
var remaining = total - loaded;
if (remaining <= 0) return 0;
var avgSpeed = calculateAverageSpeed();
if (avgSpeed === 0) {
return Infinity;
}
return remaining / avgSpeed;
}
function smoothProgress(nowPercent) {
state.targetPercent = nowPercent;
if (!state.animationFrame) {
animateProgress();
}
}
function animateProgress() {
var diff = state.targetPercent - state.smoothedPercent;
if (Math.abs(diff) < 0.01) {
state.smoothedPercent = state.targetPercent;
state.animationFrame = null;
updateProgressDisplay(state.smoothedPercent);
return;
}
var step = diff * 0.15;
state.smoothedPercent += step;
updateProgressDisplay(state.smoothedPercent);
state.animationFrame = requestAnimationFrame(animateProgress);
}
function updateProgressDisplay(percent) {
if (state.progressBar) {
state.progressBar.style.width = percent + '%';
}
if (state.progressPercent) {
state.progressPercent.textContent = percent.toFixed(1) + '%';
}
}
function updateProgressUI(loaded, total, currentTime) {
if (!state.progressBar || !state.progressPercent) return;
var percent = total > 0 ? (loaded / total * 100) : 0;
smoothProgress(percent);
addSpeedSample(loaded, total, currentTime);
var avgSpeed = calculateAverageSpeed();
var eta = calculateETA(loaded, total);
var details = '';
details += formatSize(loaded) + ' / ' + formatSize(total);
if (avgSpeed > 0) {
details += ' | ' + formatSpeed(avgSpeed);
}
if (state.progressDetails) {
state.progressDetails.textContent = details;
}
if (state.speedSamples.length >= CONFIG.ETA_MIN_SAMPLES && avgSpeed > 0) {
if (isFinite(eta)) {
var etaText = 'Remaining: ' + formatDuration(eta);
if (eta > 0 && eta < Infinity) {
var completionTime = currentTime + (eta * 1000);
etaText += ' (est. ' + formatTime(completionTime) + ')';
}
if (state.progressEta) {
state.progressEta.textContent = etaText;
}
}
} else {
if (state.progressEta) {
state.progressEta.textContent = 'Remaining: calculating...';
}
}
if (state.progressSizefmt) {
state.progressSizefmt.textContent = 'Speed: ' + formatSpeed(avgSpeed);
}
}
function showProgress(show) {
if (state.progressDiv) {
state.progressDiv.style.display = show ? 'block' : 'none';
}
}
function showResult(success, message) {
if (state.resultDiv) {
state.resultDiv.style.display = 'block';
state.resultDiv.style.background = success ? '#050' : '#500';
state.resultDiv.style.color = '#fff';
state.resultDiv.innerHTML = message;
}
}
function hideResult() {
if (state.resultDiv) {
state.resultDiv.style.display = 'none';
}
}
function setStatus(text) {
if (state.progressStatus) {
state.progressStatus.textContent = text;
}
}
function setFileinfo(text) {
if (state.progressFileinfo) {
state.progressFileinfo.textContent = text;
}
}
function setFormEnabled(enabled) {
if (state.submitBtn) {
state.submitBtn.disabled = !enabled;
state.submitBtn.value = enabled ? 'start upload' : 'uploading...';
}
if (state.fileInput) {
state.fileInput.disabled = !enabled;
}
}
function uploadFile(file, formAction, callback) {
state.currentXhr = null;
state.isCancelled = false;
state.uploadStartTime = Date.now();
state.currentRetry = 0;
resetSpeedTracking();
state.smoothedPercent = 0;
state.targetPercent = 0;
setStatus('Uploading: ' + file.name);
setFileinfo('Size: ' + formatSize(file.size) + ' | Retry: 0/' + CONFIG.MAX_RETRIES);
if (state.progressEta) {
state.progressEta.textContent = '';
}
if (state.progressDetails) {
state.progressDetails.textContent = '0 B / ' + formatSize(file.size);
}
if (state.progressSizefmt) {
state.progressSizefmt.textContent = '';
}
var formData = new FormData();
formData.append('act', 'bput');
formData.append('f', file);
function doUpload() {
if (state.isCancelled) {
return;
}
var xhr = new XMLHttpRequest();
state.currentXhr = xhr;
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && !state.isCancelled) {
updateProgressUI(e.loaded, e.total, Date.now());
}
};
xhr.onload = function() {
if (state.isCancelled) {
return;
}
var status = xhr.status;
var responseText = xhr.responseText || '';
if (status === 200 || status === 201) {
if (responseText.indexOf('ERROR') === -1 &&
responseText.indexOf('error:') === -1 &&
responseText.indexOf('ERR') === -1) {
state.currentXhr = null;
callback(null, xhr);
return;
}
}
if (isRetryableError(xhr) && state.currentRetry < CONFIG.MAX_RETRIES) {
state.currentRetry++;
var delay = calculateRetryDelay(state.currentRetry);
setFileinfo('Size: ' + formatSize(file.size) +
' | Retry: ' + state.currentRetry + '/' + CONFIG.MAX_RETRIES +
' - waiting ' + (delay / 1000).toFixed(1) + 's...');
resetSpeedTracking();
setTimeout(function() {
if (!state.isCancelled) {
setFileinfo('Size: ' + formatSize(file.size) +
' | Retry: ' + state.currentRetry + '/' + CONFIG.MAX_RETRIES +
' - retrying...');
doUpload();
}
}, delay);
return;
}
state.currentXhr = null;
var errMsg = 'Upload failed';
if (status === 0) {
errMsg = 'Network error - connection failed';
} else if (status >= 500) {
errMsg = 'Server error (' + status + ')';
} else if (status === 403) {
errMsg = 'Permission denied';
} else if (status === 404) {
errMsg = 'Target folder not found';
} else if (status === 413) {
errMsg = 'File too large';
} else if (status !== 200 && status !== 201) {
errMsg = 'HTTP error: ' + status;
}
if (responseText && responseText.length < 500) {
errMsg += ' - ' + responseText.trim();
}
callback(new Error(errMsg), xhr);
};
xhr.onerror = function() {
if (state.isCancelled) {
return;
}
if (state.currentRetry < CONFIG.MAX_RETRIES) {
state.currentRetry++;
var delay = calculateRetryDelay(state.currentRetry);
setFileinfo('Size: ' + formatSize(file.size) +
' | Retry: ' + state.currentRetry + '/' + CONFIG.MAX_RETRIES +
' - network error, retrying in ' + (delay / 1000).toFixed(1) + 's...');
resetSpeedTracking();
setTimeout(function() {
if (!state.isCancelled) {
doUpload();
}
}, delay);
return;
}
state.currentXhr = null;
callback(new Error('Network error after ' + CONFIG.MAX_RETRIES + ' retries'), xhr);
};
xhr.ontimeout = function() {
if (state.isCancelled) {
return;
}
if (state.currentRetry < CONFIG.MAX_RETRIES) {
state.currentRetry++;
var delay = calculateRetryDelay(state.currentRetry);
setFileinfo('Size: ' + formatSize(file.size) +
' | Retry: ' + state.currentRetry + '/' + CONFIG.MAX_RETRIES +
' - timeout, retrying in ' + (delay / 1000).toFixed(1) + 's...');
resetSpeedTracking();
setTimeout(function() {
if (!state.isCancelled) {
doUpload();
}
}, delay);
return;
}
state.currentXhr = null;
callback(new Error('Upload timeout after ' + CONFIG.MAX_RETRIES + ' retries'), xhr);
};
xhr.onabort = function() {
if (state.isCancelled) {
return;
}
state.currentXhr = null;
callback(new Error('Upload aborted'), xhr);
};
xhr.timeout = CONFIG.UPLOAD_TIMEOUT;
xhr.open('POST', formAction, true);
xhr.send(formData);
}
doUpload();
}
function uploadNextFile(formAction) {
if (state.currentFileIndex >= state.currentFiles.length) {
showProgress(false);
showResult(true,
'<strong>Upload complete!</strong><br>' +
'Files: ' + state.currentFiles.length + '<br>' +
'Total size: ' + formatSize(state.totalFilesSize) + '<br>' +
'Time: ' + formatDuration((Date.now() - state.uploadStartTime) / 1000)
);
setFormEnabled(true);
return;
}
var file = state.currentFiles[state.currentFileIndex];
uploadFile(file, formAction, function(err, xhr) {
if (err) {
showProgress(false);
showResult(false,
'<strong>Upload failed</strong><br>' +
'File: ' + file.name + '<br>' +
'Error: ' + err.message
);
setFormEnabled(true);
} else {
state.currentFileIndex++;
uploadNextFile(formAction);
}
});
}
function initBupUpload() {
state.progressDiv = document.getElementById('bup_progress');
state.progressBar = document.getElementById('bup_bar');
state.progressPercent = document.getElementById('bup_percent');
state.progressDetails = document.getElementById('bup_details');
state.progressEta = document.getElementById('bup_eta');
state.progressStatus = document.getElementById('bup_status');
state.progressFileinfo = document.getElementById('bup_fileinfo');
state.progressSizefmt = document.getElementById('bup_sizefmt');
state.resultDiv = document.getElementById('bup_result');
state.form = document.getElementById('bup_form');
state.submitBtn = document.getElementById('bup_submit');
state.fileInput = document.getElementById('bup_files');
if (!state.form || !state.submitBtn || !state.fileInput) {
console.log('Basic upload form not found');
return;
}
var style = document.createElement('style');
style.textContent = '\
@keyframes bup_shimmer {\
0% { transform: translateX(-100%); }\
100% { transform: translateX(100%); }\
}\
#bup_bar_animate {\
animation: bup_shimmer 1.5s infinite linear;\
}\
';
document.head.appendChild(style);
state.form.addEventListener('submit', function(e) {
e.preventDefault();
if (!state.fileInput.files || state.fileInput.files.length === 0) {
showResult(false, 'Please select at least one file to upload.');
return;
}
hideResult();
showProgress(true);
setFormEnabled(false);
state.currentFiles = Array.from(state.fileInput.files);
state.currentFileIndex = 0;
state.totalFilesSize = 0;
state.uploadStartTime = Date.now();
for (var i = 0; i < state.currentFiles.length; i++) {
state.totalFilesSize += state.currentFiles[i].size;
}
setStatus('Preparing upload...');
setFileinfo('Total: ' + state.currentFiles.length + ' file(s), ' + formatSize(state.totalFilesSize));
var formAction = state.form.action || window.location.href;
if (formAction.indexOf('?') === -1) {
formAction += '?j';
} else if (formAction.indexOf('j') === -1) {
formAction += '&j';
}
uploadNextFile(formAction);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBupUpload);
} else {
initBupUpload();
}
})();
</script>
</body>
</html>