git-sync-web 自动提交 2026-09-08 17:12:27
This commit is contained in:
+419
@@ -0,0 +1,419 @@
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
const STATUS = {
|
||||
ok: { text: '正常', cls: 'st-ok' },
|
||||
error: { text: '异常', cls: 'st-error' },
|
||||
conflict: { text: '冲突', cls: 'st-conflict' },
|
||||
running: { text: '同步中', cls: 'st-running' },
|
||||
idle: { text: '未启动', cls: 'st-idle' },
|
||||
};
|
||||
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' };
|
||||
|
||||
let repos = [];
|
||||
let current = null;
|
||||
let creating = false;
|
||||
let chart = null;
|
||||
|
||||
// ---------- 基础 ----------
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function api(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 401) {
|
||||
showLogin();
|
||||
throw new Error('需要登录');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||
return data;
|
||||
}
|
||||
const post = (url) => api(url, { method: 'POST' });
|
||||
|
||||
// ---------- 渲染 ----------
|
||||
function render() {
|
||||
const grid = $('#grid');
|
||||
grid.innerHTML = '';
|
||||
repos.forEach(r => grid.appendChild(card(r)));
|
||||
$('#empty').hidden = repos.length > 0;
|
||||
}
|
||||
|
||||
function card(r) {
|
||||
const st = STATUS[r.status] || STATUS.idle;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card ' + st.cls;
|
||||
el.dataset.name = r.name;
|
||||
const last = r.last_sync
|
||||
? `${r.last_sync.time.slice(5, 16)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
|
||||
: '从未同步';
|
||||
el.innerHTML = `
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<h3>${esc(r.name)}</h3>
|
||||
<span class="mode">${esc(r.mode)}</span>
|
||||
</div>
|
||||
<div class="path" title="${esc(r.repo_path)}">${esc(r.repo_path)}</div>
|
||||
<div class="badges">
|
||||
<span class="badge">${esc(r.branch)}</span>
|
||||
<span class="badge ${r.ahead ? 'b-up' : ''}">领先 ↑${r.ahead}</span>
|
||||
<span class="badge ${r.behind ? 'b-down' : ''}">落后 ↓${r.behind}</span>
|
||||
${r.dirty ? `<span class="badge b-dirty">改动 ${r.dirty}</span>` : ''}
|
||||
${r.auto_commit ? '<span class="badge">自动提交</span>' : ''}
|
||||
${r.cron ? `<span class="badge" title="${esc(r.cron)}">cron</span>` : ''}
|
||||
${r.conflicts && r.conflicts.length ? `<span class="badge b-conflict">冲突 ${r.conflicts.length}</span>` : ''}
|
||||
</div>
|
||||
<div class="last">上次同步:${esc(last)}</div>
|
||||
${r.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
|
||||
<div class="card-btns">
|
||||
<button class="btn" data-act="sync" ${r.running ? '' : 'disabled'}>立即同步</button>
|
||||
<button class="btn" data-act="toggle">${r.running ? '暂停' : '启动'}</button>
|
||||
<button class="btn" data-act="detail">详情</button>
|
||||
</div>`;
|
||||
return el;
|
||||
}
|
||||
|
||||
$('#grid').addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('button[data-act]');
|
||||
if (!btn) return;
|
||||
const name = btn.closest('.card').dataset.name;
|
||||
const act = btn.dataset.act;
|
||||
try {
|
||||
if (act === 'sync') {
|
||||
await post(`/api/repos/${name}/sync`);
|
||||
} else if (act === 'toggle') {
|
||||
const r = repos.find(x => x.name === name);
|
||||
await post(`/api/repos/${name}/${r.running ? 'stop' : 'start'}`);
|
||||
} else if (act === 'detail') {
|
||||
openDetail(name);
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
|
||||
// ---------- SSE 实时事件 ----------
|
||||
function connect() {
|
||||
const es = new EventSource('/api/events');
|
||||
es.onopen = () => setConn(true);
|
||||
es.onerror = () => setConn(false); // EventSource 会自动重连
|
||||
es.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||
if (msg.type === 'snapshot') {
|
||||
repos = msg.data || [];
|
||||
render();
|
||||
} else if (msg.type === 'status') {
|
||||
const i = repos.findIndex(x => x.name === msg.repo);
|
||||
if (i >= 0) repos[i] = msg.data; else repos.push(msg.data);
|
||||
render();
|
||||
} else if (msg.type === 'log') {
|
||||
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] ${msg.msg}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setConn(ok) {
|
||||
const el = $('#conn');
|
||||
el.textContent = ok ? '● 已连接' : '● 未连接';
|
||||
el.className = 'conn ' + (ok ? 'on' : 'off');
|
||||
}
|
||||
|
||||
// ---------- 登录 ----------
|
||||
function showLogin() {
|
||||
const el = $('#login');
|
||||
if (el.hidden) {
|
||||
el.hidden = false;
|
||||
setTimeout(() => $('#login-password').focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const errEl = $('#login-err');
|
||||
errEl.hidden = true;
|
||||
try {
|
||||
await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: $('#login-password').value })
|
||||
}).then(async res => {
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error || '登录失败');
|
||||
}
|
||||
});
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
}
|
||||
$('#btn-login').addEventListener('click', doLogin);
|
||||
$('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||
|
||||
// ---------- 详情弹窗 ----------
|
||||
function showTab(t) {
|
||||
document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; });
|
||||
if (t === 'log' && current) loadLog(current);
|
||||
if (t === 'history' && current) loadHistory(current);
|
||||
if (t === 'conflict' && current) loadConflicts(current);
|
||||
if (t === 'stats' && current) loadStats(current);
|
||||
}
|
||||
|
||||
$('#m-tabs').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('.tab');
|
||||
if (b) showTab(b.dataset.tab);
|
||||
});
|
||||
|
||||
function openDetail(name) {
|
||||
creating = false;
|
||||
current = name;
|
||||
const r = repos.find(x => x.name === name);
|
||||
$('#m-title').textContent = name;
|
||||
$('#f-name').disabled = true;
|
||||
$('#btn-delete').hidden = false;
|
||||
fillForm(r);
|
||||
showTab('log');
|
||||
$('#modal').hidden = false;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
creating = true;
|
||||
current = null;
|
||||
$('#m-title').textContent = '新建配置';
|
||||
$('#f-name').disabled = false;
|
||||
$('#btn-delete').hidden = true;
|
||||
fillForm(null);
|
||||
showTab('config');
|
||||
$('#modal').hidden = false;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
$('#modal').hidden = true;
|
||||
current = null;
|
||||
}
|
||||
|
||||
$('#m-close').addEventListener('click', closeModal);
|
||||
$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
||||
|
||||
// ---------- 日志 ----------
|
||||
function loadLog(name) {
|
||||
$('#logbox').textContent = '加载中…';
|
||||
api(`/api/repos/${name}/log?lines=500`).then(d => {
|
||||
$('#logbox').textContent = d.lines && d.lines.length ? d.lines.join('\n') : '(暂无日志)';
|
||||
$('#logbox').scrollTop = $('#logbox').scrollHeight;
|
||||
}).catch(err => { $('#logbox').textContent = '日志加载失败:' + err.message; });
|
||||
}
|
||||
|
||||
function appendLogLine(line) {
|
||||
const el = $('#logbox');
|
||||
if (el.textContent === '(暂无日志)') el.textContent = '';
|
||||
el.textContent += (el.textContent ? '\n' : '') + line;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
// ---------- 提交历史 ----------
|
||||
function loadHistory(name) {
|
||||
const list = $('#commit-list');
|
||||
list.textContent = '加载中…';
|
||||
api(`/api/repos/${name}/commits?limit=50`).then(commits => {
|
||||
if (!commits.length) {
|
||||
list.textContent = '暂无提交记录。';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = commits.map(c => `
|
||||
<div class="commit" data-hash="${esc(c.hash)}">
|
||||
<div class="commit-head">
|
||||
<span class="commit-hash">${esc(c.hash.slice(0, 8))}</span>
|
||||
<span class="commit-subject" title="${esc(c.subject)}">${esc(c.subject)}</span>
|
||||
<span class="commit-meta">${esc(c.author)} · ${esc(c.date)}</span>
|
||||
</div>
|
||||
<pre class="commit-diff" hidden></pre>
|
||||
</div>`).join('');
|
||||
}).catch(err => { list.textContent = '提交历史加载失败:' + err.message; });
|
||||
}
|
||||
|
||||
$('#commit-list').addEventListener('click', async (e) => {
|
||||
const head = e.target.closest('.commit-head');
|
||||
if (!head || !current) return;
|
||||
const item = head.closest('.commit');
|
||||
const pre = item.querySelector('.commit-diff');
|
||||
if (!pre.hidden) { pre.hidden = true; return; }
|
||||
pre.hidden = false;
|
||||
pre.textContent = '加载中…';
|
||||
try {
|
||||
const d = await api(`/api/repos/${current}/commits/${item.dataset.hash}`);
|
||||
pre.textContent = d.diff || '(无差异内容)';
|
||||
} catch (err) {
|
||||
pre.textContent = '加载失败:' + err.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 冲突 ----------
|
||||
function loadConflicts(name) {
|
||||
api(`/api/repos/${name}/conflicts`).then(files => {
|
||||
const list = $('#conflict-list');
|
||||
if (!files.length) {
|
||||
list.innerHTML = '<p style="color:var(--muted)">当前没有冲突。</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = files.map(f =>
|
||||
`<div class="conflict-file"><h4>${esc(f.file)}</h4><pre>${esc(f.diff) || '(无差异内容)'}</pre></div>`
|
||||
).join('');
|
||||
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
||||
}
|
||||
|
||||
async function doAction(action, okMsg) {
|
||||
try {
|
||||
await api(`/api/repos/${current}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
alert(okMsg);
|
||||
loadConflicts(current);
|
||||
} catch (err) { alert(err.message); }
|
||||
}
|
||||
$('#btn-abort').addEventListener('click', () => {
|
||||
if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并');
|
||||
});
|
||||
$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成'));
|
||||
|
||||
// ---------- 统计 ----------
|
||||
function dateStr(d) {
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function loadStats(name) {
|
||||
api(`/api/repos/${name}/stats`).then(rows => {
|
||||
const days = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
days.push(dateStr(d));
|
||||
}
|
||||
const mk = (key) => days.map(d => rows.filter(r => r.result === key && r.time.slice(0, 10) === d).length);
|
||||
const total = { ok: 0, error: 0, conflict: 0 };
|
||||
rows.forEach(r => { if (total[r.result] !== undefined) total[r.result]++; });
|
||||
$('#stat-summary').innerHTML =
|
||||
`<span>近 30 天共 <b>${rows.length}</b> 次同步记录</span>` +
|
||||
`<span style="color:var(--green)">成功 <b>${total.ok}</b></span>` +
|
||||
`<span style="color:var(--red)">失败 <b>${total.error}</b></span>` +
|
||||
`<span style="color:var(--amber)">冲突 <b>${total.conflict}</b></span>`;
|
||||
if (chart) { chart.destroy(); chart = null; }
|
||||
if (!window.Chart) return;
|
||||
chart = new Chart($('#stat-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: days.map(d => d.slice(5)),
|
||||
datasets: [
|
||||
{ label: '成功', data: mk('ok'), backgroundColor: '#97c459' },
|
||||
{ label: '失败', data: mk('error'), backgroundColor: '#f09595' },
|
||||
{ label: '冲突', data: mk('conflict'), backgroundColor: '#ef9f27' },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true, ticks: { precision: 0 } }
|
||||
},
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
}).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; });
|
||||
}
|
||||
|
||||
// ---------- 配置表单 ----------
|
||||
function fillForm(r) {
|
||||
$('#f-name').value = r ? r.name : '';
|
||||
$('#f-path').value = r ? r.repo_path : '';
|
||||
$('#f-branch').value = r ? r.branch : 'main';
|
||||
$('#f-mode').value = r ? r.mode : 'both';
|
||||
$('#f-remotes').value = r && r.remotes ? r.remotes.map(x => x.name + '=' + x.url).join('\n') : '';
|
||||
$('#f-interval').value = r ? r.interval : 60;
|
||||
$('#f-auth').value = r ? r.auth_method : 'ssh';
|
||||
$('#f-conflict').value = r ? r.conflict_strategy : 'merge';
|
||||
$('#f-retry').value = r ? r.retry_count : 3;
|
||||
$('#f-retry-interval').value = r ? r.retry_interval : 5;
|
||||
$('#f-autocommit').checked = r ? !!r.auto_commit : false;
|
||||
$('#f-cron').value = r ? (r.cron || '') : '';
|
||||
$('#f-log').value = r ? (r.log_file || '') : '';
|
||||
}
|
||||
|
||||
function readForm() {
|
||||
const remotes = [];
|
||||
$('#f-remotes').value.split('\n').forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line) return;
|
||||
const i = line.indexOf('=');
|
||||
if (i > 0) remotes.push({ name: line.slice(0, i).trim(), url: line.slice(i + 1).trim() });
|
||||
});
|
||||
return {
|
||||
name: $('#f-name').value.trim(),
|
||||
repo_path: $('#f-path').value.trim(),
|
||||
branch: $('#f-branch').value.trim() || 'main',
|
||||
remotes,
|
||||
mode: $('#f-mode').value,
|
||||
interval: parseInt($('#f-interval').value, 10) || 60,
|
||||
auth_method: $('#f-auth').value,
|
||||
retry_count: parseInt($('#f-retry').value, 10) || 0,
|
||||
retry_interval: parseInt($('#f-retry-interval').value, 10) || 0,
|
||||
conflict_strategy: $('#f-conflict').value,
|
||||
auto_commit: $('#f-autocommit').checked,
|
||||
cron: $('#f-cron').value.trim(),
|
||||
log_file: $('#f-log').value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
$('#cfg-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const cfg = readForm();
|
||||
try {
|
||||
if (creating) {
|
||||
await api('/api/repos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg)
|
||||
});
|
||||
} else {
|
||||
await api(`/api/repos/${cfg.name}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg)
|
||||
});
|
||||
}
|
||||
closeModal();
|
||||
refresh();
|
||||
} catch (err) { alert('保存失败:' + err.message); }
|
||||
});
|
||||
|
||||
$('#btn-delete').addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
if (!confirm(`确定删除配置 "${current}" ?(不影响日志和统计文件)`)) return;
|
||||
try {
|
||||
await api(`/api/repos/${current}`, { method: 'DELETE' });
|
||||
closeModal();
|
||||
refresh();
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
|
||||
// ---------- 顶部按钮 ----------
|
||||
$('#btn-new').addEventListener('click', openCreate);
|
||||
$('#btn-empty-new').addEventListener('click', openCreate);
|
||||
$('#btn-sync-all').addEventListener('click', () => {
|
||||
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
|
||||
});
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
async function refresh() {
|
||||
try {
|
||||
const d = await api('/api/status');
|
||||
repos = d.repos || [];
|
||||
render();
|
||||
} catch (e) { /* 忽略,SSE 会重试 */ }
|
||||
}
|
||||
|
||||
refresh();
|
||||
connect();
|
||||
setInterval(refresh, 20000);
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Git Sync 控制台</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand"><span class="brand-dot"></span>Git Sync 控制台</div>
|
||||
<div class="header-actions">
|
||||
<span id="conn" class="conn">连接中…</span>
|
||||
<button class="btn" id="btn-sync-all">全部同步</button>
|
||||
<button class="btn btn-primary" id="btn-new">+ 新建配置</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="grid" class="grid"></div>
|
||||
<div id="empty" class="empty" hidden>
|
||||
<p>还没有任何仓库配置。</p>
|
||||
<button class="btn btn-primary" id="btn-empty-new">创建第一个配置</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="modal" class="modal" hidden>
|
||||
<div class="modal-box">
|
||||
<div class="modal-head">
|
||||
<h2 id="m-title">详情</h2>
|
||||
<button class="btn-icon" id="m-close" title="关闭">✕</button>
|
||||
</div>
|
||||
<div class="tabs" id="m-tabs">
|
||||
<button data-tab="log" class="tab active">日志</button>
|
||||
<button data-tab="history" class="tab">历史</button>
|
||||
<button data-tab="conflict" class="tab">冲突</button>
|
||||
<button data-tab="stats" class="tab">统计</button>
|
||||
<button data-tab="config" class="tab">配置</button>
|
||||
</div>
|
||||
<div class="tab-body">
|
||||
<div id="tab-log" class="tab-pane">
|
||||
<pre id="logbox" class="logbox">加载中…</pre>
|
||||
</div>
|
||||
<div id="tab-history" class="tab-pane" hidden>
|
||||
<div id="commit-list" class="commit-list">加载中…</div>
|
||||
</div>
|
||||
<div id="tab-conflict" class="tab-pane" hidden>
|
||||
<div class="conflict-actions">
|
||||
<button class="btn btn-danger" id="btn-abort">放弃合并</button>
|
||||
<button class="btn btn-primary" id="btn-resolved">我已在本地解决,继续</button>
|
||||
</div>
|
||||
<div id="conflict-list"></div>
|
||||
</div>
|
||||
<div id="tab-stats" class="tab-pane" hidden>
|
||||
<div id="stat-summary" class="stat-summary"></div>
|
||||
<canvas id="stat-chart" height="120"></canvas>
|
||||
</div>
|
||||
<div id="tab-config" class="tab-pane" hidden>
|
||||
<form id="cfg-form" autocomplete="off">
|
||||
<div class="form-grid">
|
||||
<label>配置名称
|
||||
<input name="name" id="f-name" required pattern="[A-Za-z0-9_\-]+" title="仅字母、数字、下划线、连字符">
|
||||
</label>
|
||||
<label>分支
|
||||
<input name="branch" id="f-branch" value="main">
|
||||
</label>
|
||||
<label class="full">仓库路径
|
||||
<input name="repo_path" id="f-path" placeholder="C:\path\to\repo 或 /c/path/to/repo" required>
|
||||
</label>
|
||||
<label class="full">远程仓库(每行一个,格式 名称=URL)
|
||||
<textarea name="remotes" id="f-remotes" rows="3" placeholder="origin=https://github.com/user/repo.git"></textarea>
|
||||
</label>
|
||||
<label>操作模式
|
||||
<select name="mode" id="f-mode">
|
||||
<option value="both">both(拉取并推送)</option>
|
||||
<option value="push">push(仅推送)</option>
|
||||
<option value="pull">pull(仅拉取)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>检查间隔(秒)
|
||||
<input name="interval" id="f-interval" type="number" min="5" value="60">
|
||||
</label>
|
||||
<label>认证方式
|
||||
<select name="auth_method" id="f-auth">
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="https">HTTPS(支持重试)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>冲突策略
|
||||
<select name="conflict_strategy" id="f-conflict">
|
||||
<option value="merge">merge(冲突后等待人工解决)</option>
|
||||
<option value="rebase">rebase(变基)</option>
|
||||
<option value="abort">abort(冲突后自动放弃)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>重试次数(HTTPS)
|
||||
<input name="retry_count" id="f-retry" type="number" min="0" value="3">
|
||||
</label>
|
||||
<label>重试间隔(秒,HTTPS)
|
||||
<input name="retry_interval" id="f-retry-interval" type="number" min="0" value="5">
|
||||
</label>
|
||||
<label class="full check-row">
|
||||
<input type="checkbox" id="f-autocommit">
|
||||
<span>自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
|
||||
</label>
|
||||
<label class="full">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
|
||||
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔">
|
||||
</label>
|
||||
<label class="full">日志文件路径(留空使用默认 logs/配置名.log)
|
||||
<input name="log_file" id="f-log">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-delete" hidden>删除配置</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="login" class="modal" hidden>
|
||||
<div class="modal-box login-box">
|
||||
<div class="modal-head">
|
||||
<h2>Git Sync 控制台</h2>
|
||||
</div>
|
||||
<div class="login-body">
|
||||
<label>访问密码
|
||||
<input type="password" id="login-password">
|
||||
</label>
|
||||
<p id="login-err" class="login-err" hidden></p>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" id="btn-login">登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
:root {
|
||||
--bg: #f6f7f9; --card: #fff; --border: #e3e5e8; --text: #1f2328; --muted: #6a737d;
|
||||
--blue: #185fa5; --green: #3b6d11; --green-bg: #eaf3de; --red: #a32d2d; --red-bg: #fcebeb;
|
||||
--amber: #854f0b; --amber-bg: #faeeda; --gray-bg: #f1efe8; --radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); }
|
||||
|
||||
header { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
||||
.brand { font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.brand-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--blue); display: inline-block; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.conn { font-size: 12px; color: var(--muted); margin-right: 8px; }
|
||||
.conn.on { color: var(--green); }
|
||||
.conn.off { color: var(--red); }
|
||||
|
||||
.btn { border: 1px solid var(--border); background: #fff; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; color: var(--text); }
|
||||
.btn:hover { border-color: #b6bcc4; background: #f3f4f6; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--blue); border-color: var(--blue); color: #fff; }
|
||||
.btn-primary:hover { background: #124d86; border-color: #124d86; }
|
||||
.btn-danger { background: #fff; border-color: #e2b6b6; color: var(--red); }
|
||||
.btn-danger:hover { background: var(--red-bg); }
|
||||
.btn-icon { border: none; background: none; font-size: 18px; cursor: pointer; color: var(--muted); }
|
||||
|
||||
main { max-width: 1200px; margin: 24px auto; padding: 0 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 80px 0; }
|
||||
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.card.st-conflict { border-color: #e8c98a; background: #fffdf6; }
|
||||
.card.st-error { border-color: #eec9c9; }
|
||||
.card-head { display: flex; align-items: center; gap: 8px; }
|
||||
.card-head h3 { margin: 0; font-size: 15px; font-weight: 500; flex: 1; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
||||
.st-ok .dot { background: #4f9e33; }
|
||||
.st-error .dot { background: #d34b4b; }
|
||||
.st-conflict .dot { background: #e39a1d; }
|
||||
.st-idle .dot { background: #b4b2a9; }
|
||||
.st-running .dot { background: #378add; animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
.mode { font-size: 11px; color: var(--muted); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }
|
||||
.path { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: Consolas, monospace; }
|
||||
.badges { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.badge { font-size: 12px; background: var(--gray-bg); border-radius: 6px; padding: 1px 8px; color: #444; }
|
||||
.badge.b-up { background: var(--green-bg); color: var(--green); }
|
||||
.badge.b-down { background: #e6f1fb; color: var(--blue); }
|
||||
.badge.b-dirty { background: var(--amber-bg); color: var(--amber); }
|
||||
.badge.b-conflict { background: var(--red-bg); color: var(--red); }
|
||||
.last { font-size: 12px; color: var(--muted); }
|
||||
.err { font-size: 12px; color: var(--red); background: var(--red-bg); border-radius: 8px; padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-btns { display: flex; gap: 8px; margin-top: auto; }
|
||||
|
||||
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal[hidden] { display: none; }
|
||||
.modal-box { background: #fff; border-radius: 16px; width: min(860px, 94vw); max-height: 88vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid var(--border); }
|
||||
.modal-head h2 { margin: 0; font-size: 16px; font-weight: 500; }
|
||||
.tabs { display: flex; gap: 4px; padding: 8px 20px 0; border-bottom: 1px solid var(--border); }
|
||||
.tab { border: none; background: none; padding: 8px 14px; font-size: 13px; cursor: pointer; color: var(--muted); border-bottom: 2px solid transparent; }
|
||||
.tab.active { color: var(--blue); border-bottom-color: var(--blue); }
|
||||
.tab-body { padding: 16px 20px; overflow: auto; }
|
||||
.tab-pane[hidden] { display: none; }
|
||||
|
||||
.logbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 12px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 55vh; overflow: auto; }
|
||||
.conflict-actions { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.conflict-file { margin-bottom: 14px; }
|
||||
.conflict-file h4 { margin: 0 0 6px; font-size: 13px; font-family: Consolas, monospace; }
|
||||
.conflict-file pre { margin: 0; background: #f6f7f9; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; }
|
||||
.stat-summary { display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px; color: var(--muted); flex-wrap: wrap; }
|
||||
.stat-summary b { color: var(--text); }
|
||||
|
||||
.commit-list { max-height: 55vh; overflow: auto; }
|
||||
.commit { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
|
||||
.commit-head { display: flex; gap: 10px; align-items: baseline; padding: 8px 12px; cursor: pointer; }
|
||||
.commit-head:hover { background: #f6f7f9; }
|
||||
.commit-hash { font: 12px Consolas, monospace; color: var(--blue); flex: none; }
|
||||
.commit-subject { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.commit-meta { font-size: 12px; color: var(--muted); flex: none; }
|
||||
.commit-diff { margin: 0; border-top: 1px solid var(--border); background: #f6f7f9; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 320px; overflow: auto; }
|
||||
.commit-diff[hidden] { display: none; }
|
||||
|
||||
.login-box { width: min(360px, 92vw); }
|
||||
.login-body { padding: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.login-err { color: var(--red); font-size: 12px; margin: 0; }
|
||||
.form-grid label.check-row { flex-direction: row; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||||
.form-grid label.check-row input { width: auto; margin: 0; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
input, select, textarea { border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; font-size: 13px; font-family: inherit; color: var(--text); background: #fff; }
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--blue); }
|
||||
textarea { font-family: Consolas, monospace; resize: vertical; }
|
||||
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
|
||||
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
Reference in New Issue
Block a user