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: '冲突' };
// ---------- 相对时间(卡片「上次同步」) ----------
const STALE_HOURS = 24; // 超过该小时数且仍在运行 → 标记「久未同步」
function parseTs(s) {
if (!s) return null;
// 后端时间格式 "2006-01-02 15:04:05",补齐为 ISO 本地时间
const t = new Date(String(s).replace(' ', 'T'));
return isNaN(t.getTime()) ? null : t;
}
function relTime(s) {
const t = parseTs(s);
if (!t) return String(s || '');
const min = Math.floor((Date.now() - t.getTime()) / 60000);
if (min < 1) return '刚刚';
if (min < 60) return min + ' 分钟前';
const hr = Math.floor(min / 60);
if (hr < 24) return hr + ' 小时前';
const day = Math.floor(hr / 24);
if (day < 30) return day + ' 天前';
return String(s).slice(0, 10);
}
function isStale(s) {
const t = parseTs(s);
return !!t && (Date.now() - t.getTime()) > STALE_HOURS * 3600 * 1000;
}
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
? `${relTime(r.last_sync.time)} · ${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}` : ''}
${r.push_conflict ? `推送冲突` : ''}
${r.running && r.last_sync && isStale(r.last_sync.time) ? '久未同步' : ''}
上次同步:${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();
updateConflictActions();
if (current === msg.repo && !$('#tab-diff').hidden) loadDiff(msg.repo);
} else if (msg.type === 'log') {
appendLiveLine(msg);
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] [${msg.level || 'info'}] ${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(/^\[(.+?)\]\s*(?:\[(info|warn|error)\]\s*)?(.*)$/i);
appendLiveLine({
time: m ? m[1] : '',
repo: name,
msg: m ? m[3] : t,
level: m && m[2] ? m[2].toLowerCase() : '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 === 'diff' && current) loadDiff(current);
if (t === 'file' && current) loadTree(current);
if (t === 'conflict' && current) { loadConflicts(current); updateConflictActions(); }
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(); });
// ---------- 日志(级别过滤 + 关键词搜索) ----------
let logLines = [];
let logLevel = 'all';
let logQuery = '';
// 落盘格式:[时间] [级别] 消息;历史日志无级别前缀时按 info 处理
function logLevelOf(line) {
const m = /^\[[^\]]+\]\s*\[(info|warn|error)\]/i.exec(line);
return m ? m[1].toLowerCase() : 'info';
}
function renderLog() {
const box = $('#logbox');
const q = logQuery.trim().toLowerCase();
const out = logLines.filter(l =>
(logLevel === 'all' || logLevelOf(l) === logLevel) &&
(!q || l.toLowerCase().includes(q))
);
if (!logLines.length) box.textContent = '(暂无日志)';
else if (!out.length) box.textContent = '(当前过滤条件下无匹配行)';
else box.textContent = out.join('\n');
box.scrollTop = box.scrollHeight;
}
function loadLog(name) {
$('#logbox').textContent = '加载中…';
api(`/api/repos/${name}/log?lines=500`).then(d => {
logLines = d.lines && d.lines.length ? d.lines : [];
renderLog();
}).catch(err => { logLines = []; $('#logbox').textContent = '日志加载失败:' + err.message; });
}
function appendLogLine(line) {
logLines.push(line);
if (logLines.length > 2000) logLines.shift();
renderLog();
}
$('#log-level').addEventListener('change', (e) => { logLevel = e.target.value; renderLog(); });
$('#log-search').addEventListener('input', (e) => { logQuery = e.target.value; renderLog(); });
$('#btn-log-clear').addEventListener('click', () => {
logLevel = 'all';
logQuery = '';
$('#log-level').value = 'all';
$('#log-search').value = '';
renderLog();
});
// ---------- 提交历史 ----------
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', '冲突处理流程完成'));
// 手动拉取 / 推送 / 获取(B1:后端 ManualGit 已支持)
$('#btn-fetch').addEventListener('click', () => doAction('fetch', '获取完成'));
$('#btn-pull').addEventListener('click', () => doAction('pull', '拉取完成'));
$('#btn-push').addEventListener('click', () => doAction('push', '推送完成'));
// 推送冲突(non-fast-forward)的解决入口(B3)
$('#btn-force-push').addEventListener('click', async () => {
if (!current) return;
try {
await post(`/api/repos/${current}/force-push`);
alert('已发起强制推送(--force-with-lease)');
updateConflictActions();
} catch (err) { alert(err.message); }
});
$('#btn-abort-push').addEventListener('click', async () => {
if (!current) return;
try {
await post(`/api/repos/${current}/abort-push`);
alert('已放弃强制推送');
updateConflictActions();
} catch (err) { alert(err.message); }
});
// 根据当前仓库快照切换推送冲突操作区显隐(B3)
function updateConflictActions() {
const r = repos.find(x => x.name === current);
const box = $('#push-conflict-actions');
const hint = $('#push-conflict-hint');
if (!box) return;
if (r && r.push_conflict) {
box.hidden = false;
if (hint) hint.textContent = `远程 ${r.push_conflict_remote || ''} 分支 ${r.push_conflict_branch || ''} 有新提交,推送被拒绝`;
} else {
box.hidden = true;
}
}
// ---------- 差异显示(网页端) ----------
function loadDiff(name) {
const scope = $('#f-diff-scope').value;
const box = $('#diffbox');
const stat = $('#diff-stat');
box.textContent = '加载中…';
stat.innerHTML = '';
api(`/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`).then(d => {
renderDiff(box, d.text || '(无差异)');
if (d.files && d.files.length) {
stat.innerHTML = d.files.map(f =>
`${esc(f.path)} +${f.add} -${f.del}`
).join('');
} else if (d.stat) {
stat.textContent = d.stat.trim();
}
}).catch(err => { box.textContent = '差异加载失败:' + err.message; });
}
function renderDiff(box, text) {
box.innerHTML = '';
const lines = String(text).split('\n');
const frag = document.createDocumentFragment();
lines.forEach(line => {
const pre = document.createElement('div');
pre.className = 'diff-line';
if (line.startsWith('+')) pre.classList.add('diff-add');
else if (line.startsWith('-')) pre.classList.add('diff-del');
else if (line.startsWith('@@')) pre.classList.add('diff-hunk');
pre.textContent = line === '' ? ' ' : line;
frag.appendChild(pre);
});
box.appendChild(frag);
}
// ---------- 文件编辑(网页端) ----------
let fileCurrent = null;
function loadTree(name) {
const list = $('#file-list');
list.textContent = '加载中…';
api(`/api/repos/${name}/tree`).then(d => {
const files = d.files || [];
if (!files.length) { list.textContent = '(无文件)'; return; }
list.innerHTML = files.map(f =>
`${esc(f.path)}
`
).join('');
}).catch(err => { list.textContent = '文件列表加载失败:' + err.message; });
}
async function loadFile(name, path) {
const editor = $('#file-editor');
editor.value = '加载中…';
editor.disabled = false;
$('#file-current').textContent = path;
fileCurrent = path;
try {
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
if (d.binary) {
editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
editor.disabled = true;
} else {
editor.value = d.content || '';
editor.disabled = false;
}
} catch (err) {
editor.value = '读取失败:' + err.message;
editor.disabled = true;
}
}
async function saveFile(commit) {
const name = current;
const path = fileCurrent;
if (!name || !path) { alert('请先在左侧选择一个文件'); return; }
const editor = $('#file-editor');
if (editor.disabled) { alert('当前文件不可编辑(二进制或读取失败)'); return; }
const content = editor.value;
try {
// 先写盘
await api(`/api/repos/${name}/file`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content })
});
if (commit) {
await api(`/api/repos/${name}/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, message: $('#file-msg').value })
});
alert('已保存并提交');
} else {
// 保存后自动切到差异预览
$('#f-diff-scope').value = 'working';
showTab('diff');
alert('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。');
}
} catch (err) { alert('保存失败:' + err.message); }
}
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
$('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); });
$('#file-list').addEventListener('click', (e) => {
const item = e.target.closest('.file-item');
if (!item || !current) return;
loadFile(current, item.dataset.path);
});
$('#btn-file-save').addEventListener('click', () => saveFile(false));
$('#btn-file-commit').addEventListener('click', () => saveFile(true));
// ---------- 统计 ----------
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));
}
// 后端写入的 result 词汇是 success/failed/conflict,这里归一化到 ok/error/conflict 再统计
const normResult = r => r.result === 'success' ? 'ok' : r.result === 'failed' ? 'error' : r.result;
const mk = (key) => days.map(d => rows.filter(r => normResult(r) === key && r.time.slice(0, 10) === d).length);
const total = { ok: 0, error: 0, conflict: 0 };
rows.forEach(r => { const k = normResult(r); if (total[k] !== undefined) total[k]++; });
$('#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-push').value = r ? r.push_strategy : 'manual';
$('#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,
push_strategy: $('#f-push').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);