/**
* Koneko 音源测试平台 - 前端逻辑 v1.1
* SSE 实时日志 + 上传进度 + 平台展示
*/
const state = {
currentTab: 'url', files: [], results: [], testing: false, theme: 'light',
platforms: []
};
const $ = (id) => document.getElementById(id);
document.addEventListener('DOMContentLoaded', () => {
initTheme(); initTabs(); initUrlInput(); initFileUpload();
initActions(); initModals(); loadPlatforms(); lucide.createIcons();
});
// === 主题 ===
function initTheme() {
const saved = localStorage.getItem('koneko-theme') || 'light';
setTheme(saved);
$('themeBtn').addEventListener('click', (e) => { e.stopPropagation(); $('themeDropdown').classList.toggle('show'); });
document.addEventListener('click', () => $('themeDropdown').classList.remove('show'));
document.querySelectorAll('.theme-option').forEach(opt => {
opt.addEventListener('click', (e) => { e.stopPropagation(); setTheme(opt.dataset.theme); $('themeDropdown').classList.remove('show'); });
});
}
function setTheme(theme) {
state.theme = theme;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('koneko-theme', theme);
const labels = { light:'浅色', dark:'深色', sakura:'樱花', ocean:'海洋', forest:'森林', midnight:'暗夜紫', sunset:'落日' };
$('themeLabel').textContent = labels[theme] || '浅色';
document.querySelectorAll('.theme-option').forEach(opt => opt.classList.toggle('active', opt.dataset.theme === theme));
lucide.createIcons();
}
// === Tabs ===
function initTabs() {
document.querySelectorAll('.input-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.input-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
state.currentTab = tab.dataset.tab;
$('urlTab').style.display = state.currentTab === 'url' ? 'block' : 'none';
$('fileTab').style.display = state.currentTab === 'file' ? 'block' : 'none';
});
});
}
// === URL 输入 ===
function initUrlInput() {
$('urlInput').addEventListener('input', () => {
const lines = $('urlInput').value.split('\n').map(l => l.trim()).filter(l => l);
$('urlCount').textContent = `${lines.length} 个链接`;
});
}
// === 文件上传(带进度) ===
function initFileUpload() {
const dropZone = $('fileDropZone'), fileInput = $('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); });
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
}
function handleFiles(fileList) {
for (const file of fileList) {
if (file.name.match(/\.(js|zip)$/i)) state.files.push(file);
}
renderFileList();
}
function renderFileList() {
const container = $('fileList');
container.innerHTML = '';
state.files.forEach((file, idx) => {
const chip = document.createElement('div');
chip.className = 'file-chip';
chip.innerHTML = `${file.name}`;
chip.querySelector('.remove-file').addEventListener('click', () => { state.files.splice(idx, 1); renderFileList(); });
container.appendChild(chip);
});
lucide.createIcons();
}
// 逐个读取文件,显示进度
async function readFilesWithProgress(files) {
const plugins = [];
const total = files.length;
$('uploadProgress').style.display = 'block';
for (let i = 0; i < total; i++) {
const file = files[i];
const pct = Math.round(((i) / total) * 100);
$('uploadProgressText').textContent = `读取文件 ${i + 1}/${total}: ${file.name}`;
$('uploadProgressPercent').textContent = `${pct}%`;
$('uploadProgressFill').style.width = `${pct}%`;
const code = await readFile(file);
plugins.push({ code, filename: file.name });
await sleep(50);
}
$('uploadProgressText').textContent = `全部文件读取完成`;
$('uploadProgressPercent').textContent = `100%`;
$('uploadProgressFill').style.width = `100%`;
await sleep(300);
$('uploadProgress').style.display = 'none';
return plugins;
}
function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// === 平台展示 ===
async function loadPlatforms() {
try {
const res = await fetch('/api/platforms');
const data = await res.json();
if (data.success) {
state.platforms = data.platforms;
renderPlatforms(data.platforms);
}
} catch(e) { console.error('加载平台信息失败', e); }
}
function renderPlatforms(platforms) {
const icons = { lx: 'music', qzv2: 'disc-3', musicfree: 'radio' };
const grid = $('platformsGrid');
grid.innerHTML = platforms.map(p => `
`).join('');
$('platformsCard').style.display = 'block';
lucide.createIcons();
}
// === 按钮 ===
function initActions() {
$('clearBtn').addEventListener('click', clearAll);
$('sampleBtn').addEventListener('click', fillSample);
$('testBtn').addEventListener('click', startTest);
$('exportMdBtn').addEventListener('click', exportMarkdown);
$('exportPosterBtn').addEventListener('click', exportPoster);
$('clearLogBtn').addEventListener('click', () => { $('logPanel').innerHTML = ''; });
}
function clearAll() {
$('urlInput').value = ''; $('urlCount').textContent = '0 个链接';
state.files = []; renderFileList();
toast('已清空输入', 'info');
}
function fillSample() {
$('urlInput').value = 'https://paste.rs/WqzPv';
$('urlInput').dispatchEvent(new Event('input'));
toast('已填入示例链接', 'info');
}
// === 日志面板 ===
function showLogPanel() {
$('logPanelCard').style.display = 'block';
$('logPulse').classList.remove('idle');
}
function addLog(level, message, isHeader = false) {
const panel = $('logPanel');
const now = new Date();
const time = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
const entry = document.createElement('div');
entry.className = `log-entry${isHeader ? ' plugin-header' : ''}`;
entry.innerHTML = `${time}${level.toUpperCase()}${escapeHtml(message)}`;
panel.appendChild(entry);
panel.scrollTop = panel.scrollHeight;
}
function escapeHtml(str) {
return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
// === 开始测试(SSE 流式) ===
async function startTest() {
if (state.testing) return;
const plugins = [];
// 收集 URL
if (state.currentTab === 'url') {
const urls = $('urlInput').value.split('\n').map(l => l.trim()).filter(l => l && l.startsWith('http'));
if (urls.length === 0) { toast('请输入至少一个有效的插件链接', 'error'); return; }
urls.forEach(url => plugins.push({ url }));
}
// 收集文件(带进度)
if (state.currentTab === 'file') {
if (state.files.length === 0) { toast('请选择至少一个插件文件', 'error'); return; }
const filePlugins = await readFilesWithProgress(state.files);
plugins.push(...filePlugins);
}
state.testing = true;
state.results = [];
$('testBtn').disabled = true;
$('testBtn').innerHTML = ' 测试中...';
$('progressBar').classList.add('show');
$('progressFill').style.width = '0%';
$('emptyState').style.display = 'none';
$('resultsSection').style.display = 'block';
$('resultsGrid').innerHTML = '';
showLogPanel();
addLog('info', `开始批量测试,共 ${plugins.length} 个插件`);
try {
// 使用 fetch + ReadableStream 接收 SSE
const response = await fetch('/api/test/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugins })
});
if (!response.ok) throw new Error('测试请求失败');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const resultsMap = new Map();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let currentEvent = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.substring(7).trim();
} else if (line.startsWith('data: ') && currentEvent) {
try {
const data = JSON.parse(line.substring(6));
handleSSEEvent(currentEvent, data, resultsMap);
} catch(e) { /* skip */ }
currentEvent = '';
}
}
}
// 渲染最终结果(按 index 排序)
state.results = [...resultsMap.entries()].sort((a,b) => a[0] - b[0]).map(([,v]) => v);
renderResults(state.results);
$('progressFill').style.width = '100%';
setTimeout(() => $('progressBar').classList.remove('show'), 500);
$('logPulse').classList.add('idle');
addLog('success', `全部测试完成,共 ${state.results.length} 个插件`);
toast(`测试完成,共 ${state.results.length} 个插件`, 'success');
} catch (err) {
toast(err.message, 'error');
addLog('error', `测试失败: ${err.message}`);
$('logPulse').classList.add('idle');
} finally {
state.testing = false;
$('testBtn').disabled = false;
$('testBtn').innerHTML = ' 开始测试';
lucide.createIcons();
}
}
function handleSSEEvent(event, data, resultsMap) {
switch(event) {
case 'start':
addLog('info', `测试任务启动,时间: ${new Date(data.time).toLocaleTimeString('zh-CN')}`);
break;
case 'plugin_start':
addLog('info', `--- 插件 #${data.index + 1}: ${data.filename || data.url || '未知'} ---`, true);
break;
case 'log':
addLog(data.level, data.message);
break;
case 'plugin_done':
resultsMap.set(data.index, data.result);
// 增量渲染
renderIncrementalCard(data.index, data.result);
break;
case 'progress':
$('progressFill').style.width = data.percent + '%';
if (data.completed === data.total) {
addLog('info', `进度: ${data.completed}/${data.total} (100%)`);
}
break;
case 'done':
addLog('success', `测试任务完成`);
break;
}
}
// 增量渲染单个结果卡片
function renderIncrementalCard(index, result) {
const grid = $('resultsGrid');
let card = document.getElementById(`result-card-${index}`);
if (!card) {
card = document.createElement('div');
card.id = `result-card-${index}`;
grid.appendChild(card);
}
card.outerHTML = renderResultCard(result, index).replace('';
lucide.createIcons(); return;
}
grid.innerHTML = results.map((r, i) => renderResultCard(r, i)).join('');
lucide.createIcons();
}
function renderResultCard(r, index) {
const name = r.metadata?.name || r.filename || truncUrl(r.url) || `插件 #${index + 1}`;
const platform = r.platform || 'unknown';
const score = r.summary?.score ?? 0;
const grade = r.summary?.details?.grade ?? 'D';
const status = r.overallStatus || 'pending';
const platformIcons = { lx:'music', qzv2:'disc-3', musicfree:'radio', unknown:'help-circle' };
const platformTag = `${r.platformLabel || '未知'}`;
const scoreBadge = ``;
let latencyHtml = '';
if (r.latencyTests?.length > 0) {
latencyHtml = r.latencyTests.slice(0, 4).map(t => {
let cls = 'good'; if (t.latency > 1500) cls = 'bad'; else if (t.latency > 800) cls = 'ok';
const pct = Math.min(100, (t.latency / 3000) * 100);
const label = t.isPluginUrl ? '插件源' : truncUrl(t.url, 25);
return `${label}${t.success ? t.latency + 'ms' : '失败'} `;
}).join('');
}
let qualityHtml = '';
if (r.qualityTests?.length > 0) {
qualityHtml = r.qualityTests.map(q => `${q.quality}`).join('');
}
const features = r.metadata?.features || [];
const featuresText = features.length > 0 ? features.slice(0, 5).join(', ') + (features.length > 5 ? ` +${features.length - 5}` : '') : '无';
const version = r.metadata?.version || '-';
const author = r.metadata?.author || '-';
const dlInfo = r.downloadInfo || {};
const dlStatus = dlInfo.success ? `${formatSize(dlInfo.size||0)} · ${dlInfo.latency||0}ms` : `${dlInfo.error || '失败'}`;
return `
${platformTag}${r.metadata?.sources?.length ? r.metadata.sources.map(s => `${s.toUpperCase()}`).join('') : ''}
版本${version}
作者${author}
下载${dlStatus}
成功率${r.summary?.details?.successRate || 'N/A'}
${latencyHtml ? `
` : ''}
${qualityHtml ? `
` : ''}
`;
}
// === Markdown 导出 ===
async function exportMarkdown() {
if (state.results.length === 0) { toast('没有可导出的测试结果', 'error'); return; }
try {
const res = await fetch('/api/export/md', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({results: state.results}) });
const data = await res.json();
if (data.success) {
$('mdPreview').textContent = data.markdown;
$('mdModal').classList.add('show');
$('copyMdBtn').onclick = () => { navigator.clipboard.writeText(data.markdown).then(() => toast('已复制到剪贴板','success')).catch(() => toast('复制失败','error')); };
$('downloadMdBtn').onclick = () => { downloadFile(data.markdown, 'koneko-test-report.md', 'text/markdown'); toast('文件已下载','success'); };
}
} catch(err) { toast('导出失败: ' + err.message, 'error'); }
}
// === 海报生成 ===
function exportPoster() {
if (state.results.length === 0) { toast('没有可生成海报的测试结果', 'error'); return; }
generatePoster(state.results);
$('posterModal').classList.add('show');
}
function generatePoster(results) {
const canvas = $('poster-canvas'); const ctx = canvas.getContext('2d');
const headerHeight = 200, cardHeight = 140, footerHeight = 60, padding = 40;
canvas.width = 800; canvas.height = Math.max(600, headerHeight + results.length * (cardHeight + 12) + footerHeight + padding);
const styles = getComputedStyle(document.documentElement);
const accent = styles.getPropertyValue('--accent').trim() || '#6c5ce7';
const accentLight = styles.getPropertyValue('--accent-light').trim() || '#a29bfe';
const textPrimary = styles.getPropertyValue('--text-primary').trim() || '#1a1a2e';
const textMuted = styles.getPropertyValue('--text-muted').trim() || '#8888a0';
const success = styles.getPropertyValue('--success').trim() || '#00b894';
const warning = styles.getPropertyValue('--warning').trim() || '#fdcb6e';
const danger = styles.getPropertyValue('--danger').trim() || '#e17055';
const isDark = ['dark','midnight'].includes(state.theme);
const bgColor = isDark ? '#1a1a2e' : '#f8f9fa';
const cardBg = isDark ? 'rgba(35,38,54,0.9)' : 'rgba(255,255,255,0.95)';
const cardBorder = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const bgGradient = ctx.createLinearGradient(0,0,canvas.width,canvas.height);
bgGradient.addColorStop(0,bgColor); bgGradient.addColorStop(1, isDark ? '#0d0d1a' : '#eef0f5');
ctx.fillStyle = bgGradient; ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.globalAlpha = 0.06; ctx.fillStyle = accent;
ctx.beginPath(); ctx.arc(700,80,120,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(100,canvas.height-100,80,0,Math.PI*2); ctx.fill();
ctx.globalAlpha = 1;
const titleGradient = ctx.createLinearGradient(0,0,canvas.width,0);
titleGradient.addColorStop(0,accent); titleGradient.addColorStop(1,accentLight);
ctx.fillStyle = titleGradient; roundRect(ctx,padding,35,44,44,10); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 24px sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('K', padding+22, 57);
ctx.fillStyle = titleGradient; ctx.font = 'bold 28px Inter, "Noto Sans SC", sans-serif';
ctx.textAlign='left'; ctx.textBaseline='top'; ctx.fillText('Koneko 音源测试平台', padding+60, 38);
ctx.fillStyle = textMuted; ctx.font = '14px Inter, "Noto Sans SC", sans-serif';
ctx.fillText('Lx / QZV2 / MusicFree 插件音质与延时测试报告', padding+60, 72);
const now = new Date();
const dateStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
ctx.fillStyle = textMuted; ctx.font = '12px Inter, monospace';
ctx.fillText(`测试时间: ${dateStr} | 插件数量: ${results.length}`, padding+60, 94);
ctx.strokeStyle = cardBorder; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(padding,130); ctx.lineTo(canvas.width-padding,130); ctx.stroke();
const passed = results.filter(r=>r.overallStatus==='passed').length;
const wc = results.filter(r=>r.overallStatus==='warning').length;
const failed = results.filter(r=>r.overallStatus==='failed').length;
const avgScore = results.length > 0 ? Math.round(results.reduce((s,r)=>s+(r.summary?.score||0),0)/results.length) : 0;
const statsY = 150, statW = (canvas.width - padding*2 - 24) / 4;
const stats = [{label:'总插件数',value:results.length,color:accent},{label:'通过',value:passed,color:success},{label:'警告',value:wc,color:warning},{label:'平均分',value:avgScore,color:accentLight}];
stats.forEach((stat,i) => {
const x = padding + i*(statW+8);
ctx.fillStyle = cardBg; roundRect(ctx,x,statsY,statW,56,10); ctx.fill();
ctx.strokeStyle = cardBorder; roundRect(ctx,x,statsY,statW,56,10); ctx.stroke();
ctx.fillStyle = stat.color; ctx.font = 'bold 22px Inter, sans-serif'; ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(stat.value, x+statW/2, statsY+10);
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
ctx.fillText(stat.label, x+statW/2, statsY+38);
});
let y = statsY + 76; ctx.textAlign = 'left';
results.forEach((r,i) => {
const name = r.metadata?.name || r.filename || truncUrl(r.url) || `插件 #${i+1}`;
const platform = r.platform || 'unknown';
const score = r.summary?.score ?? 0;
const grade = r.summary?.details?.grade ?? 'D';
const avgLatency = r.summary?.details?.avgLatency ?? -1;
const qualityStr = r.qualityTests?.map(q=>q.quality).join(', ') || '-';
ctx.fillStyle = cardBg; roundRect(ctx,padding,y,canvas.width-padding*2,cardHeight-8,12); ctx.fill();
ctx.strokeStyle = cardBorder; roundRect(ctx,padding,y,canvas.width-padding*2,cardHeight-8,12); ctx.stroke();
let statusColor = textMuted;
if (r.overallStatus==='passed') statusColor = success;
else if (r.overallStatus==='warning') statusColor = warning;
else if (r.overallStatus==='failed') statusColor = danger;
ctx.fillStyle = statusColor; roundRect(ctx,padding,y,4,cardHeight-8,2); ctx.fill();
ctx.fillStyle = textMuted; ctx.font = 'bold 12px Inter, monospace'; ctx.textAlign='left'; ctx.textBaseline='top';
ctx.fillText(`#${i+1}`, padding+16, y+14);
ctx.fillStyle = textPrimary; ctx.font = 'bold 15px Inter, "Noto Sans SC", sans-serif';
ctx.fillText(truncate(name,38), padding+42, y+12);
const platformLabels = { lx:'Lx Music', qzv2:'QZV2', musicfree:'MusicFree', unknown:'未知' };
const platText = platformLabels[platform] || '未知';
const platColor = platform==='lx'?accent:(platform==='qzv2'?success:(platform==='musicfree'?'#e84393':textMuted));
const platTextW = ctx.measureText(platText).width;
ctx.fillStyle = platColor+'20'; roundRect(ctx,padding+42,y+34,platTextW+16,20,10); ctx.fill();
ctx.fillStyle = platColor; ctx.font = '11px Inter, sans-serif';
ctx.fillText(platText, padding+50, y+38);
ctx.fillStyle = textMuted; ctx.font = '12px Inter, sans-serif';
ctx.fillText(r.metadata?.version?`v${r.metadata.version}`:'v-', padding+50+platTextW+16, y+38);
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
ctx.fillText('音质:', padding+42, y+64);
ctx.fillStyle = textPrimary; ctx.font = '11px Inter, monospace';
ctx.fillText(truncate(qualityStr,35), padding+76, y+64);
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
ctx.fillText('延时:', padding+42, y+82);
ctx.fillStyle = avgLatency>0?(avgLatency<800?success:(avgLatency<1500?warning:danger)):textMuted;
ctx.font = 'bold 11px Inter, monospace';
ctx.fillText(avgLatency>0?`${avgLatency}ms`:'N/A', padding+76, y+82);
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif';
ctx.fillText('功能:', padding+200, y+82);
ctx.fillStyle = textPrimary; ctx.font = '11px Inter, monospace';
ctx.fillText(`${r.metadata?.features?.length||0} 项`, padding+234, y+82);
const scoreX = canvas.width-padding-44, scoreY = y+(cardHeight-8)/2, scoreR = 28;
ctx.beginPath(); ctx.arc(scoreX,scoreY,scoreR,0,Math.PI*2);
ctx.fillStyle = (grade==='S'||grade==='A')?success+'20':((grade==='B'||grade==='C')?warning+'20':danger+'20'); ctx.fill();
ctx.strokeStyle = (grade==='S'||grade==='A')?success:((grade==='B'||grade==='C')?warning:danger);
ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = (grade==='S'||grade==='A')?success:((grade==='B'||grade==='C')?warning:danger);
ctx.font = 'bold 18px Inter, sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(score, scoreX, scoreY-4);
ctx.font = 'bold 10px Inter, sans-serif'; ctx.fillText(grade, scoreX, scoreY+12);
ctx.textAlign='left'; y += cardHeight + 4;
});
y += 8;
ctx.fillStyle = textMuted; ctx.font = '11px Inter, "Noto Sans SC", sans-serif'; ctx.textAlign='center';
ctx.fillText('Koneko 音源测试平台 · Generated by Koneko Music Tester', canvas.width/2, y);
$('downloadPosterBtn').onclick = () => {
const link = document.createElement('a');
link.download = `koneko-poster-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
toast('海报已下载', 'success');
};
}
function roundRect(ctx,x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath()}
// === Modals ===
function initModals() {
$('mdCloseBtn').addEventListener('click', () => $('mdModal').classList.remove('show'));
$('posterCloseBtn').addEventListener('click', () => $('posterModal').classList.remove('show'));
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.remove('show'); });
});
}
// === Toast ===
function toast(message, type='info') {
const container = $('toastContainer');
const icons = { success:'check-circle', error:'x-circle', info:'info' };
const el = document.createElement('div');
el.className = `toast ${type}`;
el.innerHTML = `${message}`;
container.appendChild(el);
lucide.createIcons();
setTimeout(() => { el.style.opacity='0'; el.style.transform='translateX(40px)'; el.style.transition='all 0.3s'; setTimeout(()=>el.remove(),300); }, 3000);
}
// === Utils ===
function truncUrl(url, max=40){if(!url)return'';if(url.length<=max)return url;return url.substring(0,max-3)+'...'}
function truncate(str,max){if(!str)return'';if(str.length<=max)return str;return str.substring(0,max-3)+'...'}
function formatSize(b){if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'KB';return(b/1048576).toFixed(1)+'MB'}
function downloadFile(content,filename,type){const blob=new Blob([content],{type:type||'text/plain'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=filename;a.click();URL.revokeObjectURL(url)}