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 = `

${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) { 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 = `${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); 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 => `
${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)}

`; }); } // 冲突文件内联编辑:展开编辑区 → 读取原文 → 保存 / 保存并标记已解决 $('#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)} ✕ 显示全部`; 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 => `${esc(f.path)} +${f.add} -${f.del}` ).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 ? `${esc(st)}` : ''; return `
${esc(f.path)}${badge}
`; }).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 = '已恢复未保存的草稿 放弃草稿'; 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 = `近 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 || '') : ''; 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);