(() => { const el = id => document.getElementById(id) let currentConfig = null let pollTimer = null const esc = s => String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') const setNotice = (text, isError) => { const box = el('save-notice') if (!box) return if (!text) { box.style.display = 'none' box.textContent = '' box.className = 'notice' return } box.style.display = '' box.textContent = text box.className = isError ? 'notice notice-err' : 'notice' } const setAuthWarning = text => { const box = el('auth-warning') if (!box) return if (!text) { box.style.display = 'none' box.textContent = '' return } box.style.display = '' box.textContent = text } const getJson = async url => { const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}_ts=${Date.now()}`, { cache: 'no-store', credentials: 'same-origin' }) const data = await resp.json().catch(() => null) if (resp.status === 401) throw new Error('UNAUTHORIZED') if (!resp.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${resp.status}`) return data } const postJson = async (url, body) => { const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', credentials: 'same-origin', body: JSON.stringify(body || {}) }) const data = await resp.json().catch(() => null) if (resp.status === 401) throw new Error('UNAUTHORIZED') if (!resp.ok || !data || data.ok === false) throw new Error((data && data.error) || `请求失败: ${resp.status}`) return data } const formatStatus = status => { const map = { success: '成功', partial_success: '部分成功', failed: '失败', running: '运行中', idle: '空闲' } return map[String(status || '')] || (status || '-') } const formatSize = bytes => { const n = Number(bytes || 0) if (!Number.isFinite(n) || n <= 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let value = n let idx = 0 while (value >= 1024 && idx < units.length - 1) { value /= 1024 idx += 1 } return `${value >= 100 || idx === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[idx]}` } const fillLines = (id, values) => { const node = el(id) if (!node) return node.value = Array.isArray(values) ? values.join('\n') : '' } const readLines = id => { const node = el(id) return String((node && node.value) || '') .split(/\r?\n/) .map(s => s.trim()) .filter(Boolean) } const renderOverview = data => { const high = data.highfreq || {} const low = data.lowfreq || {} const current = data.current_run || {} const manual = data.manual_baidu_backup || {} const scheduler = data.scheduler || {} const verify = data.verify || {} const restore = data.restore || {} el('highfreq-status').textContent = formatStatus(high.last_status || '-') el('highfreq-last').textContent = `上次:${high.last_run_at || '-'}` el('highfreq-next').textContent = `下次:${scheduler.next_highfreq_at || '-'}` el('lowfreq-status').textContent = formatStatus(low.last_status || '-') el('lowfreq-last').textContent = `上次:${low.last_run_at || '-'}` el('lowfreq-next').textContent = `下次:${scheduler.next_lowfreq_at || '-'}` el('current-run-status').textContent = formatStatus(current.status || 'idle') el('current-run-stage').textContent = `阶段:${current.stage || '-'}` el('current-run-message').textContent = `消息:${current.message || '-'}` el('manual-last').textContent = manual.last_recorded_at || '-' el('oss-ready').textContent = data.oss_ready ? 'OSS状态:已就绪' : 'OSS状态:未就绪' el('verify-last').textContent = `最近抽检:${verify.last_run_at ? `${formatStatus(verify.last_status || '-')} / ${verify.last_run_at}` : '-'}` el('restore-last').textContent = `最近演练:${restore.last_run_at ? `${formatStatus(restore.last_status || '-')} / ${restore.last_run_at}` : '-'}` el('credential-file').textContent = `凭证文件:${data.credential_file || '-'}` } const renderCurrentRun = run => { const current = run || {} const percent = Math.max(0, Math.min(100, Number(current.percent || 0))) el('progress-title').textContent = current.run_id ? `${formatStatus(current.status)} / ${current.mode || '-'}` : '当前无任务' el('progress-percent').textContent = `${percent}%` el('progress-inner').style.width = `${percent}%` el('progress-detail').textContent = current.message || '等待任务启动' const summary = current.stats && Object.keys(current.stats).length ? JSON.stringify(current.stats, null, 2) : '暂无结果' el('run-summary').textContent = summary const running = current.status === 'running' ;['btn-run-high', 'btn-run-low', 'btn-run-all', 'btn-run-verify', 'btn-run-restore', 'btn-manual-record', 'btn-config-save'].forEach(id => { const node = el(id) if (node) node.disabled = running }) } const renderAlerts = rows => { const body = el('alerts-body') if (!body) return body.innerHTML = '' const list = Array.isArray(rows) ? rows : [] if (!list.length) { body.innerHTML = '暂无告警' return } body.innerHTML = list.map(item => ` ${esc(item.created_at || '')} ${esc(item.severity || '')} ${esc(item.title || '')} ${esc(item.message || '')} `).join('') } const renderLogs = rows => { const body = el('logs-body') if (!body) return body.innerHTML = '' const list = Array.isArray(rows) ? rows : [] if (!list.length) { body.innerHTML = '暂无日志' return } body.innerHTML = list.map(item => ` ${esc(item.created_at || '')} ${esc(item.mode || '')} ${esc(item.event_type || '')} ${esc(item.message || '')} `).join('') } const renderCatalog = payload => { const highBody = el('catalog-highfreq-body') const lowBody = el('catalog-lowfreq-body') if (!highBody || !lowBody) return const highfreqRuns = Array.isArray(payload.highfreq_runs) ? payload.highfreq_runs : [] const lowfreqObjects = Array.isArray(payload.lowfreq_objects) ? payload.lowfreq_objects : [] if (!highfreqRuns.length) { highBody.innerHTML = '暂无高频批次' } else { highBody.innerHTML = highfreqRuns.map(item => { const high = item.highfreq || {} const key = esc(high.oss_key || '-') const runGuid = esc(high.run_guid || '-') const info = `${Number(high.file_count || 0)} 文件 / ${formatSize(high.zip_size_bytes || 0)}` const time = esc(item.ended_at || item.started_at || '') const status = esc(formatStatus(item.status || '')) const href = `/api/box_backup/download/highfreq?id=${encodeURIComponent(item.id)}` return ` ${time || '-'} ${status}
${runGuid}
${esc(info)}
${key}
下载归档 ` }).join('') } if (!lowfreqObjects.length) { lowBody.innerHTML = '暂无低频对象' } else { lowBody.innerHTML = lowfreqObjects.map(item => { const meta = item.meta || {} const encryption = item.encryption || {} const storedName = esc(meta.stored_name || '-') const key = esc(item.oss_key || '-') const relPath = esc(item.relative_path || '-') const time = esc(item.last_uploaded_at || item.deleted_at || '') const statusTextBase = item.is_deleted ? `${formatStatus(item.upload_status || '')} / 已物理删除` : formatStatus(item.upload_status || '') const statusText = encryption.encrypted ? `${statusTextBase} / ${encryption.label || '加密文件'}` : statusTextBase const action = String(item.upload_status || '') === 'uploaded' ? `${encryption.encrypted ? `解密下载|` : ''}${encryption.encrypted ? '原件下载' : '下载文件'}` : `${esc(item.last_error || '-')}` return ` ${time || '-'} ${esc(statusText)}
${relPath}
${storedName}
${key}
${action} ` }).join('') } } const renderConfig = payload => { currentConfig = payload.config || {} const cfg = currentConfig const status = payload.credential_status || {} el('cfg-high-cron').value = ((cfg.scheduler || {}).highfreq_cron) || '' el('cfg-low-cron').value = ((cfg.scheduler || {}).lowfreq_cron) || '' el('cfg-poll-seconds').value = ((cfg.scheduler || {}).poll_seconds) || 30 el('cfg-oss-root-prefix').value = ((cfg.oss || {}).root_prefix) || status.root_prefix || '' el('cfg-oss-root-prefix-key').value = ((cfg.oss || {}).root_prefix_key) || 'BOX_BACKUP_OSS_ROOT_PREFIX' el('cfg-lowfreq-dir-name').value = ((cfg.oss || {}).lowfreq_dir_name) || status.lowfreq_dir_name || '' el('cfg-oss-bucket').value = ((cfg.oss || {}).bucket) || '' el('cfg-oss-endpoint').value = ((cfg.oss || {}).endpoint) || '' el('cfg-credential-file').value = ((cfg.oss || {}).credential_file_name) || '' el('cfg-ak-key').value = ((cfg.oss || {}).access_key_id_key) || '' el('cfg-sk-key').value = ((cfg.oss || {}).access_key_secret_key) || '' el('cfg-bucket-key').value = ((cfg.oss || {}).bucket_key) || '' el('cfg-endpoint-key').value = ((cfg.oss || {}).endpoint_key) || '' el('cfg-sample-size').value = ((cfg.verification || {}).lowfreq_sample_size) || 3 el('cfg-restore-max-mb').value = ((cfg.verification || {}).restore_max_download_mb) || 2048 fillLines('cfg-high-code-paths', (((cfg.paths || {}).highfreq || {}).code_paths) || []) fillLines('cfg-high-root-files', (((cfg.paths || {}).highfreq || {}).root_files) || []) fillLines('cfg-high-exts', (((cfg.paths || {}).highfreq || {}).tool_code_extensions) || []) fillLines('cfg-low-paths', (((cfg.paths || {}).lowfreq || {}).include_paths) || []) fillLines('cfg-high-excludes', (((cfg.paths || {}).highfreq || {}).exclude_dirs) || []) fillLines('cfg-low-excludes', (((cfg.paths || {}).lowfreq || {}).exclude_dirs) || []) } const collectConfig = () => { const cfg = JSON.parse(JSON.stringify(currentConfig || {})) cfg.scheduler = cfg.scheduler || {} cfg.paths = cfg.paths || {} cfg.paths.highfreq = cfg.paths.highfreq || {} cfg.paths.lowfreq = cfg.paths.lowfreq || {} cfg.oss = cfg.oss || {} cfg.scheduler.highfreq_cron = el('cfg-high-cron').value.trim() cfg.scheduler.lowfreq_cron = el('cfg-low-cron').value.trim() cfg.scheduler.poll_seconds = Number(el('cfg-poll-seconds').value || 30) cfg.oss.root_prefix = el('cfg-oss-root-prefix').value.trim() cfg.oss.root_prefix_key = el('cfg-oss-root-prefix-key').value.trim() cfg.oss.lowfreq_dir_name = el('cfg-lowfreq-dir-name').value.trim() cfg.oss.bucket = el('cfg-oss-bucket').value.trim() cfg.oss.endpoint = el('cfg-oss-endpoint').value.trim() cfg.oss.credential_file_name = el('cfg-credential-file').value.trim() cfg.oss.access_key_id_key = el('cfg-ak-key').value.trim() cfg.oss.access_key_secret_key = el('cfg-sk-key').value.trim() cfg.oss.bucket_key = el('cfg-bucket-key').value.trim() cfg.oss.endpoint_key = el('cfg-endpoint-key').value.trim() cfg.verification = cfg.verification || {} cfg.verification.lowfreq_sample_size = Number(el('cfg-sample-size').value || 3) cfg.verification.restore_max_download_mb = Number(el('cfg-restore-max-mb').value || 2048) cfg.paths.highfreq.code_paths = readLines('cfg-high-code-paths') cfg.paths.highfreq.root_files = readLines('cfg-high-root-files') cfg.paths.highfreq.tool_code_extensions = readLines('cfg-high-exts') cfg.paths.lowfreq.include_paths = readLines('cfg-low-paths') cfg.paths.highfreq.exclude_dirs = readLines('cfg-high-excludes') cfg.paths.lowfreq.exclude_dirs = readLines('cfg-low-excludes') return cfg } const refreshOverview = async () => { const [overview, currentRun, alerts] = await Promise.all([ getJson('/api/box_backup/overview'), getJson('/api/box_backup/current_run'), getJson('/api/box_backup/alerts?limit=30') ]) renderOverview(overview) renderCurrentRun(currentRun.run) renderAlerts(alerts.rows || []) } const refreshLogs = async () => { const failuresOnly = !!(el('filter-failures') && el('filter-failures').checked) const logs = await getJson(`/api/box_backup/events?limit=200${failuresOnly ? '&failures_only=1' : ''}`) renderLogs(logs.rows || []) } const refreshConfig = async () => { const payload = await getJson('/api/box_backup/config') renderConfig(payload) } const refreshCatalog = async () => { const status = el('catalog-status-filter') ? el('catalog-status-filter').value.trim() : '' const payload = await getJson(`/api/box_backup/catalog?run_limit=20&object_limit=200${status ? `&status=${encodeURIComponent(status)}` : ''}`) renderCatalog(payload) } const refreshAll = async () => { try { setAuthWarning('') await Promise.all([refreshOverview(), refreshLogs(), refreshConfig(), refreshCatalog()]) } catch (error) { if (String(error.message || error) === 'UNAUTHORIZED') { setAuthWarning('未授权。请从带 token 的入口进入,或先完成导航鉴权。') return } setNotice(String(error.message || error), true) } } const runMode = async mode => { try { setNotice('', false) await postJson(`/api/box_backup/run/${mode}`, {}) await Promise.all([refreshOverview(), refreshLogs(), refreshCatalog()]) } catch (error) { setNotice(String(error.message || error), true) } } const saveConfig = async () => { try { const cfg = collectConfig() const payload = await postJson('/api/box_backup/config/save', { config: cfg }) renderConfig(payload) setNotice('配置已保存并热生效', false) await Promise.all([refreshOverview(), refreshCatalog()]) } catch (error) { setNotice(String(error.message || error), true) } } const recordManual = async () => { try { await postJson('/api/box_backup/manual_baidu_backup', { note: el('manual-note').value.trim() }) el('manual-note').value = '' setNotice('已记录手工百度云盘备份时间', false) await refreshOverview() } catch (error) { setNotice(String(error.message || error), true) } } const startPolling = () => { if (pollTimer) clearInterval(pollTimer) pollTimer = setInterval(async () => { try { await refreshOverview() await refreshLogs() await refreshCatalog() } catch {} }, 4000) } const bind = () => { el('btn-refresh').onclick = refreshAll el('btn-run-high').onclick = () => runMode('high') el('btn-run-low').onclick = () => runMode('low') el('btn-run-all').onclick = () => runMode('all') el('btn-run-verify').onclick = () => runMode('verify') el('btn-run-restore').onclick = () => runMode('restore') el('btn-config-reload').onclick = refreshConfig el('btn-config-save').onclick = saveConfig el('btn-manual-record').onclick = recordManual el('filter-failures').onchange = refreshLogs el('btn-catalog-refresh').onclick = refreshCatalog el('catalog-status-filter').onchange = refreshCatalog } document.addEventListener('DOMContentLoaded', async () => { bind() await refreshAll() startPolling() }) })()