const $ = (sel) => document.querySelector(sel); const STATUS = { ok: { text: '正常', cls: 'st-ok' }, error: { text: '异常', cls: 'st-error' }, conflict: { text: '冲突', cls: 'st-conflict' }, running: { text: '同步中', cls: 'st-running' }, idle: { text: '未启动', cls: 'st-idle' }, }; const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' }; let repos = []; let current = null; let creating = false; let chart = null; // ---------- 基础 ---------- function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } async function api(url, opts) { const res = await fetch(url, opts); if (res.status === 401) { showLogin(); throw new Error('需要登录'); } const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || res.statusText); return data; } const post = (url) => api(url, { method: 'POST' }); // ---------- 渲染 ---------- function render() { const grid = $('#grid'); grid.innerHTML = ''; repos.forEach(r => grid.appendChild(card(r))); $('#empty').hidden = repos.length > 0; } function card(r) { const st = STATUS[r.status] || STATUS.idle; const el = document.createElement('div'); el.className = 'card ' + st.cls; el.dataset.name = r.name; const last = r.last_sync ? `${r.last_sync.time.slice(5, 16)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}` : '从未同步'; el.innerHTML = `

${esc(r.name)}

${esc(r.mode)}
${esc(r.repo_path)}
${esc(r.branch)} 领先 ↑${r.ahead} 落后 ↓${r.behind} ${r.dirty ? `改动 ${r.dirty}` : ''} ${r.auto_commit ? '自动提交' : ''} ${r.cron ? `cron` : ''} ${r.conflicts && r.conflicts.length ? `冲突 ${r.conflicts.length}` : ''}
上次同步:${esc(last)}
${r.last_sync && r.last_sync.detail ? `
${esc(r.last_sync.detail)}
` : ''} ${r.status === 'error' && r.last_error ? `
${esc(r.last_error)}
` : ''}
`; return el; } $('#grid').addEventListener('click', async (e) => { const btn = e.target.closest('button[data-act]'); if (!btn) return; const name = btn.closest('.card').dataset.name; const act = btn.dataset.act; try { if (act === 'sync') { await post(`/api/repos/${name}/sync`); } else if (act === 'toggle') { const r = repos.find(x => x.name === name); await post(`/api/repos/${name}/${r.running ? 'stop' : 'start'}`); } else if (act === 'detail') { openDetail(name); } } catch (err) { alert(err.message); } }); // ---------- SSE 实时事件 ---------- function connect() { const es = new EventSource('/api/events'); es.onopen = () => setConn(true); es.onerror = () => setConn(false); // EventSource 会自动重连 es.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === 'snapshot') { repos = msg.data || []; render(); } else if (msg.type === 'status') { const i = repos.findIndex(x => x.name === msg.repo); if (i >= 0) repos[i] = msg.data; else repos.push(msg.data); render(); } else if (msg.type === 'log') { appendLiveLine(msg); if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] ${msg.msg}`); } }; } function setConn(ok) { const el = $('#conn'); el.textContent = ok ? '● 已连接' : '● 未连接'; el.className = 'conn ' + (ok ? 'on' : 'off'); const live = $('#live-conn'); if (live) { live.textContent = ok ? '● 已连接' : '● 未连接'; live.className = 'conn ' + (ok ? 'on' : 'off'); } } // ---------- 全局实时活动面板 ---------- const LIVE_MAX = 400; function appendLiveLine(msg) { const box = $('#live-log'); if (!box) return; let level = 'info'; if (msg.level === 'error') level = 'err'; else if (msg.level === 'warn') level = 'warn'; const line = document.createElement('div'); line.className = 'live-line lv-' + level; line.innerHTML = `${esc(msg.time)}${esc(msg.repo)}${esc(msg.msg)}`; box.appendChild(line); while (box.childElementCount > LIVE_MAX) box.removeChild(box.firstChild); box.scrollTop = box.scrollHeight; } function clearLive() { const box = $('#live-log'); if (box) box.innerHTML = ''; } // 载入各仓库最近日志作为初始上下文,避免面板一开始空白(仅启动播种一次) let liveSeeded = false; async function seedLive() { if (liveSeeded) return; liveSeeded = true; const names = repos.map(r => r.name); await Promise.all(names.map(async (name) => { try { const d = await api(`/api/repos/${name}/log?lines=8`); if (d.lines && d.lines.length) { d.lines.forEach(t => { const m = t.match(/^\[(.+?)\] (.*)$/); appendLiveLine({ time: m ? m[1] : '', repo: name, msg: m ? m[2] : t, level: 'info', seed: true }); }); } } catch (_) { /* 忽略 */ } })); } $('#btn-live-clear').addEventListener('click', clearLive); // ---------- 登录 ---------- function showLogin() { const el = $('#login'); if (el.hidden) { el.hidden = false; setTimeout(() => $('#login-password').focus(), 50); } } async function doLogin() { const errEl = $('#login-err'); errEl.hidden = true; try { await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: $('#login-password').value }) }).then(async res => { if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || '登录失败'); } }); location.reload(); } catch (err) { errEl.textContent = err.message; errEl.hidden = false; } } $('#btn-login').addEventListener('click', doLogin); $('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); // ---------- 详情弹窗 ---------- function showTab(t) { document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t)); document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; }); if (t === 'log' && current) loadLog(current); if (t === 'history' && current) loadHistory(current); if (t === 'conflict' && current) loadConflicts(current); if (t === 'stats' && current) loadStats(current); } $('#m-tabs').addEventListener('click', (e) => { const b = e.target.closest('.tab'); if (b) showTab(b.dataset.tab); }); function openDetail(name) { creating = false; current = name; const r = repos.find(x => x.name === name); $('#m-title').textContent = name; $('#f-name').disabled = true; $('#btn-delete').hidden = false; fillForm(r); showTab('log'); $('#modal').hidden = false; } function openCreate() { creating = true; current = null; $('#m-title').textContent = '新建配置'; $('#f-name').disabled = false; $('#btn-delete').hidden = true; fillForm(null); showTab('config'); $('#modal').hidden = false; } function closeModal() { $('#modal').hidden = true; current = null; } $('#m-close').addEventListener('click', closeModal); $('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); }); // ---------- 日志 ---------- function loadLog(name) { $('#logbox').textContent = '加载中…'; api(`/api/repos/${name}/log?lines=500`).then(d => { $('#logbox').textContent = d.lines && d.lines.length ? d.lines.join('\n') : '(暂无日志)'; $('#logbox').scrollTop = $('#logbox').scrollHeight; }).catch(err => { $('#logbox').textContent = '日志加载失败:' + err.message; }); } function appendLogLine(line) { const el = $('#logbox'); if (el.textContent === '(暂无日志)') el.textContent = ''; el.textContent += (el.textContent ? '\n' : '') + line; el.scrollTop = el.scrollHeight; } // ---------- 提交历史 ---------- function loadHistory(name) { const list = $('#commit-list'); list.textContent = '加载中…'; api(`/api/repos/${name}/commits?limit=50`).then(commits => { if (!commits.length) { list.textContent = '暂无提交记录。'; return; } list.innerHTML = commits.map(c => `
${esc(c.hash.slice(0, 8))} ${esc(c.subject)} ${esc(c.author)} · ${esc(c.date)}
`).join(''); }).catch(err => { list.textContent = '提交历史加载失败:' + err.message; }); } $('#commit-list').addEventListener('click', async (e) => { const head = e.target.closest('.commit-head'); if (!head || !current) return; const item = head.closest('.commit'); const pre = item.querySelector('.commit-diff'); if (!pre.hidden) { pre.hidden = true; return; } pre.hidden = false; pre.textContent = '加载中…'; try { const d = await api(`/api/repos/${current}/commits/${item.dataset.hash}`); pre.textContent = d.diff || '(无差异内容)'; } catch (err) { pre.textContent = '加载失败:' + err.message; } }); // ---------- 冲突 ---------- function loadConflicts(name) { api(`/api/repos/${name}/conflicts`).then(files => { const list = $('#conflict-list'); if (!files.length) { list.innerHTML = '

当前没有冲突。

'; return; } list.innerHTML = files.map(f => `

