diff --git a/cron.go b/cron.go index 972dd23..5eeeaa9 100644 --- a/cron.go +++ b/cron.go @@ -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) diff --git a/engine.go b/engine.go index 89c47a0..0b03e31 100644 --- a/engine.go +++ b/engine.go @@ -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") } + // 单文件过滤:追加 -- ,先做路径安全校验(防穿越 / .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 diff --git a/git.go b/git.go index 91ab1d3..4d41869 100644 --- a/git.go +++ b/git.go @@ -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 +} diff --git a/server.go b/server.go index b161014..32b51a9 100644 --- a/server.go +++ b/server.go @@ -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}) } \ No newline at end of file diff --git a/static/app.js b/static/app.js index ba78400..431f490 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = `
@@ -63,8 +88,9 @@ function card(r) { ${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)}
+
上次同步:${esc(last)}
${r.last_sync && r.last_sync.detail ? `
${esc(r.last_sync.detail)}
` : ''} ${r.status === 'error' && r.last_error ? `
${esc(r.last_error)}
` : ''}
@@ -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'); diff --git a/static/index.html b/static/index.html index f9126c6..ddaab30 100644 --- a/static/index.html +++ b/static/index.html @@ -12,6 +12,7 @@
Git Sync 控制台
连接中… +
@@ -52,9 +53,21 @@
-
-
加载中…
+
+
+ + +
+
加载中…
+
@@ -69,6 +82,9 @@ + + +
加载中…
@@ -76,12 +92,14 @@