git-sync-web 自动提交 2026-09-09 13:46:40

This commit is contained in:
tamakyi
2026-09-09 13:46:41 +08:00
parent b921569b72
commit bba6a3362e
7 changed files with 234 additions and 24 deletions
+9
View File
@@ -92,6 +92,15 @@ func parseCronField(f string, min, max int) (map[int]bool, error) {
return set, nil return set, nil
} }
// cronNextString 返回 cron 表达式相对当前时间的下次触发时间(供配置表单实时预览)
func cronNextString(expr string) (string, error) {
spec, err := parseCron(expr)
if err != nil {
return "", err
}
return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil
}
// cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年) // cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年)
func cronNext(spec *cronSpec, after time.Time) time.Time { func cronNext(spec *cronSpec, after time.Time) time.Time {
t := after.Truncate(time.Minute).Add(time.Minute) t := after.Truncate(time.Minute).Add(time.Minute)
+17 -5
View File
@@ -205,7 +205,8 @@ func (e *Engine) logf(s *repoState, level, format string, args ...any) {
if logPath != "" { if logPath != "" {
_ = os.MkdirAll(filepath.Dir(logPath), 0o755) _ = os.MkdirAll(filepath.Dir(logPath), 0o755)
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil { if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
fmt.Fprintln(f, "["+ts+"] "+msg) // 落盘格式:[时间] [级别] 消息,便于网页端按级别过滤
fmt.Fprintln(f, "["+ts+"] ["+level+"] "+msg)
_ = f.Close() _ = f.Close()
} }
} }
@@ -1241,7 +1242,8 @@ func (e *Engine) safeRepoPath(s *repoState, rel string) (string, error) {
// FileNode 文件树节点(网页端) // FileNode 文件树节点(网页端)
type FileNode struct { type FileNode struct {
Path string `json:"path"` Path string `json:"path"`
Status string `json:"status"` // M 已修改 / A 新增 / D 删除 / ? 未跟踪 / 空 未改动
} }
// FileTree 列出仓库内可编辑文件(已跟踪 + 未跟踪且未被忽略),天然避开 .git 内部 // FileTree 列出仓库内可编辑文件(已跟踪 + 未跟踪且未被忽略),天然避开 .git 内部
@@ -1268,9 +1270,11 @@ func (e *Engine) FileTree(name string) []FileNode {
paths = append(paths, p) paths = append(paths, p)
} }
sort.Strings(paths) sort.Strings(paths)
// 附带每个文件的改动状态(M/A/D/?),便于网页端一眼看出哪些文件被动过
status := changedFiles(s.cfg.RepoPath)
nodes := make([]FileNode, 0, len(paths)) nodes := make([]FileNode, 0, len(paths))
for _, p := range paths { for _, p := range paths {
nodes = append(nodes, FileNode{Path: p}) nodes = append(nodes, FileNode{Path: p, Status: status[p]})
} }
return nodes return nodes
} }
@@ -1373,8 +1377,8 @@ type DiffResult struct {
VsRemote bool `json:"vs_remote"` VsRemote bool `json:"vs_remote"`
} }
// WorkingDiff 返回工作区/暂存/全部/与远程的差异 // WorkingDiff 返回工作区/暂存/全部/与远程的差异;rel 非空时只对比该文件
func (e *Engine) WorkingDiff(name, scope string) (*DiffResult, error) { func (e *Engine) WorkingDiff(name, scope, rel string) (*DiffResult, error) {
s := e.get(name) s := e.get(name)
if s == nil { if s == nil {
return nil, fmt.Errorf("配置不存在: %s", name) return nil, fmt.Errorf("配置不存在: %s", name)
@@ -1404,6 +1408,14 @@ func (e *Engine) WorkingDiff(name, scope string) (*DiffResult, error) {
} else { } else {
statArgs = append(statArgs, "--stat") statArgs = append(statArgs, "--stat")
} }
// 单文件过滤:追加 -- <path>,先做路径安全校验(防穿越 / .git)
if rel != "" {
if _, err := e.safeRepoPath(s, rel); err != nil {
return nil, err
}
args = append(args, "--", rel)
statArgs = append(statArgs, "--", rel)
}
if statOut, serr := gitRun(s.cfg.RepoPath, 30*time.Second, statArgs...); serr == nil { if statOut, serr := gitRun(s.cfg.RepoPath, 30*time.Second, statArgs...); serr == nil {
statOut = stripWarnings(statOut) statOut = stripWarnings(statOut)
res.Stat = statOut res.Stat = statOut
+37
View File
@@ -189,3 +189,40 @@ func resolveDone(path string, rebase bool) error {
} }
return nil return nil
} }
// changedFiles 解析 git status --porcelain,返回 路径 -> 状态标记(M/A/D/?),
// 供网页端文件树展示每个文件的改动状态。
func changedFiles(path string) map[string]string {
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
m := map[string]string{}
if err != nil {
return m
}
for _, line := range strings.Split(out, "\n") {
if len(line) < 4 {
continue
}
code := line[:2]
p := strings.TrimSpace(line[3:])
// 重命名/复制:取目标路径
if i := strings.Index(p, "->"); i >= 0 {
p = strings.TrimSpace(p[i+2:])
}
// git 对含特殊字符的路径会加引号
p = strings.Trim(p, `"`)
if p == "" {
continue
}
switch {
case code == "??":
m[p] = "?"
case code[0] == 'A' || code[0] == 'C':
m[p] = "A"
case code[0] == 'D' || code[1] == 'D':
m[p] = "D"
default:
m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改
}
}
return m
}
+18 -1
View File
@@ -55,6 +55,7 @@ func NewServer(e *Engine, static fs.FS, password string) *Server {
mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo)) mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo))
mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo)) mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo))
mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo)) mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo))
mux.HandleFunc("GET /api/cron-next", s.handleCronNext)
mux.Handle("/", http.FileServer(http.FS(static))) mux.Handle("/", http.FileServer(http.FS(static)))
return s return s
} }
@@ -401,10 +402,26 @@ func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) {
if scope == "" { if scope == "" {
scope = "working" scope = "working"
} }
res, err := s.engine.WorkingDiff(name, scope) // path 可选:传入时只对比该文件(用于编辑器「对比此文件」)
res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"))
if err != nil { if err != nil {
writeErr(w, 400, err.Error()) writeErr(w, 400, err.Error())
return return
} }
writeJSON(w, 200, res) writeJSON(w, 200, res)
} }
// handleCronNext 返回 cron 表达式的下次执行时间,供配置表单实时预览
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) {
expr := r.URL.Query().Get("expr")
if expr == "" {
writeErr(w, 400, "缺少 expr 参数")
return
}
next, err := cronNextString(expr)
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, map[string]any{"next": next})
}
+76 -13
View File
@@ -9,6 +9,31 @@ const STATUS = {
}; };
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' }; 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 repos = [];
let current = null; let current = null;
let creating = false; let creating = false;
@@ -45,7 +70,7 @@ function card(r) {
el.className = 'card ' + st.cls; el.className = 'card ' + st.cls;
el.dataset.name = r.name; el.dataset.name = r.name;
const last = r.last_sync const last = r.last_sync
? `${r.last_sync.time.slice(5, 16)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}` ? `${relTime(r.last_sync.time)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
: '从未同步'; : '从未同步';
el.innerHTML = ` el.innerHTML = `
<div class="card-head"> <div class="card-head">
@@ -63,8 +88,9 @@ function card(r) {
${r.cron ? `<span class="badge" title="${esc(r.cron)}">cron</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.conflicts && r.conflicts.length ? `<span class="badge b-conflict">冲突 ${r.conflicts.length}</span>` : ''}
${r.push_conflict ? `<span class="badge b-conflict">推送冲突</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>
<div class="last">上次同步:${esc(last)}</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.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>` : ''} ${r.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
<div class="card-btns"> <div class="card-btns">
@@ -111,7 +137,7 @@ function connect() {
if (current === msg.repo && !$('#tab-diff').hidden) loadDiff(msg.repo); if (current === msg.repo && !$('#tab-diff').hidden) loadDiff(msg.repo);
} else if (msg.type === 'log') { } else if (msg.type === 'log') {
appendLiveLine(msg); appendLiveLine(msg);
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] ${msg.msg}`); if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] [${msg.level || 'info'}] ${msg.msg}`);
} }
}; };
} }
@@ -159,8 +185,15 @@ async function seedLive() {
const d = await api(`/api/repos/${name}/log?lines=8`); const d = await api(`/api/repos/${name}/log?lines=8`);
if (d.lines && d.lines.length) { if (d.lines && d.lines.length) {
d.lines.forEach(t => { d.lines.forEach(t => {
const m = t.match(/^\[(.+?)\] (.*)$/); // 兼容新格式 [时间] [级别] 消息 与历史格式 [时间] 消息
appendLiveLine({ time: m ? m[1] : '', repo: name, msg: m ? m[2] : t, level: 'info', seed: true }); 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 (_) { /* 忽略 */ } } catch (_) { /* 忽略 */ }
@@ -250,22 +283,52 @@ $('#m-close').addEventListener('click', closeModal);
$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); }); $('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') 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) { function loadLog(name) {
$('#logbox').textContent = '加载中…'; $('#logbox').textContent = '加载中…';
api(`/api/repos/${name}/log?lines=500`).then(d => { api(`/api/repos/${name}/log?lines=500`).then(d => {
$('#logbox').textContent = d.lines && d.lines.length ? d.lines.join('\n') : '(暂无日志)'; logLines = d.lines && d.lines.length ? d.lines : [];
$('#logbox').scrollTop = $('#logbox').scrollHeight; renderLog();
}).catch(err => { $('#logbox').textContent = '日志加载失败:' + err.message; }); }).catch(err => { logLines = []; $('#logbox').textContent = '日志加载失败:' + err.message; });
} }
function appendLogLine(line) { function appendLogLine(line) {
const el = $('#logbox'); logLines.push(line);
if (el.textContent === '(暂无日志)') el.textContent = ''; if (logLines.length > 2000) logLines.shift();
el.textContent += (el.textContent ? '\n' : '') + line; renderLog();
el.scrollTop = el.scrollHeight;
} }
$('#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) { function loadHistory(name) {
const list = $('#commit-list'); const list = $('#commit-list');
+24 -5
View File
@@ -12,6 +12,7 @@
<div class="brand"><span class="brand-dot"></span>Git Sync 控制台</div> <div class="brand"><span class="brand-dot"></span>Git Sync 控制台</div>
<div class="header-actions"> <div class="header-actions">
<span id="conn" class="conn">连接中…</span> <span id="conn" class="conn">连接中…</span>
<button class="btn btn-icon" id="btn-theme" title="切换深色 / 浅色模式">🌙</button>
<button class="btn" id="btn-sync-all">全部同步</button> <button class="btn" id="btn-sync-all">全部同步</button>
<button class="btn btn-primary" id="btn-new">+ 新建配置</button> <button class="btn btn-primary" id="btn-new">+ 新建配置</button>
</div> </div>
@@ -52,9 +53,21 @@
<button data-tab="config" class="tab">配置</button> <button data-tab="config" class="tab">配置</button>
</div> </div>
<div class="tab-body"> <div class="tab-body">
<div id="tab-log" class="tab-pane"> <div id="tab-log" class="tab-pane">
<pre id="logbox" class="logbox">加载中…</pre> <div class="log-tools">
<label>级别
<select id="log-level">
<option value="all">全部</option>
<option value="error">仅错误</option>
<option value="warn">仅警告</option>
<option value="info">仅信息</option>
</select>
</label>
<input id="log-search" class="log-search" placeholder="搜索日志内容(关键词)">
<button class="btn btn-sm" id="btn-log-clear" title="清除过滤条件">清除</button>
</div> </div>
<pre id="logbox" class="logbox">加载中…</pre>
</div>
<div id="tab-history" class="tab-pane" hidden> <div id="tab-history" class="tab-pane" hidden>
<div id="commit-list" class="commit-list">加载中…</div> <div id="commit-list" class="commit-list">加载中…</div>
</div> </div>
@@ -69,6 +82,9 @@
</select> </select>
</label> </label>
<button class="btn btn-sm" id="btn-diff-refresh">刷新</button> <button class="btn btn-sm" id="btn-diff-refresh">刷新</button>
<button class="btn btn-sm" id="btn-diff-copy">复制</button>
<button class="btn btn-sm" id="btn-diff-download">下载 .patch</button>
<span id="diff-filter" class="diff-filter" hidden></span>
</div> </div>
<div id="diff-stat" class="diff-stat"></div> <div id="diff-stat" class="diff-stat"></div>
<pre id="diffbox" class="diffbox">加载中…</pre> <pre id="diffbox" class="diffbox">加载中…</pre>
@@ -76,12 +92,14 @@
<div id="tab-file" class="tab-pane" hidden> <div id="tab-file" class="tab-pane" hidden>
<div class="file-head"> <div class="file-head">
<button class="btn btn-sm" id="btn-file-refresh">刷新文件列表</button> <button class="btn btn-sm" id="btn-file-refresh">刷新文件列表</button>
<button class="btn btn-sm" id="btn-file-diff" title="只看当前文件的差异">对比此文件</button>
<span id="file-current" class="file-current"></span> <span id="file-current" class="file-current"></span>
<span id="file-draft" class="file-draft" hidden></span>
</div> </div>
<div class="file-body"> <div class="file-body">
<div id="file-list" class="file-list">加载中…</div> <div id="file-list" class="file-list">加载中…</div>
<div class="file-edit-col"> <div class="file-edit-col">
<textarea id="file-editor" class="file-editor" placeholder="选择左侧文件进行编辑" spellcheck="false"></textarea> <textarea id="file-editor" class="file-editor" placeholder="选择左侧文件进行编辑(Ctrl/Cmd + S 保存)" spellcheck="false"></textarea>
<div class="file-actions"> <div class="file-actions">
<input id="file-msg" class="file-msg" placeholder="提交说明(可选)"> <input id="file-msg" class="file-msg" placeholder="提交说明(可选)">
<button class="btn btn-sm" id="btn-file-save">保存</button> <button class="btn btn-sm" id="btn-file-save">保存</button>
@@ -167,8 +185,9 @@
<span>自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span> <span>自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
</label> </label>
<label class="full">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔) <label class="full">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔"> <input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔">
</label> <span id="cron-next" class="cron-next"></span>
</label>
<label class="full">日志文件路径(留空使用默认 logs/配置名.log) <label class="full">日志文件路径(留空使用默认 logs/配置名.log)
<input name="log_file" id="f-log"> <input name="log_file" id="f-log">
</label> </label>
+53
View File
@@ -153,3 +153,56 @@ textarea { font-family: Consolas, monospace; resize: vertical; }
.file-body { flex-direction: column; } .file-body { flex-direction: column; }
.file-list { width: 100%; max-height: 200px; } .file-list { width: 100%; max-height: 200px; }
} }
/* ---------- 暗色模式 ---------- */
html.dark {
--bg: #16181c; --card: #1e2127; --border: #30363d; --text: #d8dee6; --muted: #8b949e;
--blue: #58a6ff; --green: #7ee787; --green-bg: #12261a; --red: #ff7b72; --red-bg: #2d1618;
--amber: #e3b341; --amber-bg: #2b2313; --gray-bg: #24272e;
}
html.dark header { background: var(--card); }
html.dark .btn { background: var(--card); }
html.dark .btn:hover { background: var(--gray-bg); border-color: #4b535d; }
html.dark .btn-sm { background: var(--card); color: var(--text); }
html.dark .btn-danger { background: var(--card); }
html.dark .btn-primary { background: var(--blue); border-color: var(--blue); color: #0d1117; }
html.dark .btn-primary:hover { background: #3d8bfd; border-color: #3d8bfd; }
html.dark .logbox,
html.dark .conflict-file pre,
html.dark .commit-diff,
html.dark .diffbox,
html.dark .file-editor:disabled { background: #12151a; }
html.dark .commit-head:hover { background: var(--gray-bg); }
html.dark .diff-line.diff-hunk { background: #17242f; }
html.dark input, html.dark select, html.dark textarea { background: #12151a; }
html.dark .file-list { background: var(--card); }
html.dark .file-item:hover { background: var(--gray-bg); }
html.dark .file-editor { background: #12151a; }
/* ---------- 日志过滤 ---------- */
.log-tools { display: flex; gap: 10px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
.log-tools label { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.log-search { flex: 1; min-width: 140px; }
/* ---------- 差异:单文件过滤提示 ---------- */
.diff-filter { font-size: 12px; color: var(--amber); background: var(--amber-bg); border-radius: 6px; padding: 2px 8px; }
.diff-filter[hidden] { display: none; }
/* ---------- 文件编辑:草稿提示与状态徽标 ---------- */
.file-draft { font-size: 12px; color: var(--amber); background: var(--amber-bg); border-radius: 6px; padding: 2px 8px; }
.file-draft[hidden] { display: none; }
.file-item { display: flex; align-items: center; gap: 6px; }
.file-item .fi-name { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.fi-status { flex: none; font-size: 11px; padding: 0 5px; border-radius: 4px; background: var(--gray-bg); color: var(--muted); }
.fi-status.s-M { background: var(--amber-bg); color: var(--amber); }
.fi-status.s-A { background: var(--green-bg); color: var(--green); }
.fi-status.s-D { background: var(--red-bg); color: var(--red); }
.fi-status.s-Q { background: var(--gray-bg); color: var(--muted); }
/* ---------- 卡片:久未同步 ---------- */
.badge.b-stale { background: var(--amber-bg); color: var(--amber); }
/* ---------- 配置:cron 下次执行预览 ---------- */
.cron-next { font-size: 12px; color: var(--muted); min-height: 16px; }
.cron-next.bad { color: var(--red); }
.cron-next.good { color: var(--green); }