git-sync-web 自动提交 2026-09-08 17:12:27
This commit is contained in:
+419
@@ -0,0 +1,419 @@
|
||||
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 = `
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<h3>${esc(r.name)}</h3>
|
||||
<span class="mode">${esc(r.mode)}</span>
|
||||
</div>
|
||||
<div class="path" title="${esc(r.repo_path)}">${esc(r.repo_path)}</div>
|
||||
<div class="badges">
|
||||
<span class="badge">${esc(r.branch)}</span>
|
||||
<span class="badge ${r.ahead ? 'b-up' : ''}">领先 ↑${r.ahead}</span>
|
||||
<span class="badge ${r.behind ? 'b-down' : ''}">落后 ↓${r.behind}</span>
|
||||
${r.dirty ? `<span class="badge b-dirty">改动 ${r.dirty}</span>` : ''}
|
||||
${r.auto_commit ? '<span class="badge">自动提交</span>' : ''}
|
||||
${r.cron ? `<span class="badge" title="${esc(r.cron)}">cron</span>` : ''}
|
||||
${r.conflicts && r.conflicts.length ? `<span class="badge b-conflict">冲突 ${r.conflicts.length}</span>` : ''}
|
||||
</div>
|
||||
<div class="last">上次同步:${esc(last)}</div>
|
||||
${r.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
|
||||
<div class="card-btns">
|
||||
<button class="btn" data-act="sync" ${r.running ? '' : 'disabled'}>立即同步</button>
|
||||
<button class="btn" data-act="toggle">${r.running ? '暂停' : '启动'}</button>
|
||||
<button class="btn" data-act="detail">详情</button>
|
||||
</div>`;
|
||||
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') {
|
||||
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');
|
||||
}
|
||||
|
||||
// ---------- 登录 ----------
|
||||
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 => `
|
||||
<div class="commit" data-hash="${esc(c.hash)}">
|
||||
<div class="commit-head">
|
||||
<span class="commit-hash">${esc(c.hash.slice(0, 8))}</span>
|
||||
<span class="commit-subject" title="${esc(c.subject)}">${esc(c.subject)}</span>
|
||||
<span class="commit-meta">${esc(c.author)} · ${esc(c.date)}</span>
|
||||
</div>
|
||||
<pre class="commit-diff" hidden></pre>
|
||||
</div>`).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 = '<p style="color:var(--muted)">当前没有冲突。</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = files.map(f =>
|
||||
`<div class="conflict-file"><h4>${esc(f.file)}</h4><pre>${esc(f.diff) || '(无差异内容)'}</pre></div>`
|
||||
).join('');
|
||||
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
||||
}
|
||||
|
||||
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 =
|
||||
`<span>近 30 天共 <b>${rows.length}</b> 次同步记录</span>` +
|
||||
`<span style="color:var(--green)">成功 <b>${total.ok}</b></span>` +
|
||||
`<span style="color:var(--red)">失败 <b>${total.error}</b></span>` +
|
||||
`<span style="color:var(--amber)">冲突 <b>${total.conflict}</b></span>`;
|
||||
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);
|
||||
Reference in New Issue
Block a user