git-sync-web 自动提交 2026-09-09 13:46:40
This commit is contained in:
@@ -92,6 +92,15 @@ func parseCronField(f string, min, max int) (map[int]bool, error) {
|
||||
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 之后的下一次触发时间(分钟精度,最多向前搜索一年)
|
||||
func cronNext(spec *cronSpec, after time.Time) time.Time {
|
||||
t := after.Truncate(time.Minute).Add(time.Minute)
|
||||
|
||||
@@ -205,7 +205,8 @@ func (e *Engine) logf(s *repoState, level, format string, args ...any) {
|
||||
if logPath != "" {
|
||||
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1241,7 +1242,8 @@ func (e *Engine) safeRepoPath(s *repoState, rel string) (string, error) {
|
||||
|
||||
// FileNode 文件树节点(网页端)
|
||||
type FileNode struct {
|
||||
Path string `json:"path"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"` // M 已修改 / A 新增 / D 删除 / ? 未跟踪 / 空 未改动
|
||||
}
|
||||
|
||||
// FileTree 列出仓库内可编辑文件(已跟踪 + 未跟踪且未被忽略),天然避开 .git 内部
|
||||
@@ -1268,9 +1270,11 @@ func (e *Engine) FileTree(name string) []FileNode {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
// 附带每个文件的改动状态(M/A/D/?),便于网页端一眼看出哪些文件被动过
|
||||
status := changedFiles(s.cfg.RepoPath)
|
||||
nodes := make([]FileNode, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
nodes = append(nodes, FileNode{Path: p})
|
||||
nodes = append(nodes, FileNode{Path: p, Status: status[p]})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
@@ -1373,8 +1377,8 @@ type DiffResult struct {
|
||||
VsRemote bool `json:"vs_remote"`
|
||||
}
|
||||
|
||||
// WorkingDiff 返回工作区/暂存/全部/与远程的差异
|
||||
func (e *Engine) WorkingDiff(name, scope string) (*DiffResult, error) {
|
||||
// WorkingDiff 返回工作区/暂存/全部/与远程的差异;rel 非空时只对比该文件
|
||||
func (e *Engine) WorkingDiff(name, scope, rel string) (*DiffResult, error) {
|
||||
s := e.get(name)
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("配置不存在: %s", name)
|
||||
@@ -1404,6 +1408,14 @@ func (e *Engine) WorkingDiff(name, scope string) (*DiffResult, error) {
|
||||
} else {
|
||||
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 {
|
||||
statOut = stripWarnings(statOut)
|
||||
res.Stat = statOut
|
||||
|
||||
@@ -189,3 +189,40 @@ func resolveDone(path string, rebase bool) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo))
|
||||
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)))
|
||||
return s
|
||||
}
|
||||
@@ -401,10 +402,26 @@ func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if scope == "" {
|
||||
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 {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
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
@@ -9,6 +9,31 @@ const STATUS = {
|
||||
};
|
||||
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;
|
||||
@@ -45,7 +70,7 @@ function card(r) {
|
||||
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}`
|
||||
? `${relTime(r.last_sync.time)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
|
||||
: '从未同步';
|
||||
el.innerHTML = `
|
||||
<div class="card-head">
|
||||
@@ -63,8 +88,9 @@ function card(r) {
|
||||
${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">上次同步:${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.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
|
||||
<div class="card-btns">
|
||||
@@ -111,7 +137,7 @@ function connect() {
|
||||
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.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`);
|
||||
if (d.lines && d.lines.length) {
|
||||
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 (_) { /* 忽略 */ }
|
||||
@@ -250,22 +283,52 @@ $('#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 => {
|
||||
$('#logbox').textContent = d.lines && d.lines.length ? d.lines.join('\n') : '(暂无日志)';
|
||||
$('#logbox').scrollTop = $('#logbox').scrollHeight;
|
||||
}).catch(err => { $('#logbox').textContent = '日志加载失败:' + err.message; });
|
||||
logLines = d.lines && d.lines.length ? d.lines : [];
|
||||
renderLog();
|
||||
}).catch(err => { logLines = []; $('#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;
|
||||
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');
|
||||
|
||||
+24
-5
@@ -12,6 +12,7 @@
|
||||
<div class="brand"><span class="brand-dot"></span>Git Sync 控制台</div>
|
||||
<div class="header-actions">
|
||||
<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 btn-primary" id="btn-new">+ 新建配置</button>
|
||||
</div>
|
||||
@@ -52,9 +53,21 @@
|
||||
<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 id="tab-log" class="tab-pane">
|
||||
<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>
|
||||
<pre id="logbox" class="logbox">加载中…</pre>
|
||||
</div>
|
||||
<div id="tab-history" class="tab-pane" hidden>
|
||||
<div id="commit-list" class="commit-list">加载中…</div>
|
||||
</div>
|
||||
@@ -69,6 +82,9 @@
|
||||
</select>
|
||||
</label>
|
||||
<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 id="diff-stat" class="diff-stat"></div>
|
||||
<pre id="diffbox" class="diffbox">加载中…</pre>
|
||||
@@ -76,12 +92,14 @@
|
||||
<div id="tab-file" class="tab-pane" hidden>
|
||||
<div class="file-head">
|
||||
<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-draft" class="file-draft" hidden></span>
|
||||
</div>
|
||||
<div class="file-body">
|
||||
<div id="file-list" class="file-list">加载中…</div>
|
||||
<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">
|
||||
<input id="file-msg" class="file-msg" placeholder="提交说明(可选)">
|
||||
<button class="btn btn-sm" id="btn-file-save">保存</button>
|
||||
@@ -167,8 +185,9 @@
|
||||
<span>自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
|
||||
</label>
|
||||
<label class="full">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
|
||||
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔">
|
||||
</label>
|
||||
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔">
|
||||
<span id="cron-next" class="cron-next"></span>
|
||||
</label>
|
||||
<label class="full">日志文件路径(留空使用默认 logs/配置名.log)
|
||||
<input name="log_file" id="f-log">
|
||||
</label>
|
||||
|
||||
@@ -153,3 +153,56 @@ textarea { font-family: Consolas, monospace; resize: vertical; }
|
||||
.file-body { flex-direction: column; }
|
||||
.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); }
|
||||
|
||||
Reference in New Issue
Block a user