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 = `
当前没有冲突。
'; return; } list.innerHTML = files.map(f => `${esc(f.diff) || '(无差异内容)'}${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(); } catch (e) { /* 忽略,SSE 会重试 */ } } refresh(); connect(); setInterval(refresh, 20000);