(() => {
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()
})
})()