git-sync-web 自动提交 2026-09-09 18:02:17
This commit is contained in:
@@ -131,9 +131,114 @@ func NewEngine(base string) (*Engine, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = e.ensureSummariesDir()
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) summariesDir() string {
|
||||||
|
return filepath.Join(e.baseDir, "summaries")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSummariesDir 创建本地变更摘要目录(gitignored,不同步到远程)。
|
||||||
|
func (e *Engine) ensureSummariesDir() error {
|
||||||
|
dir := e.summariesDir()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gi := filepath.Join(dir, ".gitignore")
|
||||||
|
if _, err := os.Stat(gi); err != nil {
|
||||||
|
_ = os.WriteFile(gi, []byte("*\n"), 0o644)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) summaryPath(name string) string {
|
||||||
|
return filepath.Join(e.summariesDir(), name+".md")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) pendingPath(name string) string {
|
||||||
|
return filepath.Join(e.summariesDir(), name+".pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetPendingSummary(name string) string {
|
||||||
|
b, err := os.ReadFile(e.pendingPath(name))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) SavePendingSummary(name, text string) error {
|
||||||
|
if err := e.ensureSummariesDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t := strings.TrimSpace(text)
|
||||||
|
if t == "" {
|
||||||
|
return os.Remove(e.pendingPath(name))
|
||||||
|
}
|
||||||
|
return os.WriteFile(e.pendingPath(name), []byte(t+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoSummary 在没有任何人工摘要时,依据本次改动自动生成一句话摘要(仅本地记录用)。
|
||||||
|
func (e *Engine) autoSummary(repoPath string) string {
|
||||||
|
out, err := gitRun(repoPath, 15*time.Second, "status", "--porcelain")
|
||||||
|
if err != nil {
|
||||||
|
return "自动提交"
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
var files []string
|
||||||
|
for _, ln := range lines {
|
||||||
|
ln = strings.TrimSpace(ln)
|
||||||
|
if ln == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := strings.TrimSpace(ln[3:])
|
||||||
|
if i := strings.Index(p, "->"); i >= 0 {
|
||||||
|
p = strings.TrimSpace(p[i+2:])
|
||||||
|
}
|
||||||
|
p = strings.Trim(p, `"`)
|
||||||
|
if p != "" {
|
||||||
|
files = append(files, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "自动提交(无改动文件)"
|
||||||
|
}
|
||||||
|
const max = 6
|
||||||
|
shown := files
|
||||||
|
if len(shown) > max {
|
||||||
|
shown = files[:max]
|
||||||
|
}
|
||||||
|
more := ""
|
||||||
|
if len(files) > max {
|
||||||
|
more = fmt.Sprintf(" 等%d个", len(files))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("改动 %d 个文件:%s%s", len(files), strings.Join(shown, ", "), more)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordCommit 把一次提交(commit id + 摘要)追加写入本地按项目分开的摘要文件(不上传)。
|
||||||
|
func (e *Engine) RecordCommit(name, sha, summary string) {
|
||||||
|
if err := e.ensureSummariesDir(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ts := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
entry := fmt.Sprintf("## %s · %s\n%s\n\n", strings.TrimSpace(sha), ts, strings.TrimSpace(summary))
|
||||||
|
f, err := os.OpenFile(e.summaryPath(name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, _ = f.WriteString(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetSummaryLog(name string) string {
|
||||||
|
b, err := os.ReadFile(e.summaryPath(name))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) Bus() *EventBus { return e.bus }
|
func (e *Engine) Bus() *EventBus { return e.bus }
|
||||||
|
|
||||||
func (e *Engine) configNames() []string {
|
func (e *Engine) configNames() []string {
|
||||||
@@ -619,10 +724,22 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmsg := "git-sync-web 自动提交 " + time.Now().Format("2006-01-02 15:04:05")
|
cmsg := "git-sync-web 自动提交 " + time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
pend := e.GetPendingSummary(s.cfg.Name)
|
||||||
|
if pend != "" {
|
||||||
|
cmsg = pend
|
||||||
|
}
|
||||||
if _, cerr := gitRun(cfg.RepoPath, 120*time.Second, "commit", "-m", cmsg); cerr != nil {
|
if _, cerr := gitRun(cfg.RepoPath, 120*time.Second, "commit", "-m", cmsg); cerr != nil {
|
||||||
e.setFail(s, "自动提交失败: "+cerr.Error())
|
e.setFail(s, "自动提交失败: "+cerr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if sha, serr := gitRun(cfg.RepoPath, 10*time.Second, "rev-parse", "--short", "HEAD"); serr == nil {
|
||||||
|
sum := cmsg
|
||||||
|
if pend == "" {
|
||||||
|
sum = e.autoSummary(cfg.RepoPath)
|
||||||
|
}
|
||||||
|
e.RecordCommit(s.cfg.Name, sha, sum)
|
||||||
|
}
|
||||||
|
_ = os.Remove(e.pendingPath(s.cfg.Name))
|
||||||
e.logf(s, "info", "自动提交完成,将随本轮同步推送")
|
e.logf(s, "info", "自动提交完成,将随本轮同步推送")
|
||||||
dirty = 0
|
dirty = 0
|
||||||
} else {
|
} else {
|
||||||
@@ -986,6 +1103,13 @@ func (e *Engine) ConflictResolve(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Create(c *RepoConfig) error {
|
func (e *Engine) Create(c *RepoConfig) error {
|
||||||
|
branch := c.Branch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
if err := ensureRepo(c.RepoPath, branch); err != nil {
|
||||||
|
return fmt.Errorf("初始化仓库失败: %w", err)
|
||||||
|
}
|
||||||
if err := validateRepoConfig(e.cfgDir, c, true); err != nil {
|
if err := validateRepoConfig(e.cfgDir, c, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1006,6 +1130,13 @@ func (e *Engine) Update(c *RepoConfig) error {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return fmt.Errorf("配置不存在: %s", c.Name)
|
return fmt.Errorf("配置不存在: %s", c.Name)
|
||||||
}
|
}
|
||||||
|
branch := c.Branch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
if err := ensureRepo(c.RepoPath, branch); err != nil {
|
||||||
|
return fmt.Errorf("初始化仓库失败: %w", err)
|
||||||
|
}
|
||||||
if err := validateRepoConfig(e.cfgDir, c, false); err != nil {
|
if err := validateRepoConfig(e.cfgDir, c, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1254,6 +1385,7 @@ type FileContent struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Binary bool `json:"binary"`
|
Binary bool `json:"binary"`
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
|
Changed []int `json:"changed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
|
func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
|
||||||
@@ -1282,7 +1414,106 @@ func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
|
|||||||
if bytes.Contains(data, []byte{0}) {
|
if bytes.Contains(data, []byte{0}) {
|
||||||
return &FileContent{Path: rel, Binary: true, Size: int(info.Size())}, nil
|
return &FileContent{Path: rel, Binary: true, Size: int(info.Size())}, nil
|
||||||
}
|
}
|
||||||
return &FileContent{Path: rel, Content: string(data), Size: int(info.Size())}, nil
|
content := string(data)
|
||||||
|
changed, _ := fileChangeLines(s.cfg.RepoPath, rel, content)
|
||||||
|
return &FileContent{Path: rel, Content: content, Size: int(info.Size()), Changed: changed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUntracked(repoPath, rel string) bool {
|
||||||
|
out, err := gitRun(repoPath, 10*time.Second, "status", "--porcelain", "--", rel)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "??") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileChangeLines(repoPath, rel, content string) ([]int, error) {
|
||||||
|
if _, err := gitRun(repoPath, 10*time.Second, "rev-parse", "--verify", "HEAD"); err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if isUntracked(repoPath, rel) {
|
||||||
|
return allLineNumbers(content), nil
|
||||||
|
}
|
||||||
|
out, err := gitRun(repoPath, 30*time.Second, "diff", "--no-color", "HEAD", "--", rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return parseDiffAddedLines(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func allLineNumbers(content string) []int {
|
||||||
|
n := strings.Count(content, "\n")
|
||||||
|
if content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
lines := make([]int, 0, n)
|
||||||
|
for i := 1; i <= n; i++ {
|
||||||
|
lines = append(lines, i)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDiffAddedLines(diff string) []int {
|
||||||
|
set := map[int]struct{}{}
|
||||||
|
newLine := 0
|
||||||
|
for _, raw := range strings.Split(diff, "\n") {
|
||||||
|
line := raw
|
||||||
|
if strings.HasPrefix(line, "@@") {
|
||||||
|
if i := strings.Index(line, " +"); i >= 0 {
|
||||||
|
rest := line[i+2:]
|
||||||
|
if c, ok := parseHunkNewStart(rest); ok {
|
||||||
|
newLine = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if newLine == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "+"):
|
||||||
|
if strings.HasPrefix(line, "+++") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set[newLine] = struct{}{}
|
||||||
|
newLine++
|
||||||
|
case strings.HasPrefix(line, " "):
|
||||||
|
newLine++
|
||||||
|
case strings.HasPrefix(line, "-"):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]int, 0, len(set))
|
||||||
|
for k := range set {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Ints(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHunkNewStart(rest string) (int, bool) {
|
||||||
|
end := strings.IndexAny(rest, " @")
|
||||||
|
if end >= 0 {
|
||||||
|
rest = rest[:end]
|
||||||
|
}
|
||||||
|
if i := strings.Index(rest, ","); i >= 0 {
|
||||||
|
rest = rest[:i]
|
||||||
|
}
|
||||||
|
n := strings.TrimSpace(rest)
|
||||||
|
if n == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) WriteFileContent(name, rel, content string) error {
|
func (e *Engine) WriteFileContent(name, rel, content string) error {
|
||||||
@@ -1323,6 +1554,9 @@ func (e *Engine) CommitFile(name, rel, message string) error {
|
|||||||
if _, err := gitRun(s.cfg.RepoPath, 60*time.Second, "commit", "-m", message, "--", rel); err != nil {
|
if _, err := gitRun(s.cfg.RepoPath, 60*time.Second, "commit", "-m", message, "--", rel); err != nil {
|
||||||
return fmt.Errorf("git commit 失败: %w", err)
|
return fmt.Errorf("git commit 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if sha, serr := gitRun(s.cfg.RepoPath, 10*time.Second, "rev-parse", "--short", "HEAD"); serr == nil {
|
||||||
|
e.RecordCommit(name, sha, message)
|
||||||
|
}
|
||||||
e.logf(s, "info", "网页端提交 %s 成功", rel)
|
e.logf(s, "info", "网页端提交 %s 成功", rel)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ func isRepo(path string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureRepo(path, branch string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("仓库路径为空")
|
||||||
|
}
|
||||||
|
path = normalizeWinPath(path)
|
||||||
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isRepo(path) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
if _, err := gitRun(path, 30*time.Second, "init", "-b", branch); err != nil {
|
||||||
|
if _, e2 := gitRun(path, 30*time.Second, "init"); e2 != nil {
|
||||||
|
return e2
|
||||||
|
}
|
||||||
|
_, _ = gitRun(path, 15*time.Second, "checkout", "-b", branch)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func currentBranch(path string) (string, error) {
|
func currentBranch(path string) (string, error) {
|
||||||
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
|
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
}
|
}
|
||||||
@@ -210,5 +233,23 @@ func changedFiles(path string) map[string]string {
|
|||||||
m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改
|
m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for p, st := range m {
|
||||||
|
if st == "?" && strings.HasSuffix(p, "/") {
|
||||||
|
expandUntracked(path, p, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expandUntracked(repoPath, dir string, m map[string]string) {
|
||||||
|
out, err := gitRun(repoPath, 15*time.Second, "ls-files", "--others", "--exclude-standard", "--", dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ln := range strings.Split(out, "\n") {
|
||||||
|
ln = strings.TrimSpace(ln)
|
||||||
|
if ln != "" {
|
||||||
|
m[ln] = "?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ func NewServer(e *Engine, static fs.FS, password string) *Server {
|
|||||||
mux.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo))
|
mux.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo))
|
||||||
mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo))
|
mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo))
|
||||||
mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo))
|
mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo))
|
||||||
|
mux.HandleFunc("GET /api/repos/{name}/summary", s.wrapRepo(s.summaryGetRepo))
|
||||||
|
mux.HandleFunc("POST /api/repos/{name}/summary", s.wrapRepo(s.summarySaveRepo))
|
||||||
mux.HandleFunc("POST /api/repos/{name}/resolve-file", s.wrapRepo(s.resolveFileRepo))
|
mux.HandleFunc("POST /api/repos/{name}/resolve-file", s.wrapRepo(s.resolveFileRepo))
|
||||||
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))
|
||||||
@@ -376,6 +378,28 @@ func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name stri
|
|||||||
writeJSON(w, 200, map[string]any{"ok": true})
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) summaryGetRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
writeJSON(w, 200, map[string]any{
|
||||||
|
"pending": s.engine.GetPendingSummary(name),
|
||||||
|
"log": s.engine.GetSummaryLog(name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) summarySaveRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
var req struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, 400, "请求体格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.engine.SavePendingSummary(name, req.Summary); err != nil {
|
||||||
|
writeErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true, "pending": s.engine.GetPendingSummary(name)})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ const Editor = {
|
|||||||
if (this.cm) this.cm.setOption('theme', dark ? 'material-darker' : 'eclipse');
|
if (this.cm) this.cm.setOption('theme', dark ? 'material-darker' : 'eclipse');
|
||||||
},
|
},
|
||||||
refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); },
|
refresh() { if (this.cm) setTimeout(() => this.cm.refresh(), 30); },
|
||||||
|
setChangedLines(lines) {
|
||||||
|
this._changed = lines && lines.length ? lines : null;
|
||||||
|
if (!this.cm) return;
|
||||||
|
const n = this.cm.lineCount();
|
||||||
|
for (let i = 0; i < n; i++) this.cm.removeLineClass(i, 'background', 'cm-chg');
|
||||||
|
if (!this._changed) return;
|
||||||
|
for (const ln of this._changed) {
|
||||||
|
const idx = ln - 1;
|
||||||
|
if (idx >= 0 && idx < n) this.cm.addLineClass(idx, 'background', 'cm-chg');
|
||||||
|
}
|
||||||
|
},
|
||||||
onSave: null, // 由 cm-boot.js 的快捷键触发
|
onSave: null, // 由 cm-boot.js 的快捷键触发
|
||||||
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
|
onChange: null, // 由 cm-boot.js 的 change 事件触发(草稿缓存)
|
||||||
};
|
};
|
||||||
@@ -316,6 +327,8 @@ function openDetail(name) {
|
|||||||
$('#btn-delete').hidden = false;
|
$('#btn-delete').hidden = false;
|
||||||
$('#btn-clone').hidden = false;
|
$('#btn-clone').hidden = false;
|
||||||
fillForm(r);
|
fillForm(r);
|
||||||
|
$('#summary-box').hidden = false;
|
||||||
|
loadSummary(name);
|
||||||
showTab('log');
|
showTab('log');
|
||||||
$('#modal').hidden = false;
|
$('#modal').hidden = false;
|
||||||
}
|
}
|
||||||
@@ -328,10 +341,22 @@ function openCreate() {
|
|||||||
$('#btn-delete').hidden = true;
|
$('#btn-delete').hidden = true;
|
||||||
$('#btn-clone').hidden = true;
|
$('#btn-clone').hidden = true;
|
||||||
fillForm(null);
|
fillForm(null);
|
||||||
|
$('#summary-box').hidden = true;
|
||||||
showTab('config');
|
showTab('config');
|
||||||
$('#modal').hidden = false;
|
$('#modal').hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadSummary(name) {
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const d = await api(`/api/repos/${name}/summary`);
|
||||||
|
$('#f-summary').value = d.pending || '';
|
||||||
|
$('#summary-log').textContent = d.log ? d.log.trim() : '(暂无本地变更摘要记录)';
|
||||||
|
} catch (err) {
|
||||||
|
$('#summary-log').textContent = '读取摘要失败:' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
$('#modal').hidden = true;
|
$('#modal').hidden = true;
|
||||||
current = null;
|
current = null;
|
||||||
@@ -817,6 +842,8 @@ async function loadFile(name, path) {
|
|||||||
showDraftNote(false);
|
showDraftNote(false);
|
||||||
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
|
const d = await api(`/api/repos/${name}/file?path=${encodeURIComponent(path)}`);
|
||||||
if (d.binary) {
|
if (d.binary) {
|
||||||
|
Editor.setChangedLines(null);
|
||||||
|
const bh = $('#file-chg-hint'); if (bh) bh.hidden = true;
|
||||||
Editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
Editor.value = `(二进制文件,大小 ${d.size} 字节,无法在网页编辑)`;
|
||||||
Editor.disabled = true;
|
Editor.disabled = true;
|
||||||
draftServer = '';
|
draftServer = '';
|
||||||
@@ -830,8 +857,13 @@ async function loadFile(name, path) {
|
|||||||
} else {
|
} else {
|
||||||
Editor.value = draftServer;
|
Editor.value = draftServer;
|
||||||
}
|
}
|
||||||
|
Editor.setChangedLines(d.changed);
|
||||||
|
const hint = $('#file-chg-hint');
|
||||||
|
if (hint) hint.hidden = !(d.changed && d.changed.length);
|
||||||
Editor.disabled = false;
|
Editor.disabled = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
Editor.setChangedLines(null);
|
||||||
|
const eh = $('#file-chg-hint'); if (eh) eh.hidden = true;
|
||||||
Editor.value = '读取失败:' + err.message;
|
Editor.value = '读取失败:' + err.message;
|
||||||
Editor.disabled = true;
|
Editor.disabled = true;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1069,6 +1101,19 @@ $('#btn-clone').addEventListener('click', () => {
|
|||||||
$('#f-name').focus();
|
$('#f-name').focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#btn-summary-save').addEventListener('click', async () => {
|
||||||
|
if (!current) return;
|
||||||
|
try {
|
||||||
|
const res = await api(`/api/repos/${current}/summary`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ summary: $('#f-summary').value })
|
||||||
|
});
|
||||||
|
toast(res.ok ? '摘要已保存(本地,下次自动提交将使用此信息)' : '摘要保存失败', res.ok ? 'info' : 'error');
|
||||||
|
await loadSummary(current);
|
||||||
|
} catch (err) { toast('摘要保存失败:' + err.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
$('#btn-new').addEventListener('click', openCreate);
|
$('#btn-new').addEventListener('click', openCreate);
|
||||||
$('#btn-empty-new').addEventListener('click', openCreate);
|
$('#btn-empty-new').addEventListener('click', openCreate);
|
||||||
$('#btn-sync-all').addEventListener('click', () => {
|
$('#btn-sync-all').addEventListener('click', () => {
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
<div class="file-body" data-page-node-id="RXmkN5Ep4iegYWG3iWBiON">
|
<div class="file-body" data-page-node-id="RXmkN5Ep4iegYWG3iWBiON">
|
||||||
<div id="file-list" class="file-list" data-page-node-id="F75SFyDQShim9rZFPnjuJC">加载中…</div>
|
<div id="file-list" class="file-list" data-page-node-id="F75SFyDQShim9rZFPnjuJC">加载中…</div>
|
||||||
<div class="file-edit-col" data-page-node-id="xh1qou2SVboDfvKTLcXFEA">
|
<div class="file-edit-col" data-page-node-id="xh1qou2SVboDfvKTLcXFEA">
|
||||||
|
<div class="file-chg-hint" id="file-chg-hint" hidden>● 黄色高亮 = 相对最近一次提交仍未提交的修改行</div>
|
||||||
<textarea id="file-editor" class="file-editor" placeholder="选择左侧文件进行编辑(Ctrl/Cmd + S 保存)" spellcheck="false"></textarea>
|
<textarea id="file-editor" class="file-editor" placeholder="选择左侧文件进行编辑(Ctrl/Cmd + S 保存)" spellcheck="false"></textarea>
|
||||||
<div class="file-actions" data-page-node-id="GAMAnkYKeMi93EVEx8eYOn">
|
<div class="file-actions" data-page-node-id="GAMAnkYKeMi93EVEx8eYOn">
|
||||||
<input id="file-msg" class="file-msg" placeholder="提交说明(可选)" data-page-node-id="aS2jRAr1kAkqElk1T4Cldz">
|
<input id="file-msg" class="file-msg" placeholder="提交说明(可选)" data-page-node-id="aS2jRAr1kAkqElk1T4Cldz">
|
||||||
@@ -208,6 +209,14 @@
|
|||||||
<label class="full" data-page-node-id="fsjZ6wLid5NUBV0yAHKdII">日志文件路径(留空使用默认 logs/配置名.log)
|
<label class="full" data-page-node-id="fsjZ6wLid5NUBV0yAHKdII">日志文件路径(留空使用默认 logs/配置名.log)
|
||||||
<input name="log_file" id="f-log" data-page-node-id="KJpv7jEWYnOQZ1PtQMetfP">
|
<input name="log_file" id="f-log" data-page-node-id="KJpv7jEWYnOQZ1PtQMetfP">
|
||||||
</label>
|
</label>
|
||||||
|
<div class="summary-box" id="summary-box" hidden>
|
||||||
|
<div class="summary-head">
|
||||||
|
<span>变更摘要(本地,不同步)</span>
|
||||||
|
<button type="button" class="btn btn-sm" id="btn-summary-save">保存摘要</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="f-summary" class="file-editor" style="min-height:84px" placeholder="填写后将作为【自动提交】的下一次 commit 信息;留空则由工具自动生成一句话并在本地记录。此摘要与映射仅存于本地 summaries/,不会上传。"></textarea>
|
||||||
|
<div class="summary-log" id="summary-log"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions" data-page-node-id="1GfIJLKdxSyPwBqpqCCMb9">
|
<div class="form-actions" data-page-node-id="1GfIJLKdxSyPwBqpqCCMb9">
|
||||||
<button type="submit" class="btn btn-primary" data-page-node-id="E2123htNE6lmoNzZTg7AwV">保存</button>
|
<button type="submit" class="btn btn-primary" data-page-node-id="E2123htNE6lmoNzZTg7AwV">保存</button>
|
||||||
|
|||||||
@@ -145,11 +145,19 @@ textarea { font-family: Consolas, monospace; resize: vertical; }
|
|||||||
.file-item { padding: 4px 8px; border-radius: 6px; font-size: 12.5px; font-family: Consolas, monospace; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.file-item { padding: 4px 8px; border-radius: 6px; font-size: 12.5px; font-family: Consolas, monospace; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.file-item:hover { background: #f0f3f6; }
|
.file-item:hover { background: #f0f3f6; }
|
||||||
.file-edit-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
.file-edit-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.file-chg-hint { font-size: 12px; color: #9a6b00; background: rgba(255, 196, 0, 0.12); border: 1px solid rgba(224, 160, 32, 0.4); border-radius: 6px; padding: 4px 8px; }
|
||||||
|
html.dark .file-chg-hint { color: #d9a93a; background: rgba(255, 196, 0, 0.1); border-color: rgba(201, 143, 28, 0.4); }
|
||||||
.file-editor { flex: 1; min-height: 360px; width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; }
|
.file-editor { flex: 1; min-height: 360px; width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; }
|
||||||
.file-editor:disabled { background: #f6f7f9; color: var(--muted); }
|
.file-editor:disabled { background: #f6f7f9; color: var(--muted); }
|
||||||
.file-actions { display: flex; gap: 8px; align-items: center; }
|
.file-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
.file-msg { flex: 1; }
|
.file-msg { flex: 1; }
|
||||||
|
|
||||||
|
.summary-box { margin-top: 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--card); }
|
||||||
|
.summary-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 13px; color: var(--muted); }
|
||||||
|
.summary-head .btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.summary-log { margin-top: 10px; padding: 10px 12px; border: 1px dashed var(--border); border-radius: 8px; background: #fafbfc; font: 12px/1.6 Consolas, "Microsoft YaHei", monospace; color: var(--text); white-space: pre-wrap; max-height: 220px; overflow: auto; }
|
||||||
|
html.dark .summary-log { background: #12151a; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.file-body { flex-direction: column; }
|
.file-body { flex-direction: column; }
|
||||||
.file-list { width: 100%; max-height: 200px; }
|
.file-list { width: 100%; max-height: 200px; }
|
||||||
@@ -241,6 +249,16 @@ html.dark .file-editor { background: #12151a; }
|
|||||||
.CodeMirror { height: 420px; border: 1px solid var(--border); border-radius: 8px; font: 12.5px/1.55 Consolas, monospace; }
|
.CodeMirror { height: 420px; border: 1px solid var(--border); border-radius: 8px; font: 12.5px/1.55 Consolas, monospace; }
|
||||||
.CodeMirror-scroll { min-height: 380px; }
|
.CodeMirror-scroll { min-height: 380px; }
|
||||||
|
|
||||||
|
/* 未提交修改行高亮 */
|
||||||
|
.CodeMirror-linebackground.cm-chg {
|
||||||
|
background-color: rgba(255, 196, 0, 0.16);
|
||||||
|
box-shadow: inset 3px 0 0 0 #e0a020;
|
||||||
|
}
|
||||||
|
html.dark .CodeMirror-linebackground.cm-chg {
|
||||||
|
background-color: rgba(255, 196, 0, 0.13);
|
||||||
|
box-shadow: inset 3px 0 0 0 #c98f1c;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 冲突文件内联编辑 ---------- */
|
/* ---------- 冲突文件内联编辑 ---------- */
|
||||||
.cf-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
.cf-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
.cf-head h4 { margin: 0; flex: 1; }
|
.cf-head h4 { margin: 0; flex: 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user