1193 lines
42 KiB
JavaScript
1193 lines
42 KiB
JavaScript
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' });
|
||
|
||
// ---------- Toast 轻提示(替代原生 alert,避免打断操作) ----------
|
||
function toast(msg, type) {
|
||
const box = $('#toasts');
|
||
// 兜底:容器缺失时不能递归调用自身
|
||
if (!box) { console.warn('toast 容器缺失:', msg); return; }
|
||
const el = document.createElement('div');
|
||
el.className = 'toast t-' + (type || 'info');
|
||
el.textContent = msg;
|
||
el.title = '点击关闭';
|
||
el.addEventListener('click', () => el.remove());
|
||
box.appendChild(el);
|
||
while (box.childElementCount > 5) box.removeChild(box.firstChild); // 最多堆叠 5 条
|
||
const life = type === 'error' ? 6000 : 3200;
|
||
setTimeout(() => {
|
||
el.classList.add('out');
|
||
setTimeout(() => el.remove(), 320);
|
||
}, life);
|
||
}
|
||
|
||
// ---------- 编辑器抽象:统一原生 textarea 与 CodeMirror 的读写 ----------
|
||
// CodeMirror 由 cm-boot.js 在脚本加载后接管并写入 Editor.cm;
|
||
// 库缺失 / 初始化失败时 Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。
|
||
const Editor = {
|
||
cm: null,
|
||
get el() { return $('#file-editor'); },
|
||
get value() {
|
||
if (this.cm) return this.cm.getValue();
|
||
return this.el ? this.el.value : '';
|
||
},
|
||
set value(v) {
|
||
const s = v == null ? '' : String(v);
|
||
if (this.cm) this.cm.setValue(s);
|
||
else if (this.el) this.el.value = s;
|
||
},
|
||
get disabled() {
|
||
if (this.cm) return this.cm.getOption('readOnly') === 'nocursor';
|
||
return !!(this.el && this.el.disabled);
|
||
},
|
||
set disabled(b) {
|
||
if (this.cm) this.cm.setOption('readOnly', b ? 'nocursor' : false);
|
||
if (this.el) this.el.disabled = !!b;
|
||
},
|
||
// 按扩展名选择语法高亮模式
|
||
setMode(path) {
|
||
if (!this.cm) return;
|
||
const ext = String(path || '').split('.').pop().toLowerCase();
|
||
const map = {
|
||
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
||
json: 'application/json', ts: 'text/typescript', tsx: 'text/typescript',
|
||
html: 'htmlmixed', htm: 'htmlmixed', vue: 'htmlmixed', xml: 'xml', svg: 'xml',
|
||
css: 'css', scss: 'css', less: 'css',
|
||
go: 'go', py: 'python', sh: 'shell', bash: 'shell', zsh: 'shell',
|
||
c: 'clike', h: 'clike', cpp: 'clike', cc: 'clike', hpp: 'clike',
|
||
java: 'clike', kt: 'clike', cs: 'clike', scala: 'clike',
|
||
yml: 'yaml', yaml: 'yaml', sql: 'sql',
|
||
md: 'markdown', markdown: 'markdown',
|
||
ini: 'properties', properties: 'properties', env: 'properties',
|
||
cfg: 'properties', conf: 'properties', toml: 'properties',
|
||
diff: 'diff', patch: 'diff',
|
||
};
|
||
this.cm.setOption('mode', map[ext] || 'text/plain');
|
||
},
|
||
setTheme(dark) {
|
||
if (this.cm) this.cm.setOption('theme', dark ? 'material-darker' : 'eclipse');
|
||
},
|
||
// 弹窗 / tab 切换后需要重算尺寸,否则 CodeMirror 可能不显示内容
|
||
refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); },
|
||
onSave: null, // 由 cm-boot.js 的快捷键触发
|
||
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
|
||
};
|
||
// 顶层 const 不会挂到 window,显式暴露给 cm-boot.js 使用
|
||
window.Editor = Editor;
|
||
|
||
// ---------- 渲染 ----------
|
||
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 = `
|
||
<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>` : ''}
|
||
${r.push_conflict ? `<span class="badge b-conflict">推送冲突</span>` : ''}
|
||
${r.running && r.last_sync && isStale(r.last_sync.time) ? '<span class="badge b-stale">久未同步</span>' : ''}
|
||
</div>
|
||
<div class="last" title="${r.last_sync ? esc(r.last_sync.time) : ''}">上次同步:${esc(last)}</div>
|
||
${r.last_sync && r.last_sync.detail ? `<div class="last-detail" title="${esc(r.last_sync.detail)}">${esc(r.last_sync.detail)}</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) { toast(err.message, 'error'); }
|
||
});
|
||
|
||
// ---------- 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 = `<span class="ll-time">${esc(msg.time)}</span><span class="ll-repo">${esc(msg.repo)}</span><span class="ll-msg">${esc(msg.msg)}</span>`;
|
||
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); Editor.refresh(); }
|
||
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;
|
||
$('#btn-clone').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;
|
||
$('#btn-clone').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 => `
|
||
<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" data-path="${esc(f.file)}">
|
||
<div class="cf-head">
|
||
<h4 title="${esc(f.file)}">${esc(f.file)}</h4>
|
||
<button class="btn btn-sm" data-cf="edit">编辑解决</button>
|
||
<button class="btn btn-sm" data-cf="close" hidden>收起</button>
|
||
</div>
|
||
<div class="cf-edit" hidden>
|
||
<textarea class="cf-ta" spellcheck="false" placeholder="加载中…"></textarea>
|
||
<div class="cf-actions">
|
||
<input class="file-msg cf-msg" placeholder="提交说明(可选)">
|
||
<button class="btn btn-sm" data-cf="save">仅保存</button>
|
||
<button class="btn btn-sm btn-primary" data-cf="resolve">保存并标记已解决</button>
|
||
</div>
|
||
<div class="cf-hint">「仅保存」只写回磁盘;「保存并标记已解决」会暂存该文件,并在已无其它冲突文件时结束合并/变基。</div>
|
||
</div>
|
||
<pre>${esc(f.diff) || '(无差异内容)'}</pre>
|
||
</div>`).join('');
|
||
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
||
}
|
||
|
||
// 冲突文件内联编辑:展开编辑区 → 读取原文 → 保存 / 保存并标记已解决
|
||
$('#conflict-list').addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('button[data-cf]');
|
||
if (!btn || !current) return;
|
||
const item = btn.closest('.conflict-file');
|
||
const path = item.dataset.path;
|
||
const act = btn.dataset.cf;
|
||
const wrap = item.querySelector('.cf-edit');
|
||
const ta = item.querySelector('.cf-ta');
|
||
const editBtn = item.querySelector('[data-cf="edit"]');
|
||
const closeBtn = item.querySelector('[data-cf="close"]');
|
||
|
||
if (act === 'edit') {
|
||
wrap.hidden = false;
|
||
editBtn.hidden = true;
|
||
closeBtn.hidden = false;
|
||
ta.disabled = false;
|
||
ta.value = '加载中…';
|
||
try {
|
||
const d = await api(`/api/repos/${current}/file?path=${encodeURIComponent(path)}`);
|
||
if (d.binary) {
|
||
ta.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
||
ta.disabled = true;
|
||
} else {
|
||
ta.value = d.content || '';
|
||
}
|
||
} catch (err) {
|
||
ta.value = '读取失败:' + err.message;
|
||
ta.disabled = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (act === 'close') {
|
||
wrap.hidden = true;
|
||
editBtn.hidden = false;
|
||
closeBtn.hidden = true;
|
||
return;
|
||
}
|
||
|
||
// save / resolve
|
||
if (ta.disabled) { toast('该文件当前不可编辑', 'error'); return; }
|
||
try {
|
||
await api(`/api/repos/${current}/resolve-file`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
path,
|
||
content: ta.value,
|
||
finish: act === 'resolve',
|
||
})
|
||
});
|
||
toast(act === 'resolve' ? '已保存并结束合并/变基' : '已保存到磁盘(尚未标记解决)', 'success');
|
||
loadConflicts(current);
|
||
updateConflictActions();
|
||
refresh();
|
||
} catch (err) { toast(err.message, 'error'); }
|
||
});
|
||
|
||
async function doAction(action, okMsg) {
|
||
try {
|
||
await api(`/api/repos/${current}/action`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action })
|
||
});
|
||
toast(okMsg);
|
||
loadConflicts(current);
|
||
} catch (err) { toast(err.message, 'error'); }
|
||
}
|
||
$('#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`);
|
||
toast('已发起强制推送(--force-with-lease)');
|
||
updateConflictActions();
|
||
} catch (err) { toast(err.message, 'error'); }
|
||
});
|
||
$('#btn-abort-push').addEventListener('click', async () => {
|
||
if (!current) return;
|
||
try {
|
||
await post(`/api/repos/${current}/abort-push`);
|
||
toast('已放弃强制推送');
|
||
updateConflictActions();
|
||
} catch (err) { toast(err.message, 'error'); }
|
||
});
|
||
|
||
// 根据当前仓库快照切换推送冲突操作区显隐(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;
|
||
}
|
||
}
|
||
|
||
// ---------- 差异显示(网页端) ----------
|
||
// diffPath 非空时只显示单个文件的差异(编辑器「对比此文件」进入)
|
||
let diffPath = '';
|
||
// diffText 保存当前差异原文,供「复制 / 下载 .patch」使用
|
||
let diffText = '';
|
||
|
||
function updateDiffFilterNote() {
|
||
const el = $('#diff-filter');
|
||
if (!el) return;
|
||
if (!diffPath) { el.hidden = true; el.textContent = ''; return; }
|
||
el.hidden = false;
|
||
el.innerHTML = `仅显示:${esc(diffPath)} <a href="#" id="diff-filter-clear" style="color:inherit">✕ 显示全部</a>`;
|
||
const a = $('#diff-filter-clear');
|
||
if (a) a.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
diffPath = '';
|
||
updateDiffFilterNote();
|
||
if (current) loadDiff(current);
|
||
});
|
||
}
|
||
|
||
function loadDiff(name) {
|
||
const scope = $('#f-diff-scope').value;
|
||
const box = $('#diffbox');
|
||
const stat = $('#diff-stat');
|
||
box.textContent = '加载中…';
|
||
stat.innerHTML = '';
|
||
updateDiffFilterNote();
|
||
let url = `/api/repos/${name}/diff?scope=${encodeURIComponent(scope)}`;
|
||
if (diffPath) url += `&path=${encodeURIComponent(diffPath)}`;
|
||
api(url).then(d => {
|
||
diffText = d.text || '';
|
||
renderDiff(box, diffText || '(无差异)');
|
||
if (d.files && d.files.length) {
|
||
stat.innerHTML = d.files.map(f =>
|
||
`<span class="df ${f.add ? 'df-add' : ''} ${f.del ? 'df-del' : ''}">${esc(f.path)} <b style="color:var(--green)">+${f.add}</b> <b style="color:var(--red)">-${f.del}</b></span>`
|
||
).join('');
|
||
} else if (d.stat) {
|
||
stat.textContent = d.stat.trim();
|
||
}
|
||
}).catch(err => { box.textContent = '差异加载失败:' + err.message; });
|
||
}
|
||
|
||
// ---------- 差异渲染:统一 / 并排 / 词级 ----------
|
||
// 把 unified diff 解析成「文件 → 行记录」,统一与并排两种视图共用同一份结构
|
||
function parseUnifiedDiff(text) {
|
||
const lines = String(text).split('\n');
|
||
const files = [];
|
||
let cur = null;
|
||
let oldNo = 0, newNo = 0;
|
||
const push = (header) => { cur = { header, rows: [] }; files.push(cur); return cur; };
|
||
for (const line of lines) {
|
||
if (line.startsWith('diff --git') || line.startsWith('Index: ')) { push(line); continue; }
|
||
if (!cur && line === '') continue; // 开头空行忽略
|
||
if (!cur) push('');
|
||
if (line.startsWith('@@')) {
|
||
const m = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
||
if (m) { oldNo = parseInt(m[1], 10); newNo = parseInt(m[2], 10); }
|
||
inHunk = true;
|
||
cur.rows.push({ type: 'hunk', text: line });
|
||
continue;
|
||
}
|
||
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('index ') ||
|
||
line.startsWith('new file') || line.startsWith('deleted file') || line.startsWith('similarity ') ||
|
||
line.startsWith('rename ') || line.startsWith('old mode') || line.startsWith('new mode')) {
|
||
cur.rows.push({ type: 'meta', text: line });
|
||
continue;
|
||
}
|
||
if (line.startsWith('+')) { cur.rows.push({ type: 'add', text: line.slice(1), no: newNo++ }); continue; }
|
||
if (line.startsWith('-')) { cur.rows.push({ type: 'del', text: line.slice(1), no: oldNo++ }); continue; }
|
||
if (line.startsWith(' ')) { cur.rows.push({ type: 'ctx', text: line.slice(1), no: oldNo++, nNo: newNo++ }); continue; }
|
||
if (line === '') { cur.rows.push({ type: 'ctx', text: '', no: oldNo++, nNo: newNo++ }); continue; }
|
||
// 词级模式(--word-diff=plain)下 git 的上下文行不带前导空格,
|
||
// 因此 hunk 内未识别的行一律按上下文处理;hunk 外的(如 "\ No newline")按元信息处理。
|
||
if (inHunk && !line.startsWith('\\')) {
|
||
cur.rows.push({ type: 'ctx', text: line, no: oldNo++, nNo: newNo++ });
|
||
continue;
|
||
}
|
||
cur.rows.push({ type: 'meta', text: line });
|
||
}
|
||
return files;
|
||
}
|
||
|
||
// 词级:把 [-删除-] / {+新增+} 渲染成高亮片段(textContent 拼接,天然防 XSS)
|
||
function fillWord(el, text) {
|
||
const re = /\[-([\s\S]*?)-\]|\{\+([\s\S]*?)\+\}/g;
|
||
let last = 0, m;
|
||
el.textContent = '';
|
||
while ((m = re.exec(text)) !== null) {
|
||
if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
|
||
const span = document.createElement('span');
|
||
const isDel = m[1] !== undefined;
|
||
span.className = isDel ? 'wd-del' : 'wd-add';
|
||
span.textContent = isDel ? m[1] : m[2];
|
||
el.appendChild(span);
|
||
last = re.lastIndex;
|
||
}
|
||
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
|
||
if (!el.childNodes.length) el.appendChild(document.createTextNode(text));
|
||
}
|
||
|
||
// 并排:把连续的 del/add 段配对成行,多余的一侧留空
|
||
function buildSplitRows(rows) {
|
||
const out = [];
|
||
let i = 0;
|
||
while (i < rows.length) {
|
||
const r = rows[i];
|
||
if (r.type === 'hunk' || r.type === 'meta') { out.push({ kind: r.type, text: r.text }); i++; continue; }
|
||
if (r.type === 'ctx') {
|
||
// 左右列行号分别取旧/新行号,故右侧用副本
|
||
out.push({ kind: 'ctx', left: r, right: { text: r.text, no: r.nNo == null ? r.no : r.nNo } });
|
||
i++;
|
||
continue;
|
||
}
|
||
const dels = [], adds = [];
|
||
while (i < rows.length && rows[i].type === 'del') dels.push(rows[i++]);
|
||
while (i < rows.length && rows[i].type === 'add') adds.push(rows[i++]);
|
||
const n = Math.max(dels.length, adds.length);
|
||
for (let k = 0; k < n; k++) {
|
||
const l = dels[k] || null, a = adds[k] || null;
|
||
out.push({ kind: l && a ? 'chg' : (l ? 'del' : 'add'), left: l, right: a });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function fileHeaderOf(h) {
|
||
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(h);
|
||
return m ? m[2] : h;
|
||
}
|
||
|
||
function sideCell(cls, r, word) {
|
||
const c = document.createElement('div');
|
||
c.className = 'scol ' + cls;
|
||
const ln = document.createElement('span');
|
||
ln.className = 'ln';
|
||
ln.textContent = r ? (r.no == null ? '' : r.no) : '';
|
||
const t = document.createElement('span');
|
||
t.className = 'stxt';
|
||
if (r) { if (word) fillWord(t, r.text); else t.textContent = r.text; }
|
||
c.appendChild(ln);
|
||
c.appendChild(t);
|
||
return c;
|
||
}
|
||
|
||
function buildSplit(f, word) {
|
||
const frag = document.createDocumentFragment();
|
||
buildSplitRows(f.rows).forEach(r => {
|
||
if (r.kind === 'hunk' || r.kind === 'meta') {
|
||
const d = document.createElement('div');
|
||
d.className = 'diff-line diff-hunk';
|
||
d.textContent = r.text === '' ? ' ' : r.text;
|
||
frag.appendChild(d);
|
||
return;
|
||
}
|
||
const row = document.createElement('div');
|
||
row.className = 'srow s-' + r.kind;
|
||
row.appendChild(sideCell('sl', r.left, word));
|
||
row.appendChild(sideCell('sr', r.right, word));
|
||
frag.appendChild(row);
|
||
});
|
||
return frag;
|
||
}
|
||
|
||
function buildUnified(f, word) {
|
||
const frag = document.createDocumentFragment();
|
||
f.rows.forEach(r => {
|
||
const d = document.createElement('div');
|
||
d.className = 'diff-line';
|
||
if (r.type === 'add') d.classList.add('diff-add');
|
||
else if (r.type === 'del') d.classList.add('diff-del');
|
||
else if (r.type === 'hunk') d.classList.add('diff-hunk');
|
||
const prefix = r.type === 'add' ? '+' : r.type === 'del' ? '-' : r.type === 'ctx' ? ' ' : '';
|
||
const txt = prefix + r.text;
|
||
if (word) fillWord(d, txt); else d.textContent = txt === '' ? ' ' : txt;
|
||
frag.appendChild(d);
|
||
});
|
||
return frag;
|
||
}
|
||
|
||
function renderDiff(box, text) {
|
||
box.innerHTML = '';
|
||
const viewEl = $('#f-diff-view');
|
||
const wordEl = $('#f-diff-word');
|
||
const split = !!viewEl && viewEl.value === 'split';
|
||
const word = !!wordEl && wordEl.checked;
|
||
const files = parseUnifiedDiff(text);
|
||
const frag = document.createDocumentFragment();
|
||
if (!files.length) {
|
||
const d = document.createElement('div');
|
||
d.className = 'diff-line';
|
||
d.textContent = String(text) || '(无差异)';
|
||
frag.appendChild(d);
|
||
box.appendChild(frag);
|
||
return;
|
||
}
|
||
files.forEach(f => {
|
||
if (f.header) {
|
||
const h = document.createElement('div');
|
||
h.className = 'diff-file-head';
|
||
h.textContent = fileHeaderOf(f.header);
|
||
h.title = f.header;
|
||
frag.appendChild(h);
|
||
}
|
||
frag.appendChild(split ? buildSplit(f, word) : buildUnified(f, word));
|
||
});
|
||
box.appendChild(frag);
|
||
}
|
||
|
||
// ---------- 文件编辑(网页端) ----------
|
||
let fileCurrent = null;
|
||
const STATUS_LABEL = { M: '已修改', A: '已新增', D: '已删除', '?': '未跟踪' };
|
||
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 => {
|
||
const st = f.status || '';
|
||
const cls = st === '?' ? 'Q' : st;
|
||
const badge = st
|
||
? `<span class="fi-status s-${esc(cls)}" title="${esc(STATUS_LABEL[st] || st)}">${esc(st)}</span>`
|
||
: '';
|
||
return `<div class="file-item" data-path="${esc(f.path)}" title="${esc(f.path)}"><span class="fi-name">${esc(f.path)}</span>${badge}</div>`;
|
||
}).join('');
|
||
}).catch(err => { list.textContent = '文件列表加载失败:' + err.message; });
|
||
}
|
||
|
||
// ---------- 草稿缓存(localStorage,防刷新丢稿) ----------
|
||
const draftKey = (name, path) => `gitautosync:draft:${name}:${path}`;
|
||
let draftServer = ''; // 当前文件在磁盘上的原文,用于「放弃草稿」回滚
|
||
let draftTimer = null;
|
||
|
||
function showDraftNote(on) {
|
||
const el = $('#file-draft');
|
||
if (!el) return;
|
||
if (!on) { el.hidden = true; el.innerHTML = ''; return; }
|
||
el.hidden = false;
|
||
el.innerHTML = '已恢复未保存的草稿 <a href="#" id="draft-discard" style="color:inherit">放弃草稿</a>';
|
||
const a = $('#draft-discard');
|
||
if (a) a.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
localStorage.removeItem(draftKey(current, fileCurrent));
|
||
$('#file-editor').value = draftServer;
|
||
showDraftNote(false);
|
||
});
|
||
}
|
||
|
||
// 编辑时防抖缓存草稿;内容与磁盘一致时自动清除
|
||
function scheduleDraft() {
|
||
if (!current || !fileCurrent) return;
|
||
if (draftTimer) clearTimeout(draftTimer);
|
||
draftTimer = setTimeout(() => {
|
||
if (Editor.disabled) return;
|
||
const v = Editor.value;
|
||
if (v === draftServer) localStorage.removeItem(draftKey(current, fileCurrent));
|
||
else localStorage.setItem(draftKey(current, fileCurrent), v);
|
||
}, 600);
|
||
}
|
||
|
||
async function loadFile(name, path) {
|
||
const editor = $('#file-editor');
|
||
editor.value = '加载中…';
|
||
editor.disabled = false;
|
||
$('#file-current').textContent = path;
|
||
fileCurrent = path;
|
||
showDraftNote(false);
|
||
try {
|
||
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
|
||
if (d.binary) {
|
||
editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
||
editor.disabled = true;
|
||
draftServer = '';
|
||
return;
|
||
}
|
||
draftServer = d.content || '';
|
||
const saved = localStorage.getItem(draftKey(name, path));
|
||
if (saved !== null && saved !== draftServer) {
|
||
editor.value = saved;
|
||
showDraftNote(true);
|
||
} else {
|
||
editor.value = draftServer;
|
||
}
|
||
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) { toast('请先在左侧选择一个文件'); return; }
|
||
if (Editor.disabled) { toast('当前文件不可编辑(二进制或读取失败)'); return; }
|
||
const content = Editor.value;
|
||
try {
|
||
// 先写盘
|
||
await api(`/api/repos/${name}/file`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path, content })
|
||
});
|
||
// 写盘成功 → 草稿作废
|
||
localStorage.removeItem(draftKey(name, path));
|
||
showDraftNote(false);
|
||
draftServer = content;
|
||
if (commit) {
|
||
await api(`/api/repos/${name}/commit`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path, message: $('#file-msg').value })
|
||
});
|
||
toast('已保存并提交');
|
||
} else {
|
||
// 保存后自动切到差异预览
|
||
$('#f-diff-scope').value = 'working';
|
||
showTab('diff');
|
||
toast('已保存到磁盘(未提交)。可在「差异」tab 查看改动,或稍后由自动同步提交。');
|
||
}
|
||
} catch (err) { toast('保存失败:' + err.message); }
|
||
}
|
||
|
||
$('#f-diff-scope').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
||
// 并排/统一纯前端重渲染(同一份 diff 原文);词级需后端重新生成,故重新拉取
|
||
$('#f-diff-view').addEventListener('change', () => { renderDiff($('#diffbox'), diffText || '(无差异)'); });
|
||
$('#f-diff-word').addEventListener('change', () => { if (current && !$('#tab-diff').hidden) loadDiff(current); });
|
||
$('#btn-diff-refresh').addEventListener('click', () => { if (current) loadDiff(current); });
|
||
|
||
// 差异:复制 / 下载 .patch
|
||
$('#btn-diff-copy').addEventListener('click', async () => {
|
||
if (!diffText) { toast('当前没有可复制的差异内容'); return; }
|
||
try {
|
||
await navigator.clipboard.writeText(diffText);
|
||
toast('差异内容已复制到剪贴板');
|
||
} catch (err) {
|
||
toast('复制失败(浏览器可能限制了剪贴板权限):' + err.message);
|
||
}
|
||
});
|
||
$('#btn-diff-download').addEventListener('click', () => {
|
||
if (!diffText) { toast('当前没有可下载的差异内容'); return; }
|
||
const scope = $('#f-diff-scope').value;
|
||
const blob = new Blob([diffText], { type: 'text/plain;charset=utf-8' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `${current || 'repo'}-${scope}.patch`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(a.href);
|
||
});
|
||
|
||
$('#btn-file-refresh').addEventListener('click', () => { if (current) loadTree(current); });
|
||
|
||
// 编辑器「对比此文件」→ 跳到差异 tab 并只看该文件
|
||
$('#btn-file-diff').addEventListener('click', () => {
|
||
if (!current || !fileCurrent) { toast('请先在左侧选择一个文件'); return; }
|
||
diffPath = fileCurrent;
|
||
$('#f-diff-scope').value = 'working';
|
||
showTab('diff');
|
||
});
|
||
|
||
$('#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));
|
||
|
||
// 编辑器:输入时缓存草稿,Ctrl/Cmd + S 直接保存
|
||
$('#file-editor').addEventListener('input', scheduleDraft);
|
||
$('#file-editor').addEventListener('keydown', (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||
e.preventDefault();
|
||
saveFile(false);
|
||
}
|
||
});
|
||
|
||
// ---------- 统计 ----------
|
||
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 =
|
||
`<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-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 || '') : '';
|
||
previewCron();
|
||
}
|
||
|
||
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) { toast('保存失败:' + 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) { toast(err.message, 'error'); }
|
||
});
|
||
|
||
// ---------- 配置「克隆」按钮(多仓库同构时省得重填) ----------
|
||
// 复用 readForm 取当前表单值 → 切到新建态:名称自动加 -copy(去重),仓库路径清空
|
||
$('#btn-clone').addEventListener('click', () => {
|
||
if (!current) return;
|
||
const base = readForm();
|
||
const oldName = base.name || 'repo';
|
||
creating = true;
|
||
current = null;
|
||
const stem = oldName.replace(/-copy\d*$/i, '');
|
||
let name = stem + '-copy';
|
||
const taken = new Set(repos.map(r => r.name));
|
||
let i = 2;
|
||
while (taken.has(name)) name = stem + '-copy' + (i++);
|
||
$('#m-title').textContent = `克隆配置:${oldName}`;
|
||
$('#f-name').disabled = false;
|
||
$('#btn-delete').hidden = true;
|
||
$('#btn-clone').hidden = true;
|
||
fillForm(base);
|
||
$('#f-name').value = name;
|
||
$('#f-path').value = ''; // 路径必须换,否则会与源仓库指向同一目录
|
||
showTab('config');
|
||
toast('已载入原配置内容:请修改「配置名称」与「仓库路径」后保存(仓库路径已清空,需指向新目录)', 'info');
|
||
$('#f-name').focus();
|
||
});
|
||
|
||
// ---------- 顶部按钮 ----------
|
||
$('#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(() => {}));
|
||
toast(`已向 ${repos.filter(r => r.running).length} 个运行中的仓库发起同步`);
|
||
});
|
||
|
||
// 批量启停:并发调用,汇总成功/失败
|
||
async function batchToggle(act) {
|
||
const isStart = act === 'start';
|
||
const targets = repos.filter(r => isStart ? !r.running : r.running);
|
||
if (!targets.length) {
|
||
toast(isStart ? '没有处于暂停状态的仓库' : '没有正在运行的仓库');
|
||
return;
|
||
}
|
||
let ok = 0, fail = 0;
|
||
await Promise.all(targets.map(async (r) => {
|
||
try { await post(`/api/repos/${r.name}/${act}`); ok++; }
|
||
catch (_) { fail++; }
|
||
}));
|
||
toast(`${isStart ? '启动' : '暂停'}完成:成功 ${ok}${fail ? `,失败 ${fail}` : ''}`, fail ? 'warn' : 'success');
|
||
refresh();
|
||
}
|
||
$('#btn-start-all').addEventListener('click', () => batchToggle('start'));
|
||
$('#btn-stop-all').addEventListener('click', () => batchToggle('stop'));
|
||
|
||
// ---------- 暗色模式 ----------
|
||
const THEME_KEY = 'gitautosync:theme';
|
||
function applyTheme(dark) {
|
||
document.documentElement.classList.toggle('dark', !!dark);
|
||
const btn = $('#btn-theme');
|
||
if (btn) btn.textContent = dark ? '☀️' : '🌙';
|
||
Editor.setTheme(!!dark); // CodeMirror 主题跟随(未启用 CM 时为空操作)
|
||
}
|
||
function initTheme() {
|
||
applyTheme(localStorage.getItem(THEME_KEY) === 'dark');
|
||
}
|
||
$('#btn-theme').addEventListener('click', () => {
|
||
const dark = !document.documentElement.classList.contains('dark');
|
||
localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light');
|
||
applyTheme(dark);
|
||
});
|
||
|
||
// ---------- Cron 下次执行预览 ----------
|
||
let cronTimer = null;
|
||
async function previewCron() {
|
||
const el = $('#cron-next');
|
||
if (!el) return;
|
||
const expr = $('#f-cron').value.trim();
|
||
if (!expr) { el.textContent = ''; el.className = 'cron-next'; return; }
|
||
try {
|
||
const d = await api(`/api/cron-next?expr=${encodeURIComponent(expr)}`);
|
||
el.textContent = '下次执行:' + d.next;
|
||
el.className = 'cron-next good';
|
||
} catch (err) {
|
||
el.textContent = '表达式无效:' + err.message;
|
||
el.className = 'cron-next bad';
|
||
}
|
||
}
|
||
$('#f-cron').addEventListener('input', () => {
|
||
if (cronTimer) clearTimeout(cronTimer);
|
||
cronTimer = setTimeout(previewCron, 400);
|
||
});
|
||
|
||
// ---------- 初始化 ----------
|
||
async function refresh() {
|
||
try {
|
||
const d = await api('/api/status');
|
||
repos = d.repos || [];
|
||
render();
|
||
seedLive();
|
||
} catch (e) { /* 忽略,SSE 会重试 */ }
|
||
}
|
||
|
||
initTheme();
|
||
refresh();
|
||
connect();
|
||
setInterval(refresh, 20000);
|