${esc(f.file)}

${esc(f.diff) || '(无差异内容)'}
` ).join(''); }).catch(err => { $('#conflict-list').innerHTML = `

${esc(err.message)}

`; }); } async function doAction(action, okMsg) { try { await api(`/api/repos/${current}/action`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }); alert(okMsg); loadConflicts(current); } catch (err) { alert(err.message); } } $('#btn-abort').addEventListener('click', () => { if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并'); }); $('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成')); // ---------- 统计 ---------- function dateStr(d) { return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } function loadStats(name) { api(`/api/repos/${name}/stats`).then(rows => { const days = []; for (let i = 29; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(dateStr(d)); } const mk = (key) => days.map(d => rows.filter(r => r.result === key && r.time.slice(0, 10) === d).length); const total = { ok: 0, error: 0, conflict: 0 }; rows.forEach(r => { if (total[r.result] !== undefined) total[r.result]++; }); $('#stat-summary').innerHTML = `近 30 天共 ${rows.length} 次同步记录` + `成功 ${total.ok}` + `失败 ${total.error}` + `冲突 ${total.conflict}`; if (chart) { chart.destroy(); chart = null; } if (!window.Chart) return; chart = new Chart($('#stat-chart'), { type: 'bar', data: { labels: days.map(d => d.slice(5)), datasets: [ { label: '成功', data: mk('ok'), backgroundColor: '#97c459' }, { label: '失败', data: mk('error'), backgroundColor: '#f09595' }, { label: '冲突', data: mk('conflict'), backgroundColor: '#ef9f27' }, ] }, options: { responsive: true, scales: { x: { stacked: true }, y: { stacked: true, ticks: { precision: 0 } } }, plugins: { legend: { position: 'bottom' } } } }); }).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; }); } // ---------- 配置表单 ---------- function fillForm(r) { $('#f-name').value = r ? r.name : ''; $('#f-path').value = r ? r.repo_path : ''; $('#f-branch').value = r ? r.branch : 'main'; $('#f-mode').value = r ? r.mode : 'both'; $('#f-remotes').value = r && r.remotes ? r.remotes.map(x => x.name + '=' + x.url).join('\n') : ''; $('#f-interval').value = r ? r.interval : 60; $('#f-auth').value = r ? r.auth_method : 'ssh'; $('#f-conflict').value = r ? r.conflict_strategy : 'merge'; $('#f-retry').value = r ? r.retry_count : 3; $('#f-retry-interval').value = r ? r.retry_interval : 5; $('#f-autocommit').checked = r ? !!r.auto_commit : false; $('#f-cron').value = r ? (r.cron || '') : ''; $('#f-log').value = r ? (r.log_file || '') : ''; } function readForm() { const remotes = []; $('#f-remotes').value.split('\n').forEach(line => { line = line.trim(); if (!line) return; const i = line.indexOf('='); if (i > 0) remotes.push({ name: line.slice(0, i).trim(), url: line.slice(i + 1).trim() }); }); return { name: $('#f-name').value.trim(), repo_path: $('#f-path').value.trim(), branch: $('#f-branch').value.trim() || 'main', remotes, mode: $('#f-mode').value, interval: parseInt($('#f-interval').value, 10) || 60, auth_method: $('#f-auth').value, retry_count: parseInt($('#f-retry').value, 10) || 0, retry_interval: parseInt($('#f-retry-interval').value, 10) || 0, conflict_strategy: $('#f-conflict').value, auto_commit: $('#f-autocommit').checked, cron: $('#f-cron').value.trim(), log_file: $('#f-log').value.trim(), }; } $('#cfg-form').addEventListener('submit', async (e) => { e.preventDefault(); const cfg = readForm(); try { if (creating) { await api('/api/repos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg) }); } else { await api(`/api/repos/${cfg.name}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg) }); } closeModal(); refresh(); } catch (err) { alert('保存失败:' + err.message); } }); $('#btn-delete').addEventListener('click', async () => { if (!current) return; if (!confirm(`确定删除配置 "${current}" ?(不影响日志和统计文件)`)) return; try { await api(`/api/repos/${current}`, { method: 'DELETE' }); closeModal(); refresh(); } catch (err) { alert(err.message); } }); // ---------- 顶部按钮 ---------- $('#btn-new').addEventListener('click', openCreate); $('#btn-empty-new').addEventListener('click', openCreate); $('#btn-sync-all').addEventListener('click', () => { repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {})); }); // ---------- 初始化 ---------- async function refresh() { try { const d = await api('/api/status'); repos = d.repos || []; render(); seedLive(); } catch (e) { /* 忽略,SSE 会重试 */ } } refresh(); connect(); setInterval(refresh, 20000);