init: gitcat 自建 Git 服务源码
42 个文件变更
+6685
-0
fqh@example.com
| •.gitignore | +9 -0 |
| •go.mod | +18 -0 |
| •go.sum | +24 -0 |
| •internal/ai/ai.go | +200 -0 |
| •internal/gitx/gitx.go | +777 -0 |
| •internal/server/admin.go | +536 -0 |
| •internal/server/auth.go | +222 -0 |
| •internal/server/githttp.go | +334 -0 |
| •internal/server/home.go | +121 -0 |
| •internal/server/media.go | +33 -0 |
| •internal/server/repo.go | +539 -0 |
| •internal/server/server.go | +638 -0 |
| •internal/server/settings.go | +192 -0 |
| •internal/store/repos.go | +295 -0 |
| •internal/store/store.go | +411 -0 |
| •main.go | +134 -0 |
| •webroot/static/app.js | +101 -0 |
| •webroot/static/favicon.svg | +16 -0 |
| •webroot/static/style.css | +708 -0 |
| •webroot/templates/admin_activity.html | +13 -0 |
| •webroot/templates/admin_ai.html | +64 -0 |
| •webroot/templates/admin_dashboard.html | +76 -0 |
| •webroot/templates/admin_repo.html | +79 -0 |
| •webroot/templates/admin_repos.html | +41 -0 |
| •webroot/templates/admin_settings.html | +34 -0 |
| •webroot/templates/admin_user.html | +65 -0 |
| •webroot/templates/admin_users.html | +53 -0 |
| •webroot/templates/error.html | +8 -0 |
| •webroot/templates/home.html | +97 -0 |
| •webroot/templates/install.html | +62 -0 |
| •webroot/templates/layout.html | +149 -0 |
| •webroot/templates/login.html | +36 -0 |
| •webroot/templates/new_project.html | +38 -0 |
| •webroot/templates/profile.html | +91 -0 |
| •webroot/templates/repo_activity.html | +47 -0 |
| •webroot/templates/repo_blob.html | +45 -0 |
| •webroot/templates/repo_branches.html | +59 -0 |
| •webroot/templates/repo_commit.html | +49 -0 |
| •webroot/templates/repo_commits.html | +75 -0 |
| •webroot/templates/repo_home.html | +133 -0 |
| •webroot/templates/repo_settings.html | +54 -0 |
| •webroot/webroot.go | +9 -0 |
变更内容
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..76fe14d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+# 编译产物
+gitcat-linux-amd64
+*.exe
+
+# 运行时数据(SQLite 数据库、仓库与头像)
+data/
+
+# IDE / 工具数据
+.codebuddy/
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..514a3f4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,18 @@
+module gitcat
+
+go 1.27.0
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/yuin/goldmark v1.8.6 // indirect
+ golang.org/x/crypto v0.57.0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ modernc.org/libc v1.77.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.12.1 // indirect
+ modernc.org/sqlite v1.60.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b24d31a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,24 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
+github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
+modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
+modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
diff --git a/internal/ai/ai.go b/internal/ai/ai.go
new file mode 100644
index 0000000..d0f48db
--- /dev/null
+++ b/internal/ai/ai.go
@@ -0,0 +1,200 @@
+// Package ai 提供 OpenAI 兼容接口的调用,用于自动生成 "What's New" 更新说明。
+package ai
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Config 是 AI 接入配置(管理员在后台填写)。
+type Config struct {
+ Enabled bool
+ BaseURL string
+ APIKey string
+ Model string
+ Language string // zh / en
+}
+
+// Ready 判断是否具备调用条件。
+func (c Config) Ready() bool {
+ return c.Enabled && strings.TrimSpace(c.BaseURL) != "" && strings.TrimSpace(c.Model) != ""
+}
+
+// Endpoint 归一化出 chat/completions 地址。
+func (c Config) Endpoint() string {
+ base := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
+ switch {
+ case base == "":
+ return ""
+ case strings.HasSuffix(base, "/chat/completions"):
+ return base
+ case strings.HasSuffix(base, "/v1"):
+ return base + "/chat/completions"
+ default:
+ return base + "/v1/chat/completions"
+ }
+}
+
+// CommitLine 是喂给模型的一条提交摘要。
+type CommitLine struct {
+ SHA string
+ Author string
+ Subject string
+}
+
+// SummaryInput 是一次推送的全部素材。
+type SummaryInput struct {
+ RepoName string
+ Ref string
+ Commits []CommitLine
+ DiffStat string
+ Additions int
+ Deletions int
+}
+
+type chatRequest struct {
+ Model string `json:"model"`
+ Messages []chatMessage `json:"messages"`
+ Temperature float64 `json:"temperature"`
+}
+
+type chatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+}
+
+type chatResponse struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+func systemPrompt(lang string) string {
+ if lang == "en" {
+ return `You are the changelog assistant of "gitcat", a self-hosted Git service for a small studio.
+Given the commits and change stats of one git push, write a short visitor-friendly "What's New" summary.
+Rules: English; at most 3 bullet points, each starting with "- "; summarize the intent instead of copying commit messages; do not invent features; keep it under 80 words; no headings, no extra commentary.`
+ }
+ return `你是 "gitcat"(工作室自建 Git 服务)的更新日志助手。
+请根据一次代码推送的提交信息与变更统计,写一段面向访问者的「What's New」更新说明。
+要求:使用简体中文;最多 3 条要点,每条以「- 」开头并单独成行;归纳改动意图而不是照抄提交信息;不要编造未提及的内容;总字数控制在 120 字以内;不要输出标题或额外说明。`
+}
+
+// GenerateWhatsNew 调用模型生成更新说明。
+func GenerateWhatsNew(ctx context.Context, cfg Config, in SummaryInput) (string, error) {
+ if !cfg.Ready() {
+ return "", fmt.Errorf("AI 未启用或配置不完整")
+ }
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "仓库:%s\n分支:%s\n提交数:%d\n", in.RepoName, in.Ref, len(in.Commits))
+ if in.Additions > 0 || in.Deletions > 0 {
+ fmt.Fprintf(&sb, "代码行变化:+%d / -%d\n", in.Additions, in.Deletions)
+ }
+ if in.DiffStat != "" {
+ sb.WriteString("变更文件统计:\n")
+ sb.WriteString(truncate(in.DiffStat, 3000))
+ sb.WriteString("\n")
+ }
+ sb.WriteString("提交列表:\n")
+ for i, c := range in.Commits {
+ if i >= 40 {
+ fmt.Fprintf(&sb, "…… 其余 %d 条提交已省略\n", len(in.Commits)-i)
+ break
+ }
+ fmt.Fprintf(&sb, "- %s %s: %s\n", shortSHA(c.SHA), c.Author, c.Subject)
+ }
+
+ body, err := json.Marshal(chatRequest{
+ Model: cfg.Model,
+ Messages: []chatMessage{
+ {Role: "system", Content: systemPrompt(cfg.Language)},
+ {Role: "user", Content: sb.String()},
+ },
+ Temperature: 0.3,
+ })
+ if err != nil {
+ return "", err
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.Endpoint(), bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if cfg.APIKey != "" {
+ req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return "", err
+ }
+ var parsed chatResponse
+ if err := json.Unmarshal(raw, &parsed); err != nil {
+ return "", fmt.Errorf("AI 返回内容无法解析(HTTP %d)", resp.StatusCode)
+ }
+ if parsed.Error != nil && parsed.Error.Message != "" {
+ return "", fmt.Errorf("AI 接口错误:%s", parsed.Error.Message)
+ }
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("AI 接口返回 HTTP %d", resp.StatusCode)
+ }
+ if len(parsed.Choices) == 0 {
+ return "", fmt.Errorf("AI 未返回任何内容")
+ }
+ out := strings.TrimSpace(parsed.Choices[0].Message.Content)
+ if out == "" {
+ return "", fmt.Errorf("AI 返回内容为空")
+ }
+ return truncate(out, 2000), nil
+}
+
+// TestConnection 用一条极小的请求验证配置是否可用。
+func TestConnection(ctx context.Context, cfg Config) (string, error) {
+ cfg.Enabled = true
+ return GenerateWhatsNew(ctx, cfg, SummaryInput{
+ RepoName: "gitcat-demo",
+ Ref: "main",
+ Commits: []CommitLine{
+ {SHA: "0123456789abcdef", Author: "gitcat", Subject: "add hello world page"},
+ },
+ DiffStat: " hello.txt | 1 +\n 1 file changed, 1 insertion(+)",
+ })
+}
+
+func shortSHA(s string) string {
+ if len(s) > 7 {
+ return s[:7]
+ }
+ return s
+}
+
+// truncate 按字符(而非字节)截断,避免切断多字节字符。
+func truncate(s string, max int) string {
+ r := []rune(s)
+ if len(r) <= max {
+ return s
+ }
+ cut := string(r[:max])
+ if i := strings.LastIndexAny(cut, "\n"); i > len(cut)/2 {
+ cut = cut[:i]
+ }
+ return cut + "\n…"
+}
diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go
new file mode 100644
index 0000000..b5095dc
--- /dev/null
+++ b/internal/gitx/gitx.go
@@ -0,0 +1,777 @@
+// Package gitx 封装对 git 命令行与裸仓库的读写操作。
+package gitx
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// EmptyTreeSHA 是 git 中"空树"对象的哈希,用于与首次提交做 diff。
+const EmptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
+
+var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
+
+// Version 返回 git 版本号。
+func Version() string {
+ out, err := exec.Command("git", "--version").Output()
+ if err != nil {
+ return "unknown"
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// Available 检查 git 是否可用。
+func Available() bool {
+ _, err := exec.LookPath("git")
+ return err == nil
+}
+
+// ValidRepoName 校验仓库名是否合法。
+func ValidRepoName(name string) bool {
+ if !repoNameRe.MatchString(name) {
+ return false
+ }
+ if strings.Contains(name, "..") || strings.HasSuffix(strings.ToLower(name), ".git") {
+ return false
+ }
+ return true
+}
+
+func run(dir string, args ...string) ([]byte, error) {
+ cmd := exec.Command("git", args...)
+ if dir != "" {
+ cmd.Dir = dir
+ }
+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ msg := strings.TrimSpace(stderr.String())
+ if msg == "" {
+ msg = err.Error()
+ }
+ return stdout.Bytes(), fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
+ }
+ return stdout.Bytes(), nil
+}
+
+// InitBare 创建一个裸仓库。
+func InitBare(path, defaultBranch string) error {
+ if defaultBranch == "" {
+ defaultBranch = "main"
+ }
+ if _, err := run("", "init", "--bare", "--initial-branch="+defaultBranch, path); err != nil {
+ // 兼容老版本 git:退化为默认分支再修正 HEAD
+ if _, err2 := run("", "init", "--bare", path); err2 != nil {
+ return err
+ }
+ _, _ = run(path, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch)
+ }
+ // 关闭自动 gc,避免在推送关键时刻执行重打包
+ _, _ = run(path, "config", "gc.auto", "0")
+ return nil
+}
+
+// IsBareRepo 判断路径下是否是(空或非空的)裸仓库。
+func IsBareRepo(path string) bool {
+ if st, err := os.Stat(filepath.Join(path, "HEAD")); err != nil || st.IsDir() {
+ return false
+ }
+ if st, err := os.Stat(filepath.Join(path, "objects")); err != nil || !st.IsDir() {
+ return false
+ }
+ return true
+}
+
+// Refs 返回所有 ref 的 名称 -> SHA 映射。
+func Refs(repo string) (map[string]string, error) {
+ out, err := run(repo, "for-each-ref", "--format=%(refname)%00%(objectname)")
+ if err != nil {
+ return nil, err
+ }
+ m := map[string]string{}
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimRight(line, "\r")
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "\x00", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ m[parts[0]] = parts[1]
+ }
+ return m, nil
+}
+
+// Commit 是一次提交的元信息。
+type Commit struct {
+ SHA string
+ Author string
+ Email string
+ When time.Time
+ Parents []string
+ Subject string
+}
+
+// Short 返回短 SHA。
+func (c Commit) Short() string {
+ if len(c.SHA) > 7 {
+ return c.SHA[:7]
+ }
+ return c.SHA
+}
+
+// IsMerge 判断是否为合并提交。
+func (c Commit) IsMerge() bool { return len(c.Parents) > 1 }
+
+const logFormat = "%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%s%x1e"
+
+func parseCommitLine(line string) (Commit, bool) {
+ line = strings.TrimRight(line, "\r")
+ line = strings.TrimPrefix(line, "\x1e")
+ line = strings.TrimSuffix(line, "\x1e")
+ if !strings.Contains(line, "\x1f") {
+ return Commit{}, false
+ }
+ f := strings.SplitN(line, "\x1f", 6)
+ if len(f) < 6 {
+ return Commit{}, false
+ }
+ ts, _ := strconv.ParseInt(f[3], 10, 64)
+ c := Commit{
+ SHA: f[0],
+ Author: f[1],
+ Email: f[2],
+ When: time.Unix(ts, 0),
+ Subject: f[5],
+ }
+ if p := strings.Fields(f[4]); len(p) > 0 {
+ c.Parents = p
+ }
+ return c, true
+}
+
+// Log 返回某个 ref 的提交列表。
+func Log(repo, ref string, limit, skip int) ([]Commit, error) {
+ args := []string{"log", "--no-color", "--format=" + logFormat}
+ if limit > 0 {
+ args = append(args, "-n", strconv.Itoa(limit))
+ }
+ if skip > 0 {
+ args = append(args, "--skip="+strconv.Itoa(skip))
+ }
+ args = append(args, ref)
+ out, err := run(repo, args...)
+ if err != nil {
+ return nil, err
+ }
+ return parseCommits(string(out)), nil
+}
+
+func parseCommits(out string) []Commit {
+ var commits []Commit
+ for _, line := range strings.Split(out, "\n") {
+ if c, ok := parseCommitLine(line); ok {
+ commits = append(commits, c)
+ }
+ }
+ return commits
+}
+
+// Reserve 判断 ref 是否可以解析为提交。
+func Resolve(repo, ref string) (string, error) {
+ out, err := run(repo, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// CountCommits 统计 ref 上的提交数量。
+func CountCommits(repo, ref string) int {
+ out, err := run(repo, "rev-list", "--count", ref)
+ if err != nil {
+ return 0
+ }
+ n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
+ return n
+}
+
+// FileCount 统计文件数量。
+func FileCount(repo, ref string) int {
+ out, err := run(repo, "ls-tree", "-r", "--name-only", ref)
+ if err != nil {
+ return 0
+ }
+ n := 0
+ for _, l := range strings.Split(string(out), "\n") {
+ if strings.TrimSpace(l) != "" {
+ n++
+ }
+ }
+ return n
+}
+
+// Ref 是分支或标签。
+type Ref struct {
+ Name string
+ SHA string
+ When time.Time
+ Subject string
+ IsTag bool
+}
+
+// Branches 返回全部分支。
+func Branches(repo string) ([]Ref, error) {
+ return refList(repo, "refs/heads", false)
+}
+
+// Tags 返回全部标签。
+func Tags(repo string) ([]Ref, error) {
+ return refList(repo, "refs/tags", true)
+}
+
+func refList(repo, prefix string, isTag bool) ([]Ref, error) {
+ // 注意:for-each-ref 的 format 不支持 %xNN 转义(仅 git log/show 支持),
+ // 因此这里用 %00 作为字段分隔符。
+ out, err := run(repo, "for-each-ref", "--sort=-committerdate",
+ "--format=%(refname:short)%00%(objectname)%00%(committerdate:unix)%00%(subject)", prefix)
+ if err != nil {
+ return nil, err
+ }
+ var refs []Ref
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimRight(line, "\r")
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ f := strings.SplitN(line, "\x00", 4)
+ if len(f) < 4 {
+ continue
+ }
+ ts, _ := strconv.ParseInt(f[2], 10, 64)
+ refs = append(refs, Ref{Name: f[0], SHA: f[1], When: time.Unix(ts, 0), Subject: f[3], IsTag: isTag})
+ }
+ return refs, nil
+}
+
+// TreeEntry 是目录中的一项。
+type TreeEntry struct {
+ Mode string
+ Type string // blob / tree / commit(子模块)
+ SHA string
+ Name string
+ Size int64
+ Path string
+}
+
+// IsDir 判断是否为目录。
+func (e TreeEntry) IsDir() bool { return e.Type == "tree" }
+
+// Tree 列出某个目录下的内容(dir 为 "" 表示根目录)。
+func Tree(repo, ref, dir string) ([]TreeEntry, error) {
+ spec := ref
+ if dir != "" {
+ spec = ref + ":" + strings.Trim(dir, "/")
+ }
+ out, err := run(repo, "ls-tree", "-l", spec)
+ if err != nil {
+ return nil, err
+ }
+ var entries []TreeEntry
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimRight(line, "\r")
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ tab := strings.Index(line, "\t")
+ if tab < 0 {
+ continue
+ }
+ meta := strings.Fields(line[:tab])
+ name := line[tab+1:]
+ if len(meta) < 4 {
+ continue
+ }
+ e := TreeEntry{Mode: meta[0], Type: meta[1], SHA: meta[2], Name: name}
+ if meta[3] != "-" {
+ e.Size, _ = strconv.ParseInt(meta[3], 10, 64)
+ }
+ e.Path = name
+ if dir != "" {
+ e.Path = strings.Trim(dir, "/") + "/" + name
+ }
+ entries = append(entries, e)
+ }
+ // 目录在前,其次按名称排序
+ sort.SliceStable(entries, func(i, j int) bool {
+ if entries[i].IsDir() != entries[j].IsDir() {
+ return entries[i].IsDir()
+ }
+ return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
+ })
+ return entries, nil
+}
+
+// TreeExists 判断路径是否存在于该 ref 中。
+func TreeExists(repo, ref, path string) bool {
+ if path == "" {
+ return true
+ }
+ _, err := run(repo, "cat-file", "-t", ref+":"+strings.Trim(path, "/"))
+ return err == nil
+}
+
+// LastCommitForPath 返回影响指定路径的最近一次提交。
+func LastCommitForPath(repo, ref, path string) (Commit, bool) {
+ args := []string{"log", "--no-color", "--format=" + logFormat, "-n", "1", ref}
+ if strings.Trim(path, "/") != "" {
+ args = append(args, "--", strings.Trim(path, "/"))
+ }
+ out, err := run(repo, args...)
+ if err != nil {
+ return Commit{}, false
+ }
+ commits := parseCommits(string(out))
+ if len(commits) == 0 {
+ return Commit{}, false
+ }
+ return commits[0], true
+}
+
+// Blob 读取文件内容。
+func Blob(repo, ref, path string) ([]byte, error) {
+ return run(repo, "show", ref+":"+strings.Trim(path, "/"))
+}
+
+// BlobSize 返回文件字节数。
+func BlobSize(repo, ref, path string) int64 {
+ out, err := run(repo, "cat-file", "-s", ref+":"+strings.Trim(path, "/"))
+ if err != nil {
+ return 0
+ }
+ n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
+ return n
+}
+
+// IsBinary 用内容启发式判断是否为二进制文件。
+func IsBinary(b []byte) bool {
+ n := len(b)
+ if n > 8000 {
+ n = 8000
+ }
+ return bytes.IndexByte(b[:n], 0) >= 0
+}
+
+// LastCommits 返回目录下每个文件的最近一次提交(单次 git 调用,最多回看 limit 个提交)。
+func LastCommits(repo, ref, dir string, limit int) map[string]Commit {
+ // 字段顺序必须与 parseCommitLine 保持一致,否则整条记录会被判为非法而丢弃。
+ args := []string{"log", "--no-color", "--name-only", "--format=%x1e%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%s",
+ "-n", strconv.Itoa(limit), ref}
+ if dir != "" {
+ args = append(args, "--", strings.Trim(dir, "/"))
+ }
+ out, err := run(repo, args...)
+ if err != nil {
+ return nil
+ }
+ result := map[string]Commit{}
+ var cur *Commit
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimRight(line, "\r")
+ if strings.HasPrefix(line, "\x1e") {
+ if c, ok := parseCommitLine(line); ok {
+ cur = &c
+ }
+ continue
+ }
+ if strings.TrimSpace(line) == "" || cur == nil {
+ continue
+ }
+ if _, seen := result[line]; !seen {
+ result[line] = *cur
+ }
+ }
+ return result
+}
+
+// FileStat 是单文件增删行统计。
+type FileStat struct {
+ Path string
+ Additions int
+ Deletions int
+ Binary bool
+}
+
+// CommitDetail 是提交详情。
+type CommitDetail struct {
+ Commit
+ Message string
+ Files []FileStat
+ Diff string
+ Truncated bool
+}
+
+// Show 读取一次提交的完整信息。
+func Show(repo, sha string) (*CommitDetail, error) {
+ out, err := run(repo, "show", "-s", "--no-color", "--format=%H%x1f%an%x1f%ae%x1f%at%x1f%P%x1f%B", sha)
+ if err != nil {
+ return nil, err
+ }
+ raw := strings.TrimRight(string(out), "\n")
+ f := strings.SplitN(raw, "\x1f", 6)
+ if len(f) < 6 {
+ return nil, errors.New("无法解析提交信息")
+ }
+ ts, _ := strconv.ParseInt(f[3], 10, 64)
+ d := &CommitDetail{}
+ d.SHA = f[0]
+ d.Author = f[1]
+ d.Email = f[2]
+ d.When = time.Unix(ts, 0)
+ d.Parents = strings.Fields(f[4])
+ d.Subject = strings.SplitN(f[5], "\n", 2)[0]
+ d.Message = f[5]
+
+ if ns, err := run(repo, "show", "--numstat", "--format=", "--no-color", sha); err == nil {
+ for _, line := range strings.Split(string(ns), "\n") {
+ parts := strings.SplitN(strings.TrimRight(line, "\r"), "\t", 3)
+ if len(parts) != 3 || parts[2] == "" {
+ continue
+ }
+ st := FileStat{Path: parts[2]}
+ if parts[0] == "-" {
+ st.Binary = true
+ } else {
+ st.Additions, _ = strconv.Atoi(parts[0])
+ st.Deletions, _ = strconv.Atoi(parts[1])
+ }
+ d.Files = append(d.Files, st)
+ }
+ }
+
+ buf := &limitedBuffer{max: 400 << 10}
+ cmd := exec.Command("git", "show", "--patch", "--no-color", "--format=", "-M", sha)
+ cmd.Dir = repo
+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
+ cmd.Stdout = buf
+ _ = cmd.Run()
+ d.Diff = buf.String()
+ d.Truncated = buf.truncated
+ return d, nil
+}
+
+type limitedBuffer struct {
+ buf bytes.Buffer
+ max int
+ truncated bool
+}
+
+func (l *limitedBuffer) Write(p []byte) (int, error) {
+ if l.buf.Len() >= l.max {
+ l.truncated = true
+ return len(p), nil
+ }
+ room := l.max - l.buf.Len()
+ if room < len(p) {
+ l.truncated = true
+ l.buf.Write(p[:room])
+ return len(p), nil
+ }
+ l.buf.Write(p)
+ return len(p), nil
+}
+
+func (l *limitedBuffer) String() string { return l.buf.String() }
+
+// Contributor 是贡献者统计。
+type Contributor struct {
+ Name string
+ Email string
+ Count int
+}
+
+// Contributors 统计贡献者(按提交数排序)。
+func Contributors(repo, ref string, limit int) []Contributor {
+ out, err := run(repo, "shortlog", "-sne", ref)
+ if err != nil {
+ return nil
+ }
+ var list []Contributor
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimRight(line, "\r")
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ f := strings.SplitN(line, "\t", 2)
+ if len(f) != 2 {
+ continue
+ }
+ count, _ := strconv.Atoi(strings.TrimSpace(f[0]))
+ name, email := f[1], ""
+ if i := strings.LastIndex(f[1], "<"); i > 0 {
+ name = strings.TrimSpace(f[1][:i])
+ email = strings.Trim(strings.TrimSpace(f[1][i:]), "<>")
+ }
+ list = append(list, Contributor{Name: name, Email: email, Count: count})
+ }
+ if limit > 0 && len(list) > limit {
+ list = list[:limit]
+ }
+ return list
+}
+
+// LangStat 是语言占比统计。
+type LangStat struct {
+ Name string
+ Color string
+ Files int
+ Percent float64
+}
+
+var langByExt = map[string][2]string{
+ ".go": {"Go", "#00ADD8"},
+ ".js": {"JavaScript", "#F1E05A"},
+ ".mjs": {"JavaScript", "#F1E05A"},
+ ".cjs": {"JavaScript", "#F1E05A"},
+ ".jsx": {"JavaScript", "#F1E05A"},
+ ".ts": {"TypeScript", "#3178C6"},
+ ".tsx": {"TypeScript", "#3178C6"},
+ ".py": {"Python", "#3572A5"},
+ ".java": {"Java", "#B07219"},
+ ".kt": {"Kotlin", "#A97BFF"},
+ ".rb": {"Ruby", "#701516"},
+ ".rs": {"Rust", "#DEA584"},
+ ".c": {"C", "#555555"},
+ ".h": {"C", "#555555"},
+ ".cpp": {"C++", "#F34B7D"},
+ ".cc": {"C++", "#F34B7D"},
+ ".hpp": {"C++", "#F34B7D"},
+ ".cs": {"C#", "#178600"},
+ ".php": {"PHP", "#4F5D95"},
+ ".swift": {"Swift", "#F05138"},
+ ".sh": {"Shell", "#89E051"},
+ ".bash": {"Shell", "#89E051"},
+ ".html": {"HTML", "#E34C26"},
+ ".css": {"CSS", "#563D7C"},
+ ".scss": {"SCSS", "#C6538C"},
+ ".vue": {"Vue", "#41B883"},
+ ".svelte": {"Svelte", "#FF3E00"},
+ ".dart": {"Dart", "#00B4AB"},
+ ".sql": {"SQL", "#E38C00"},
+ ".lua": {"Lua", "#000080"},
+ ".r": {"R", "#198CE7"},
+ ".ex": {"Elixir", "#6E4A7E"},
+ ".erl": {"Erlang", "#B83998"},
+ ".scala": {"Scala", "#C22D40"},
+ ".pl": {"Perl", "#0298C3"},
+ ".zig": {"Zig", "#EC915C"},
+ ".proto": {"Protocol Buffers", "#6E4A7E"},
+}
+
+// Languages 统计代码语言占比(按文件数)。
+func Languages(repo, ref string, top int) []LangStat {
+ out, err := run(repo, "ls-tree", "-r", "--name-only", ref)
+ if err != nil {
+ return nil
+ }
+ counts := map[string]int{}
+ total := 0
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimRight(line, "\r")
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ ext := strings.ToLower(filepath.Ext(line))
+ lang, ok := langByExt[ext]
+ if !ok {
+ continue
+ }
+ counts[lang[0]]++
+ total++
+ }
+ if total == 0 {
+ return nil
+ }
+ var list []LangStat
+ for name, n := range counts {
+ color := "#888888"
+ for _, v := range langByExt {
+ if v[0] == name {
+ color = v[1]
+ break
+ }
+ }
+ list = append(list, LangStat{Name: name, Color: color, Files: n, Percent: float64(n) / float64(total) * 100})
+ }
+ sort.Slice(list, func(i, j int) bool { return list[i].Files > list[j].Files })
+ if top > 0 && len(list) > top {
+ list = list[:top]
+ }
+ return list
+}
+
+// readmeNames 是 README 候选文件名。
+var readmeNames = []string{"readme.md", "readme.markdown", "readme.rst", "readme.txt", "readme"}
+
+// Readme 查找并返回根目录的 README 内容。
+func Readme(repo, ref string) (string, []byte, bool) {
+ entries, err := Tree(repo, ref, "")
+ if err != nil {
+ return "", nil, false
+ }
+ for _, want := range readmeNames {
+ for _, e := range entries {
+ if !e.IsDir() && strings.ToLower(e.Name) == want {
+ content, err := Blob(repo, ref, e.Name)
+ if err != nil {
+ return "", nil, false
+ }
+ return e.Name, content, true
+ }
+ }
+ }
+ return "", nil, false
+}
+
+// RepoSize 统计仓库目录占用字节数。
+func RepoSize(repo string) int64 {
+ var total int64
+ _ = filepath.WalkDir(repo, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if !d.IsDir() {
+ if info, err := d.Info(); err == nil {
+ total += info.Size()
+ }
+ }
+ return nil
+ })
+ return total
+}
+
+// CommitsBetween 返回 old..new 之间的提交(含每个提交的增删行数)。
+func CommitsBetween(repo, oldSHA, newSHA string, limit int) ([]PushCommit, error) {
+ rng := newSHA
+ if oldSHA != "" && oldSHA != strings.Repeat("0", 40) {
+ rng = oldSHA + ".." + newSHA
+ }
+ args := []string{"log", "--no-color", "--numstat", "--format=" + logFormat, "-n", strconv.Itoa(limit), rng}
+ out, err := run(repo, args...)
+ if err != nil {
+ if oldSHA != "" {
+ // 可能是新分支 / 强制推送,退化为只看新提交
+ return CommitsBetween(repo, "", newSHA, limit)
+ }
+ return nil, err
+ }
+ var commits []PushCommit
+ var cur *PushCommit
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimRight(line, "\r")
+ if c, ok := parseCommitLine(line); ok {
+ commits = append(commits, PushCommit{SHA: c.SHA, Author: c.Author, When: c.When.Unix(), Subject: c.Subject})
+ cur = &commits[len(commits)-1]
+ continue
+ }
+ if cur == nil || strings.TrimSpace(line) == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "\t", 3)
+ if len(parts) != 3 {
+ continue
+ }
+ cur.Files++
+ if parts[0] == "-" {
+ continue
+ }
+ add, _ := strconv.Atoi(parts[0])
+ del, _ := strconv.Atoi(parts[1])
+ cur.Additions += add
+ cur.Deletions += del
+ }
+ return commits, nil
+}
+
+// PushCommit 是用于留痕展示的提交摘要。
+type PushCommit struct {
+ SHA string
+ Subject string
+ Author string
+ When int64
+ Additions int
+ Deletions int
+ Files int
+}
+
+// Short 返回短 SHA。
+func (c PushCommit) Short() string {
+ if len(c.SHA) > 7 {
+ return c.SHA[:7]
+ }
+ return c.SHA
+}
+
+// DiffStat 返回两次提交之间的变更统计文本。
+func DiffStat(repo, oldSHA, newSHA string) string {
+ if oldSHA == "" || oldSHA == strings.Repeat("0", 40) {
+ oldSHA = EmptyTreeSHA
+ }
+ if newSHA == "" || newSHA == strings.Repeat("0", 40) {
+ return ""
+ }
+ out, err := run(repo, "diff", "--stat", "--no-color", oldSHA, newSHA)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// SetDefaultBranch 修改裸仓库的 HEAD 指向。
+func SetDefaultBranch(repo, branch string) error {
+ _, err := run(repo, "symbolic-ref", "HEAD", "refs/heads/"+branch)
+ return err
+}
+
+// CurrentBranch 读取裸仓库 HEAD 指向的分支。
+func CurrentBranch(repo string) string {
+ out, err := run(repo, "symbolic-ref", "--short", "HEAD")
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// HasCommits 判断仓库是否有任何提交。
+func HasCommits(repo string) bool {
+ refs, err := Refs(repo)
+ if err != nil {
+ return false
+ }
+ for name := range refs {
+ if strings.HasPrefix(name, "refs/heads/") || strings.HasPrefix(name, "refs/tags/") {
+ return true
+ }
+ }
+ return false
+}
+
+// CopyReader 用于把请求体透传给 git 命令。
+func CopyReader(dst io.Writer, src io.Reader) error {
+ _, err := io.Copy(dst, src)
+ return err
+}
diff --git a/internal/server/admin.go b/internal/server/admin.go
new file mode 100644
index 0000000..7143dcf
--- /dev/null
+++ b/internal/server/admin.go
@@ -0,0 +1,536 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "gitcat/internal/ai"
+ "gitcat/internal/gitx"
+ "gitcat/internal/store"
+)
+
+func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ users, repos, pushes, _ := s.st.Counts()
+ recentPushes, _ := s.st.RecentPushes(10)
+ allUsers, _ := s.st.ListUsers()
+ if len(allUsers) > 6 {
+ allUsers = allUsers[:6]
+ }
+ cfg := s.AIConfig()
+ p := s.page(r, "管理后台")
+ p.Active = "admin"
+ p.Data["Tab"] = "dash"
+ p.Data["StatUsers"] = users
+ p.Data["StatRepos"] = repos
+ p.Data["StatPushes"] = pushes
+ p.Data["StorageText"] = humanSize(s.storageUsed())
+ p.Data["RecentPushes"] = recentPushes
+ p.Data["RecentUsers"] = allUsers
+ p.Data["AIReady"] = cfg.Ready()
+ p.Data["AIModel"] = cfg.Model
+ p.Data["AIEnabled"] = cfg.Enabled
+ p.Data["GoVersion"] = goVersion()
+ p.Data["GitVersion"] = gitx.Version()
+ p.Data["DataDir"] = s.cfg.DataDir
+ p.Data["RepoRoot"] = s.repoRoot
+ s.render(w, r, "admin_dashboard", p)
+}
+
+// ---------------------------------------------------------------- 用户管理
+
+func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ users, err := s.st.ListUsers()
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取用户失败")
+ return
+ }
+ p := s.page(r, "用户管理")
+ p.Active = "admin"
+ p.Data["Tab"] = "users"
+ p.Data["Users"] = users
+ s.render(w, r, "admin_users", p)
+}
+
+func (s *Server) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
+ admin := s.requireAdmin(w, r)
+ if admin == nil {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ username := strings.TrimSpace(r.FormValue("username"))
+ display := strings.TrimSpace(r.FormValue("display_name"))
+ email := strings.TrimSpace(r.FormValue("email"))
+ password := r.FormValue("password")
+ isAdmin := r.FormValue("is_admin") == "on"
+
+ fail := func(msg string) {
+ setFlash(w, "创建失败:"+msg)
+ http.Redirect(w, r, "/admin/users", http.StatusFound)
+ }
+ switch {
+ case !validUsername(username):
+ fail("用户名需为 2-32 位字母、数字、下划线、短横线或点号")
+ return
+ case len(password) < 6:
+ fail("密码至少需要 6 位")
+ return
+ case strings.EqualFold(username, "admin") && username != "admin":
+ fail("用户名不合法")
+ return
+ }
+ hash, err := hashPassword(password)
+ if err != nil {
+ fail("密码处理失败")
+ return
+ }
+ u, err := s.st.CreateUser(username, display, email, hash, isAdmin)
+ if err != nil {
+ fail(err.Error())
+ return
+ }
+ if u.DisplayName == "" {
+ _ = s.st.UpdateProfile(u.ID, username, email, "", "")
+ }
+ setFlash(w, fmt.Sprintf("已创建账号 %s,请把初始密码告知该成员", u.Username))
+ http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(u.ID, 10), http.StatusFound)
+}
+
+func (s *Server) adminUserFromPath(w http.ResponseWriter, r *http.Request) (*store.User, bool) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ s.renderError(w, r, http.StatusNotFound, "用户不存在")
+ return nil, false
+ }
+ u, err := s.st.UserByID(id)
+ if err != nil {
+ s.renderError(w, r, http.StatusNotFound, "用户不存在")
+ return nil, false
+ }
+ return u, true
+}
+
+func (s *Server) handleAdminUserDetail(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ u, ok := s.adminUserFromPath(w, r)
+ if !ok {
+ return
+ }
+ repos, _ := s.st.ListRepos("")
+ var owned []any
+ for _, rc := range repos {
+ if rc.OwnerID != nil && *rc.OwnerID == u.ID {
+ owned = append(owned, rc)
+ }
+ }
+ pushes, _ := s.st.RecentPushes(200)
+ var mine []any
+ for _, psh := range pushes {
+ if psh.UserID != nil && *psh.UserID == u.ID {
+ mine = append(mine, psh)
+ }
+ if len(mine) >= 10 {
+ break
+ }
+ }
+ p := s.page(r, "用户 "+u.Username)
+ p.Active = "admin"
+ p.Data["Tab"] = "users"
+ p.Data["U"] = u
+ p.Data["OwnedRepos"] = owned
+ p.Data["RecentPushes"] = mine
+ s.render(w, r, "admin_user", p)
+}
+
+func (s *Server) handleAdminUserSave(w http.ResponseWriter, r *http.Request) {
+ admin := s.requireAdmin(w, r)
+ if admin == nil {
+ return
+ }
+ u, ok := s.adminUserFromPath(w, r)
+ if !ok {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ display := strings.TrimSpace(r.FormValue("display_name"))
+ email := strings.TrimSpace(r.FormValue("email"))
+ bio := strings.TrimSpace(r.FormValue("bio"))
+ isAdmin := r.FormValue("is_admin") == "on"
+ isDisabled := r.FormValue("is_disabled") == "on"
+
+ if u.ID == admin.ID {
+ // 防止管理员误操作把自己锁在门外
+ isAdmin = true
+ isDisabled = false
+ }
+ admins, _ := s.st.CountAdmins()
+ if u.IsAdmin && !isAdmin && admins <= 1 {
+ setFlash(w, "不能取消最后一个管理员的权限")
+ http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(u.ID, 10), http.StatusFound)
+ return
+ }
+ if err := s.st.UpdateProfile(u.ID, display, email, bio, u.Avatar); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
+ return
+ }
+ if err := s.st.SetUserFlags(u.ID, isAdmin, isDisabled); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
+ return
+ }
+ if pw := r.FormValue("new_password"); pw != "" {
+ if len(pw) < 6 {
+ setFlash(w, "新密码至少需要 6 位,密码未修改")
+ http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(u.ID, 10), http.StatusFound)
+ return
+ }
+ hash, err := hashPassword(pw)
+ if err == nil {
+ _ = s.st.SetPassword(u.ID, hash)
+ }
+ }
+ setFlash(w, "已保存用户 "+u.Username+" 的信息")
+ http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(u.ID, 10), http.StatusFound)
+}
+
+func (s *Server) handleAdminUserDelete(w http.ResponseWriter, r *http.Request) {
+ admin := s.requireAdmin(w, r)
+ if admin == nil {
+ return
+ }
+ u, ok := s.adminUserFromPath(w, r)
+ if !ok {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ if u.ID == admin.ID {
+ setFlash(w, "不能删除当前登录的账号")
+ http.Redirect(w, r, "/admin/users", http.StatusFound)
+ return
+ }
+ if u.IsAdmin {
+ admins, _ := s.st.CountAdmins()
+ if admins <= 1 {
+ setFlash(w, "至少需要保留一个管理员")
+ http.Redirect(w, r, "/admin/users", http.StatusFound)
+ return
+ }
+ }
+ if err := s.st.DeleteUser(u.ID); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "删除失败:"+err.Error())
+ return
+ }
+ setFlash(w, "已删除账号 "+u.Username)
+ http.Redirect(w, r, "/admin/users", http.StatusFound)
+}
+
+// ---------------------------------------------------------------- 仓库管理
+
+func (s *Server) adminRepoFromPath(w http.ResponseWriter, r *http.Request) (*store.Repo, bool) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ s.renderError(w, r, http.StatusNotFound, "项目不存在")
+ return nil, false
+ }
+ repo, err := s.st.RepoByID(id)
+ if err != nil {
+ s.renderError(w, r, http.StatusNotFound, "项目不存在")
+ return nil, false
+ }
+ return repo, true
+}
+
+func (s *Server) handleAdminRepos(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ cards, err := s.st.ListRepos(strings.TrimSpace(r.URL.Query().Get("q")))
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取项目失败")
+ return
+ }
+ type row struct {
+ Card *store.RepoCard
+ Size string
+ }
+ var rows []row
+ for _, c := range cards {
+ rows = append(rows, row{Card: c, Size: humanSize(gitx.RepoSize(s.repoPath(c.Name)))})
+ }
+ p := s.page(r, "项目管理")
+ p.Active = "admin"
+ p.Data["Tab"] = "repos"
+ p.Data["Rows"] = rows
+ p.Data["Query"] = r.URL.Query().Get("q")
+ s.render(w, r, "admin_repos", p)
+}
+
+func (s *Server) handleAdminRepoDetail(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ repo, ok := s.adminRepoFromPath(w, r)
+ if !ok {
+ return
+ }
+ dir := s.repoPath(repo.Name)
+ users, _ := s.st.ListUsers()
+ pushes, _ := s.st.PushesByRepo(repo.ID, 20)
+ branches, _ := gitx.Branches(dir)
+ p := s.page(r, "项目 "+repo.Name)
+ p.Active = "admin"
+ p.Data["Tab"] = "repos"
+ p.Data["Repo"] = repo
+ p.Data["Users"] = users
+ p.Data["Pushes"] = pushes
+ p.Data["Branches"] = branches
+ p.Data["SizeText"] = humanSize(gitx.RepoSize(dir))
+ p.Data["CommitCount"] = gitx.CountCommits(dir, repo.DefaultBranch)
+ p.Data["CloneURL"] = cloneURL(r, repo.Name)
+ p.Data["Missing"] = !gitx.IsBareRepo(dir)
+ s.render(w, r, "admin_repo", p)
+}
+
+func (s *Server) handleAdminRepoSave(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ repo, ok := s.adminRepoFromPath(w, r)
+ if !ok {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ redirect := "/admin/repos/" + strconv.FormatInt(repo.ID, 10)
+
+ desc := strings.TrimSpace(r.FormValue("description"))
+ if len(desc) > 300 {
+ setFlash(w, "项目描述不能超过 300 个字符")
+ http.Redirect(w, r, redirect, http.StatusFound)
+ return
+ }
+ repo.Description = desc
+ repo.AIEnabled = r.FormValue("ai_enabled") == "on"
+ repo.IsArchived = r.FormValue("is_archived") == "on"
+
+ if ownerID := strings.TrimSpace(r.FormValue("owner_id")); ownerID != "" {
+ id, err := strconv.ParseInt(ownerID, 10, 64)
+ if err == nil {
+ if _, err := s.st.UserByID(id); err == nil {
+ repo.OwnerID = &id
+ }
+ }
+ } else if r.FormValue("owner_none") == "on" {
+ repo.OwnerID = nil
+ }
+ if branch := strings.TrimSpace(r.FormValue("default_branch")); branch != "" {
+ repo.DefaultBranch = branch
+ _ = gitx.SetDefaultBranch(s.repoPath(repo.Name), branch)
+ }
+ if err := s.st.UpdateRepo(repo); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
+ return
+ }
+ // UpdateRepo 不写 owner_id,这里单独更新
+ _ = s.st.SetRepoOwner(repo.ID, repo.OwnerID)
+ setFlash(w, "项目 "+repo.Name+" 已更新")
+ http.Redirect(w, r, redirect, http.StatusFound)
+}
+
+func (s *Server) handleAdminRepoDelete(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ repo, ok := s.adminRepoFromPath(w, r)
+ if !ok {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ if strings.TrimSpace(r.FormValue("confirm")) != repo.Name {
+ setFlash(w, "删除失败:请输入完整的项目名以确认")
+ http.Redirect(w, r, "/admin/repos/"+strconv.FormatInt(repo.ID, 10), http.StatusFound)
+ return
+ }
+ if err := s.st.DeleteRepo(repo.ID); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "删除失败:"+err.Error())
+ return
+ }
+ _ = removeRepoDir(s.repoPath(repo.Name))
+ setFlash(w, fmt.Sprintf("项目 %s 已彻底删除", repo.Name))
+ http.Redirect(w, r, "/admin/repos", http.StatusFound)
+}
+
+// ---------------------------------------------------------------- AI 设置
+
+func (s *Server) handleAdminAIPage(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ set := s.st.Settings()
+ p := s.page(r, "AI 接入设置")
+ p.Active = "admin"
+ p.Data["Tab"] = "ai"
+ p.Data["Enabled"] = set["ai_enabled"] == "1"
+ p.Data["BaseURL"] = set["ai_base_url"]
+ p.Data["Model"] = set["ai_model"]
+ p.Data["HasKey"] = set["ai_api_key"] != ""
+ p.Data["Language"] = set["ai_language"]
+ p.Data["Settings"] = set
+ s.render(w, r, "admin_ai", p)
+}
+
+func (s *Server) handleAdminAISave(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ target := "/admin/ai"
+ if r.FormValue("action") == "test" {
+ target = "/admin/ai/test"
+ }
+
+ base := strings.TrimSpace(r.FormValue("ai_base_url"))
+ model := strings.TrimSpace(r.FormValue("ai_model"))
+ lang := r.FormValue("ai_language")
+ if lang != "en" {
+ lang = "zh"
+ }
+ if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
+ base = "https://" + base
+ }
+ _ = s.st.SetSetting("ai_base_url", base)
+ _ = s.st.SetSetting("ai_model", model)
+ _ = s.st.SetSetting("ai_language", lang)
+ _ = s.st.SetSetting("ai_enabled", boolSetting(r.FormValue("ai_enabled") == "on"))
+
+ if r.FormValue("clear_key") == "on" {
+ _ = s.st.SetSetting("ai_api_key", "")
+ } else if key := strings.TrimSpace(r.FormValue("ai_api_key")); key != "" {
+ _ = s.st.SetSetting("ai_api_key", key)
+ }
+
+ if target == "/admin/ai/test" {
+ cfg := s.AIConfig()
+ cfg.Enabled = true
+ if !cfg.Ready() {
+ setFlash(w, "测试失败:请先填写接口地址与模型名称")
+ http.Redirect(w, r, "/admin/ai", http.StatusFound)
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
+ defer cancel()
+ out, err := ai.TestConnection(ctx, cfg)
+ if err != nil {
+ setFlash(w, "测试失败:"+err.Error())
+ } else {
+ preview := strings.ReplaceAll(out, "\n", " / ")
+ // 按字符截断,避免切断多字节字符
+ if runes := []rune(preview); len(runes) > 160 {
+ preview = string(runes[:160]) + "…"
+ }
+ setFlash(w, "AI 连接正常,返回示例:"+preview)
+ }
+ http.Redirect(w, r, "/admin/ai", http.StatusFound)
+ return
+ }
+
+ setFlash(w, "AI 设置已保存")
+ http.Redirect(w, r, target, http.StatusFound)
+}
+
+func (s *Server) handleAdminAITest(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/admin/ai", http.StatusFound)
+}
+
+func boolSetting(b bool) string {
+ if b {
+ return "1"
+ }
+ return "0"
+}
+
+// ---------------------------------------------------------------- 站点设置
+
+func (s *Server) handleAdminSettingsPage(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ set := s.st.Settings()
+ p := s.page(r, "站点设置")
+ p.Active = "admin"
+ p.Data["Tab"] = "settings"
+ p.Data["SiteName"] = set["site_name"]
+ p.Data["Announcement"] = set["announcement"]
+ p.Data["Settings"] = set
+ p.Data["Addr"] = s.cfg.Addr
+ p.Data["DataDir"] = s.cfg.DataDir
+ s.render(w, r, "admin_settings", p)
+}
+
+func (s *Server) handleAdminSettingsSave(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ name := strings.TrimSpace(r.FormValue("site_name"))
+ announcement := strings.TrimSpace(r.FormValue("announcement"))
+ if name == "" {
+ setFlash(w, "站点名称不能为空")
+ http.Redirect(w, r, "/admin/settings", http.StatusFound)
+ return
+ }
+ if len(name) > 60 || len(announcement) > 500 {
+ setFlash(w, "站点名称或公告过长")
+ http.Redirect(w, r, "/admin/settings", http.StatusFound)
+ return
+ }
+ _ = s.st.SetSetting("site_name", name)
+ _ = s.st.SetSetting("announcement", announcement)
+ setFlash(w, "站点设置已保存")
+ http.Redirect(w, r, "/admin/settings", http.StatusFound)
+}
+
+// ---------------------------------------------------------------- 全站动态
+
+func (s *Server) handleAdminActivity(w http.ResponseWriter, r *http.Request) {
+ if s.requireAdmin(w, r) == nil {
+ return
+ }
+ pushes, _ := s.st.RecentPushes(100)
+ p := s.page(r, "操作动态")
+ p.Active = "admin"
+ p.Data["Tab"] = "activity"
+ p.Data["Pushes"] = pushes
+ p.Data["PushActors"] = s.pushActors(pushes)
+ s.render(w, r, "admin_activity", p)
+}
diff --git a/internal/server/auth.go b/internal/server/auth.go
new file mode 100644
index 0000000..7362650
--- /dev/null
+++ b/internal/server/auth.go
@@ -0,0 +1,222 @@
+package server
+
+import (
+ "net/http"
+ "strings"
+ "time"
+ "unicode"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "gitcat/internal/store"
+)
+
+const sessionTTL = 30 * 24 * time.Hour
+
+// hashPassword 生成 bcrypt 哈希。
+func hashPassword(pw string) (string, error) {
+ b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
+ return string(b), err
+}
+
+// checkPassword 校验明文密码。
+func checkPassword(hash, pw string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
+}
+
+// validUsername 校验用户名格式。
+func validUsername(name string) bool {
+ if len(name) < 2 || len(name) > 32 {
+ return false
+ }
+ for _, r := range name {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' && r != '-' && r != '.' {
+ return false
+ }
+ }
+ return true
+}
+
+// ---------------------------------------------------------------- 安装
+
+func (s *Server) handleInstallPage(w http.ResponseWriter, r *http.Request) {
+ if s.st.Installed() {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ p := s.page(r, "安装 gitcat")
+ s.render(w, r, "install", p)
+}
+
+func (s *Server) handleInstallSubmit(w http.ResponseWriter, r *http.Request) {
+ if s.st.Installed() {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ siteName := strings.TrimSpace(r.FormValue("site_name"))
+ username := strings.TrimSpace(r.FormValue("username"))
+ display := strings.TrimSpace(r.FormValue("display_name"))
+ password := r.FormValue("password")
+ password2 := r.FormValue("password2")
+
+ fail := func(msg string) {
+ p := s.page(r, "安装 gitcat")
+ p.Error = msg
+ p.Data["SiteName"] = siteName
+ p.Data["Username"] = username
+ p.Data["DisplayName"] = display
+ w.WriteHeader(http.StatusBadRequest)
+ s.render(w, r, "install", p)
+ }
+
+ switch {
+ case siteName == "":
+ fail("请填写站点名称")
+ return
+ case len(siteName) > 60:
+ fail("站点名称不能超过 60 个字符")
+ return
+ case !validUsername(username):
+ fail("用户名需为 2-32 位字母、数字、下划线、短横线或点号")
+ return
+ case len(password) < 6:
+ fail("密码至少需要 6 位")
+ return
+ case password != password2:
+ fail("两次输入的密码不一致")
+ return
+ }
+
+ hash, err := hashPassword(password)
+ if err != nil {
+ fail("密码处理失败:" + err.Error())
+ return
+ }
+ user, err := s.st.CreateUser(username, display, "", hash, true)
+ if err != nil {
+ fail(err.Error())
+ return
+ }
+ if display == "" {
+ _ = s.st.UpdateProfile(user.ID, username, "", "", "")
+ }
+ _ = s.st.SetSetting("site_name", siteName)
+ _ = s.st.SetSetting("installed", "1")
+ _ = s.st.SetSetting("ai_enabled", "0")
+ _ = s.st.SetSetting("ai_language", "zh")
+
+ // 安装完成后直接登录
+ if err := s.startSession(w, r, user); err != nil {
+ fail("创建会话失败:" + err.Error())
+ return
+ }
+ setFlash(w, "欢迎使用 gitcat,安装已完成 🎉")
+ http.Redirect(w, r, "/", http.StatusFound)
+}
+
+// ---------------------------------------------------------------- 登录
+
+func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
+ if userFrom(r.Context()) != nil {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ p := s.page(r, "登录")
+ p.Data["Next"] = safeNext(r.URL.Query().Get("next"))
+ s.render(w, r, "login", p)
+}
+
+func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
+ username := strings.TrimSpace(r.FormValue("username"))
+ password := r.FormValue("password")
+ next := safeNext(r.FormValue("next"))
+
+ fail := func(msg string) {
+ p := s.page(r, "登录")
+ p.Error = msg
+ p.Data["Next"] = next
+ p.Data["Username"] = username
+ w.WriteHeader(http.StatusUnauthorized)
+ s.render(w, r, "login", p)
+ }
+
+ u, err := s.st.UserByUsername(username)
+ if err != nil || !checkPassword(u.PasswordHash, password) {
+ time.Sleep(300 * time.Millisecond) // 轻微限速,缓解暴力破解
+ fail("用户名或密码不正确")
+ return
+ }
+ if u.IsDisabled {
+ fail("该账号已被管理员停用")
+ return
+ }
+ if err := s.startSession(w, r, u); err != nil {
+ fail("创建会话失败:" + err.Error())
+ return
+ }
+ _ = s.st.TouchLogin(u.ID)
+ if next == "" {
+ next = "/"
+ }
+ http.Redirect(w, r, next, http.StatusFound)
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ if sess := sessionFrom(r.Context()); sess != nil {
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ _ = s.st.DeleteSession(sess.ID)
+ }
+ http.SetCookie(w, &http.Cookie{Name: SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
+ http.Redirect(w, r, "/", http.StatusFound)
+}
+
+// startSession 创建会话并写入 Cookie。
+func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User) error {
+ id := randomToken(32)
+ csrf := randomToken(16)
+ if err := s.st.CreateSession(id, u.ID, csrf, sessionTTL, remoteIP(r), r.UserAgent()); err != nil {
+ return err
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: SessionCookieName,
+ Value: id,
+ Path: "/",
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: int(sessionTTL.Seconds()),
+ })
+ return nil
+}
+
+// safeNext 防止开放重定向。
+func safeNext(next string) string {
+ if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ return ""
+ }
+ return next
+}
+
+// basicAuthUser 解析 HTTP Basic 认证中的 gitcat 账号(用于 git push / clone)。
+func (s *Server) basicAuthUser(r *http.Request) *store.User {
+ username, password, ok := r.BasicAuth()
+ if !ok {
+ return nil
+ }
+ u, err := s.st.UserByUsername(username)
+ if err != nil || u.IsDisabled {
+ return nil
+ }
+ if !checkPassword(u.PasswordHash, password) {
+ return nil
+ }
+ return u
+}
+
+// challengeGit 要求客户端提供账号密码。
+func challengeGit(w http.ResponseWriter) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="gitcat: 请使用工作室账号登录", charset="UTF-8"`)
+ http.Error(w, "需要登录后操作", http.StatusUnauthorized)
+}
diff --git a/internal/server/githttp.go b/internal/server/githttp.go
new file mode 100644
index 0000000..b9f08d1
--- /dev/null
+++ b/internal/server/githttp.go
@@ -0,0 +1,334 @@
+package server
+
+import (
+ "compress/gzip"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+
+ "gitcat/internal/ai"
+ "gitcat/internal/gitx"
+ "gitcat/internal/store"
+)
+
+// handleGitAction 处理 Git Smart HTTP 协议请求。
+func (s *Server) handleGitAction(w http.ResponseWriter, r *http.Request, name, action string) {
+ if !s.st.Installed() {
+ http.Error(w, "gitcat 尚未完成安装", http.StatusServiceUnavailable)
+ return
+ }
+ repo, err := s.st.RepoByName(name)
+ if err != nil {
+ w.Header().Set("WWW-Authenticate", `Basic realm="gitcat"`)
+ http.Error(w, "项目不存在", http.StatusNotFound)
+ return
+ }
+ dir := s.repoPath(repo.Name)
+ if !gitx.IsBareRepo(dir) {
+ http.Error(w, "仓库数据缺失", http.StatusInternalServerError)
+ return
+ }
+
+ switch action {
+ case "info/refs":
+ service := r.URL.Query().Get("service")
+ switch service {
+ case "git-upload-pack":
+ s.serveInfoRefs(w, r, dir, "upload-pack")
+ case "git-receive-pack":
+ u := s.basicAuthUser(r)
+ if u == nil {
+ challengeGit(w)
+ return
+ }
+ if repo.IsArchived {
+ http.Error(w, "项目已归档,暂不接受推送", http.StatusForbidden)
+ return
+ }
+ s.serveInfoRefs(w, r, dir, "receive-pack")
+ default:
+ http.Error(w, "不支持的服务类型", http.StatusBadRequest)
+ }
+ case "git-upload-pack":
+ s.serveUploadPack(w, r, dir)
+ case "git-receive-pack":
+ u := s.basicAuthUser(r)
+ if u == nil {
+ challengeGit(w)
+ return
+ }
+ if repo.IsArchived {
+ http.Error(w, "项目已归档,暂不接受推送", http.StatusForbidden)
+ return
+ }
+ s.serveReceivePack(w, r, repo, dir, u)
+ default:
+ http.Error(w, "未知的 git 请求", http.StatusNotFound)
+ }
+}
+
+func gitEnv(r *http.Request) []string {
+ env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
+ if proto := r.Header.Get("Git-Protocol"); proto != "" {
+ env = append(env, "GIT_PROTOCOL="+proto)
+ }
+ return env
+}
+
+// serveInfoRefs 输出 refs 广告(clone / fetch / push 的握手阶段)。
+func (s *Server) serveInfoRefs(w http.ResponseWriter, r *http.Request, dir, service string) {
+ w.Header().Set("Content-Type", "application/x-git-"+service+"-advertisement")
+ w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
+ w.WriteHeader(http.StatusOK)
+
+ header := "# service=git-" + service + "\n"
+ fmt.Fprintf(w, "%04x%s0000", len(header)+4, header)
+
+ cmd := exec.Command("git", service, "--stateless-rpc", "--advertise-refs", dir)
+ cmd.Env = gitEnv(r)
+ cmd.Stdout = w
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ fmt.Fprintf(os.Stderr, "git %s --advertise-refs 失败: %v\n", service, err)
+ }
+}
+
+// serveUploadPack 处理 clone / fetch(公开可读)。
+func (s *Server) serveUploadPack(w http.ResponseWriter, r *http.Request, dir string) {
+ w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
+ w.Header().Set("Cache-Control", "no-cache")
+ body := requestBody(w, r)
+ if body == nil {
+ return
+ }
+ cmd := exec.Command("git", "upload-pack", "--stateless-rpc", dir)
+ cmd.Env = gitEnv(r)
+ cmd.Stdin = body
+ cmd.Stdout = w
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ fmt.Fprintf(os.Stderr, "git upload-pack 失败: %v\n", err)
+ }
+}
+
+// serveReceivePack 处理 push,并在完成后记录推送留痕。
+func (s *Server) serveReceivePack(w http.ResponseWriter, r *http.Request, repo *store.Repo, dir string, u *store.User) {
+ body := requestBody(w, r)
+ if body == nil {
+ return
+ }
+ w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
+ w.Header().Set("Cache-Control", "no-cache")
+
+ mu := s.repoLock(repo.Name)
+ mu.Lock()
+ before, _ := gitx.Refs(dir)
+
+ cmd := exec.Command("git", "receive-pack", "--stateless-rpc", dir)
+ cmd.Env = gitEnv(r)
+ cmd.Stdin = body
+ cmd.Stdout = w
+ cmd.Stderr = os.Stderr
+ err := cmd.Run()
+ if flusher, ok := w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ after, _ := gitx.Refs(dir)
+ mu.Unlock()
+
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "git receive-pack 失败: %v\n", err)
+ }
+ s.recordPush(repo, dir, u, before, after)
+}
+
+type refChange struct {
+ Ref string
+ Old string
+ New string
+}
+
+// recordPush 对比推送前后的 refs,落库留痕并触发 AI 摘要。
+func (s *Server) recordPush(repo *store.Repo, dir string, u *store.User, before, after map[string]string) {
+ names := map[string]bool{}
+ for k := range before {
+ names[k] = true
+ }
+ for k := range after {
+ names[k] = true
+ }
+ var changes []refChange
+ for name := range names {
+ oldSHA, newSHA := before[name], after[name]
+ if oldSHA == newSHA {
+ continue
+ }
+ if !strings.HasPrefix(name, "refs/heads/") && !strings.HasPrefix(name, "refs/tags/") {
+ continue
+ }
+ changes = append(changes, refChange{Ref: name, Old: oldSHA, New: newSHA})
+ }
+ if len(changes) == 0 {
+ return
+ }
+ // 分支优先,其次标签;保证顺序稳定
+ sortRefChanges(changes)
+
+ const maxCommits = 30
+ var details []gitx.PushCommit
+ totalCommits := 0
+ for _, ch := range changes {
+ if ch.New == "" {
+ continue
+ }
+ commits, err := gitx.CommitsBetween(dir, ch.Old, ch.New, maxCommits)
+ if err != nil {
+ continue
+ }
+ totalCommits += len(commits)
+ for _, c := range commits {
+ if len(details) < maxCommits {
+ details = append(details, c)
+ }
+ }
+ }
+
+ refLabel := strings.TrimPrefix(changes[0].Ref, "refs/heads/")
+ refLabel = strings.TrimPrefix(refLabel, "refs/tags/")
+ if len(changes) > 1 {
+ refLabel = fmt.Sprintf("%s 等 %d 个引用", refLabel, len(changes))
+ }
+
+ var uid *int64
+ actorName, actorDisplay := "unknown", "未知用户"
+ if u != nil {
+ uid = &u.ID
+ actorName = u.Username
+ actorDisplay = u.Display()
+ }
+ storeDetails := make([]store.PushCommit, 0, len(details))
+ for _, c := range details {
+ storeDetails = append(storeDetails, store.PushCommit{
+ SHA: c.SHA, Subject: c.Subject, Author: c.Author, When: c.When,
+ Additions: c.Additions, Deletions: c.Deletions, Files: c.Files,
+ })
+ }
+ push, err := s.st.CreatePush(repo.ID, uid, actorName, actorDisplay, refLabel, changes[0].Old, changes[0].New, storeDetails)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "写入推送记录失败: %v\n", err)
+ return
+ }
+ _ = s.st.TouchRepo(repo.ID)
+
+ // 摘要素材:变更统计 + 提交列表
+ var diffStat string
+ additions, deletions := 0, 0
+ for _, c := range details {
+ additions += c.Additions
+ deletions += c.Deletions
+ }
+ primary := changes[0]
+ // 只统计代码分支的变更规模,避免多个引用叠加导致统计失真
+ if primary.New != "" {
+ diffStat = gitx.DiffStat(dir, primary.Old, primary.New)
+ }
+ // 分支被删除时给一条说明
+ if primary.New == "" {
+ fallback := fmt.Sprintf("- 删除了分支 `%s`", strings.TrimPrefix(primary.Ref, "refs/heads/"))
+ _ = s.st.SetPushSummary(push.ID, fallback, "fallback")
+ return
+ }
+
+ go s.generateWhatsNew(push.ID, repo, primary, refLabel, details, diffStat, additions, deletions)
+}
+
+func sortRefChanges(changes []refChange) {
+ // refs/heads 优先于 refs/tags,删除的引用排在最后
+ weight := func(c refChange) int {
+ w := 0
+ if strings.HasPrefix(c.Ref, "refs/tags/") {
+ w += 2
+ }
+ if c.New == "" {
+ w++
+ }
+ return w
+ }
+ for i := 1; i < len(changes); i++ {
+ for j := i; j > 0 && weight(changes[j]) < weight(changes[j-1]); j-- {
+ changes[j], changes[j-1] = changes[j-1], changes[j]
+ }
+ }
+}
+
+// generateWhatsNew 调用 AI 生成更新说明,失败时降级为提交摘要。
+func (s *Server) generateWhatsNew(pushID int64, repo *store.Repo, primary refChange, refLabel string, details []gitx.PushCommit, diffStat string, additions, deletions int) {
+ fallback := fallbackSummary(refLabel, details, additions, deletions)
+
+ cfg := s.AIConfig()
+ if !repo.AIEnabled {
+ _ = s.st.SetPushSummary(pushID, fallback, "fallback")
+ return
+ }
+ if !cfg.Ready() {
+ _ = s.st.SetPushSummary(pushID, fallback, "fallback")
+ return
+ }
+
+ lines := make([]ai.CommitLine, 0, len(details))
+ for _, c := range details {
+ lines = append(lines, ai.CommitLine{SHA: c.SHA, Author: c.Author, Subject: c.Subject})
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ out, err := ai.GenerateWhatsNew(ctx, cfg, ai.SummaryInput{
+ RepoName: repo.Name,
+ Ref: refLabel,
+ Commits: lines,
+ DiffStat: diffStat,
+ Additions: additions,
+ Deletions: deletions,
+ })
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "AI 生成更新说明失败: %v\n", err)
+ _ = s.st.SetPushSummary(pushID, fallback, "failed")
+ return
+ }
+ _ = s.st.SetPushSummary(pushID, out, "done")
+}
+
+// fallbackSummary 在未接入 AI 时给出仍然可读的更新说明。
+func fallbackSummary(refLabel string, details []gitx.PushCommit, additions, deletions int) string {
+ var sb strings.Builder
+ if len(details) == 0 {
+ fmt.Fprintf(&sb, "- 更新了 `%s`", refLabel)
+ return sb.String()
+ }
+ fmt.Fprintf(&sb, "- 本次共 %d 个提交,代码变化 +%d / -%d\n", len(details), additions, deletions)
+ for i, c := range details {
+ if i >= 5 {
+ fmt.Fprintf(&sb, "- 其余 %d 个提交见下方提交列表\n", len(details)-i)
+ break
+ }
+ fmt.Fprintf(&sb, "- `%s` %s(%s)\n", c.Short(), c.Subject, c.Author)
+ }
+ return strings.TrimRight(sb.String(), "\n")
+}
+
+// requestBody 取出请求体并按需解压。
+func requestBody(w http.ResponseWriter, r *http.Request) io.Reader {
+ if strings.Contains(r.Header.Get("Content-Encoding"), "gzip") {
+ gz, err := gzip.NewReader(r.Body)
+ if err != nil {
+ http.Error(w, "无法解析请求体", http.StatusBadRequest)
+ return nil
+ }
+ return gz
+ }
+ return r.Body
+}
diff --git a/internal/server/home.go b/internal/server/home.go
new file mode 100644
index 0000000..7afe507
--- /dev/null
+++ b/internal/server/home.go
@@ -0,0 +1,121 @@
+package server
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "gitcat/internal/gitx"
+ "gitcat/internal/store"
+)
+
+var reservedRepoNames = map[string]bool{
+ "admin": true, "login": true, "logout": true, "install": true, "new": true,
+ "settings": true, "static": true, "media": true, "favicon.ico": true, "api": true,
+ "avatars": true, "assets": true, "help": true, "about": true,
+}
+
+func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
+ q := strings.TrimSpace(r.URL.Query().Get("q"))
+ repos, err := s.st.ListRepos(q)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取项目列表失败")
+ return
+ }
+ pushes, _ := s.st.RecentPushes(8)
+ users, repoCount, pushCount, _ := s.st.Counts()
+
+ p := s.page(r, "项目")
+ p.Active = "home"
+ p.Data["Repos"] = repos
+ p.Data["Query"] = q
+ p.Data["RecentPushes"] = pushes
+ p.Data["PushActors"] = s.pushActors(pushes)
+ p.Data["StatRepos"] = repoCount
+ p.Data["StatUsers"] = users
+ p.Data["StatPushes"] = pushCount
+ p.Data["HasRepos"] = len(repos) > 0
+ p.Data["Announcement"] = s.st.GetSetting("announcement")
+ s.render(w, r, "home", p)
+}
+
+func (s *Server) handleNewProjectPage(w http.ResponseWriter, r *http.Request) {
+ u := s.requireLogin(w, r)
+ if u == nil {
+ return
+ }
+ if !s.st.Installed() {
+ http.Redirect(w, r, "/install", http.StatusFound)
+ return
+ }
+ p := s.page(r, "新建项目")
+ p.Active = "new"
+ s.render(w, r, "new_project", p)
+}
+
+func (s *Server) handleNewProjectSubmit(w http.ResponseWriter, r *http.Request) {
+ u := s.requireLogin(w, r)
+ if u == nil {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请返回重试")
+ return
+ }
+ name := strings.TrimSpace(r.FormValue("name"))
+ desc := strings.TrimSpace(r.FormValue("description"))
+ name = strings.TrimSuffix(name, ".git")
+
+ fail := func(msg string) {
+ p := s.page(r, "新建项目")
+ p.Active = "new"
+ p.Error = msg
+ p.Data["Name"] = name
+ p.Data["Description"] = desc
+ w.WriteHeader(http.StatusBadRequest)
+ s.render(w, r, "new_project", p)
+ }
+
+ switch {
+ case name == "":
+ fail("请填写项目名称")
+ return
+ case !gitx.ValidRepoName(name):
+ fail("项目名称只能包含字母、数字、点、下划线、短横线,且需以字母或数字开头")
+ return
+ case reservedRepoNames[strings.ToLower(name)]:
+ fail("该名称为系统保留名,请换一个")
+ return
+ case len(desc) > 300:
+ fail("项目描述不能超过 300 个字符")
+ return
+ }
+ if _, err := s.st.RepoByName(name); err == nil {
+ fail("项目 " + name + " 已存在")
+ return
+ }
+
+ repo, err := s.st.CreateRepo(name, desc, u.ID)
+ if err != nil {
+ fail(err.Error())
+ return
+ }
+ if err := gitx.InitBare(s.repoPath(repo.Name), repo.DefaultBranch); err != nil {
+ _ = s.st.DeleteRepo(repo.ID)
+ fail("初始化 git 仓库失败:" + err.Error())
+ return
+ }
+ setFlash(w, fmt.Sprintf("项目 %s 创建成功,现在可以把代码推上来了", repo.Name))
+ http.Redirect(w, r, "/"+repo.Name, http.StatusFound)
+}
+
+// canManageRepo 判断当前用户能否管理该仓库(创建者或管理员)。
+func canManageRepo(u *store.User, repo *store.Repo) bool {
+ if u == nil {
+ return false
+ }
+ if u.IsAdmin {
+ return true
+ }
+ return repo.OwnerID != nil && *repo.OwnerID == u.ID
+}
diff --git a/internal/server/media.go b/internal/server/media.go
new file mode 100644
index 0000000..cf3a25d
--- /dev/null
+++ b/internal/server/media.go
@@ -0,0 +1,33 @@
+package server
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// removeRepoDir 删除仓库目录。
+func removeRepoDir(path string) error {
+ if !strings.HasSuffix(path, ".git") {
+ return os.ErrInvalid
+ }
+ return os.RemoveAll(path)
+}
+
+// handleAvatar 提供用户头像文件。
+func (s *Server) handleAvatar(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ if name == "" || name != filepath.Base(name) || strings.Contains(name, "..") {
+ http.NotFound(w, r)
+ return
+ }
+ full := filepath.Join(s.avatarDir, name)
+ if st, err := os.Stat(full); err != nil || st.IsDir() {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Cache-Control", "public, max-age=604800")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ http.ServeFile(w, r, full)
+}
diff --git a/internal/server/repo.go b/internal/server/repo.go
new file mode 100644
index 0000000..2db4d72
--- /dev/null
+++ b/internal/server/repo.go
@@ -0,0 +1,539 @@
+package server
+
+import (
+ "fmt"
+ "html/template"
+ "mime"
+ "net/http"
+ "path"
+ "strconv"
+ "strings"
+
+ "gitcat/internal/gitx"
+ "gitcat/internal/store"
+)
+
+const commitsPerPage = 30
+
+// loadRepo 读取 URL 中的仓库并校验磁盘数据。
+func (s *Server) loadRepo(w http.ResponseWriter, r *http.Request) (*store.Repo, string, bool) {
+ name := r.PathValue("repo")
+ repo, err := s.st.RepoByName(name)
+ if err != nil {
+ s.renderError(w, r, http.StatusNotFound, "项目 "+name+" 不存在")
+ return nil, "", false
+ }
+ dir := s.repoPath(repo.Name)
+ if !gitx.IsBareRepo(dir) {
+ s.renderError(w, r, http.StatusInternalServerError, "仓库数据缺失,请联系管理员")
+ return nil, "", false
+ }
+ return repo, dir, true
+}
+
+// resolveRefAndPath 解决分支名带斜杠时的歧义:优先匹配最长的 ref。
+func resolveRefAndPath(dir, ref string, rest []string) (string, string) {
+ candidates := []string{ref}
+ for _, seg := range rest {
+ candidates = append(candidates, candidates[len(candidates)-1]+"/"+seg)
+ }
+ for i := len(candidates) - 1; i >= 0; i-- {
+ cand := candidates[i]
+ for _, full := range []string{"refs/heads/" + cand, "refs/tags/" + cand, cand} {
+ if _, err := gitx.Resolve(dir, full); err == nil {
+ return cand, strings.Join(rest[i:], "/")
+ }
+ }
+ }
+ return ref, strings.Join(rest, "/")
+}
+
+func splitPath(p string) []string {
+ p = strings.Trim(p, "/")
+ if p == "" {
+ return nil
+ }
+ return strings.Split(p, "/")
+}
+
+// pickRef 选择展示用的 ref:优先指定值,其次默认分支,最后任意分支。
+func (s *Server) pickRef(dir string, repo *store.Repo, want string) string {
+ if want != "" {
+ if _, err := gitx.Resolve(dir, want); err == nil {
+ return want
+ }
+ }
+ if repo.DefaultBranch != "" {
+ if _, err := gitx.Resolve(dir, repo.DefaultBranch); err == nil {
+ return repo.DefaultBranch
+ }
+ }
+ if b := gitx.CurrentBranch(dir); b != "" {
+ if _, err := gitx.Resolve(dir, b); err == nil {
+ return b
+ }
+ }
+ branches, err := gitx.Branches(dir)
+ if err == nil && len(branches) > 0 {
+ return branches[0].Name
+ }
+ return repo.DefaultBranch
+}
+
+func cloneURL(r *http.Request, name string) string {
+ scheme := "http"
+ if r.TLS != nil {
+ scheme = "https"
+ }
+ if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" || proto == "http" {
+ scheme = proto
+ }
+ return fmt.Sprintf("%s://%s/%s.git", scheme, r.Host, name)
+}
+
+// repoCommon 填充项目页面通用的数据(侧边栏等)。
+func (s *Server) repoCommon(r *http.Request, repo *store.Repo, dir string, ref string) map[string]any {
+ u := userFrom(r.Context())
+ data := map[string]any{}
+ data["Repo"] = repo
+ data["Ref"] = ref
+ data["CanManage"] = canManageRepo(u, repo)
+ data["CloneURL"] = cloneURL(r, repo.Name)
+ data["CloneHTTP"] = cloneURL(r, repo.Name)
+
+ branches, _ := gitx.Branches(dir)
+ tags, _ := gitx.Tags(dir)
+ data["Branches"] = branches
+ data["Tags"] = tags
+ data["TagCount"] = len(tags)
+ data["BranchCount"] = len(branches)
+
+ pushes, _ := s.st.PushesByRepo(repo.ID, 6)
+ data["RecentPushes"] = pushes
+ data["LatestPush"], _ = s.st.LatestPush(repo.ID)
+
+ data["IsEmpty"] = !gitx.HasCommits(dir)
+
+ // 统计信息在这里统一填充:repo_home 模板的“关于”卡片在空仓库与目录页
+ // 也要渲染这些字段,缺任意一项都会让模板执行中断。
+ data["CommitCount"] = 0
+ data["FileCount"] = 0
+ data["SizeText"] = humanSize(gitx.RepoSize(dir))
+ if data["IsEmpty"] == false {
+ data["CommitCount"] = gitx.CountCommits(dir, ref)
+ data["FileCount"] = gitx.FileCount(dir, ref)
+ }
+ data["Languages"] = gitx.Languages(dir, ref, 6)
+ data["Contributors"] = gitx.Contributors(dir, ref, 10)
+ if commits, err := gitx.Log(dir, ref, 5, 0); err == nil {
+ data["LatestCommits"] = commits
+ }
+
+ if c, ok := gitx.LastCommitForPath(dir, ref, ""); ok {
+ data["LastCommit"] = c
+ }
+ return data
+}
+
+func (s *Server) handleRepoHome(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ p := s.page(r, repo.Name)
+ p.Active = "repo"
+ ref := s.pickRef(dir, repo, r.URL.Query().Get("ref"))
+
+ data := s.repoCommon(r, repo, dir, ref)
+ p.Data = data
+
+ if data["IsEmpty"] == true {
+ s.render(w, r, "repo_home", p)
+ return
+ }
+
+ entries, err := gitx.Tree(dir, ref, "")
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取文件列表失败:"+err.Error())
+ return
+ }
+ lastCommits := gitx.LastCommits(dir, ref, "", 80)
+ type dirEntry struct {
+ gitx.TreeEntry
+ LastCommit gitx.Commit
+ HasCommit bool
+ }
+ var list []dirEntry
+ for _, e := range entries {
+ de := dirEntry{TreeEntry: e}
+ if c, ok := lastCommitFor(lastCommits, e); ok {
+ de.LastCommit = c
+ de.HasCommit = true
+ }
+ list = append(list, de)
+ }
+ p.Data["Entries"] = list
+ p.Data["Path"] = ""
+ p.Data["Breadcrumbs"] = nil
+
+ if readmeName, content, found := gitx.Readme(dir, ref); found {
+ isMarkdown := strings.HasSuffix(strings.ToLower(readmeName), ".md") || strings.HasSuffix(strings.ToLower(readmeName), ".markdown")
+ p.Data["ReadmeName"] = readmeName
+ p.Data["ReadmeIsMarkdown"] = isMarkdown
+ if isMarkdown {
+ // 必须标记为 template.HTML,否则会被模板再次转义
+ p.Data["ReadmeHTML"] = template.HTML(renderMarkdown(string(content)))
+ } else {
+ p.Data["ReadmeText"] = string(content)
+ }
+ }
+
+ s.render(w, r, "repo_home", p)
+}
+
+// lastCommitFor 查找列表条目对应的最近一次提交。
+// 目录本身在 git 里没有提交记录,回退到该目录下最新的一条提交,
+// 这样文件夹那一行也能显示是谁改的。
+func lastCommitFor(lastCommits map[string]gitx.Commit, e gitx.TreeEntry) (gitx.Commit, bool) {
+ if c, ok := lastCommits[e.Path]; ok {
+ return c, true
+ }
+ if !e.IsDir() {
+ return gitx.Commit{}, false
+ }
+ prefix := strings.TrimSuffix(e.Path, "/") + "/"
+ best := gitx.Commit{}
+ found := false
+ for p, c := range lastCommits {
+ if !strings.HasPrefix(p, prefix) {
+ continue
+ }
+ if !found || c.When.After(best.When) {
+ best, found = c, true
+ }
+ }
+ return best, found
+}
+
+func (s *Server) handleRepoTree(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ ref, sub := resolveRefAndPath(dir, r.PathValue("ref"), splitPath(r.PathValue("path")))
+ if _, err := gitx.Resolve(dir, ref); err != nil {
+ s.renderError(w, r, http.StatusNotFound, "找不到引用 "+ref)
+ return
+ }
+ if sub != "" && !gitx.TreeExists(dir, ref, sub) {
+ s.renderError(w, r, http.StatusNotFound, "路径 "+sub+" 不存在")
+ return
+ }
+ entries, err := gitx.Tree(dir, ref, sub)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取目录失败:"+err.Error())
+ return
+ }
+ lastCommits := gitx.LastCommits(dir, ref, sub, 80)
+ type dirEntry struct {
+ gitx.TreeEntry
+ LastCommit gitx.Commit
+ HasCommit bool
+ }
+ var list []dirEntry
+ for _, e := range entries {
+ de := dirEntry{TreeEntry: e}
+ if c, ok := lastCommitFor(lastCommits, e); ok {
+ de.LastCommit = c
+ de.HasCommit = true
+ }
+ list = append(list, de)
+ }
+ p := s.page(r, repo.Name+" · "+sub)
+ p.Active = "repo"
+ p.Data = s.repoCommon(r, repo, dir, ref)
+ p.Data["Entries"] = list
+ p.Data["Path"] = sub
+ p.Data["Breadcrumbs"] = breadcrumbs(sub)
+ if sub != "" {
+ if c, ok := gitx.LastCommitForPath(dir, ref, sub); ok {
+ p.Data["LastCommit"] = c
+ }
+ }
+ s.render(w, r, "repo_home", p)
+}
+
+type crumb struct {
+ Name string
+ Path string
+}
+
+func breadcrumbs(sub string) []crumb {
+ var out []crumb
+ parts := splitPath(sub)
+ for i, part := range parts {
+ out = append(out, crumb{Name: part, Path: strings.Join(parts[:i+1], "/")})
+ }
+ return out
+}
+
+func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ ref, file := resolveRefAndPath(dir, r.PathValue("ref"), splitPath(r.PathValue("path")))
+ if file == "" {
+ http.Redirect(w, r, "/"+repo.Name, http.StatusFound)
+ return
+ }
+ if !gitx.TreeExists(dir, ref, file) {
+ s.renderError(w, r, http.StatusNotFound, "文件 "+file+" 不存在")
+ return
+ }
+ content, err := gitx.Blob(dir, ref, file)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取文件失败:"+err.Error())
+ return
+ }
+ size := gitx.BlobSize(dir, ref, file)
+ isBinary := gitx.IsBinary(content)
+ tooLarge := size > 2<<20
+
+ p := s.page(r, repo.Name+" · "+path.Base(file))
+ p.Active = "repo"
+ p.Data = s.repoCommon(r, repo, dir, ref)
+ p.Data["Path"] = file
+ p.Data["FileName"] = path.Base(file)
+ p.Data["Breadcrumbs"] = breadcrumbs(file)
+ p.Data["BlobSize"] = humanSize(size)
+ p.Data["IsBinary"] = isBinary
+ p.Data["TooLarge"] = tooLarge
+ ext := strings.ToLower(path.Ext(file))
+ p.Data["IsImage"] = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".svg": true, ".ico": true, ".bmp": true}[ext]
+ if !isBinary && !tooLarge {
+ text := string(content)
+ lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
+ if len(lines) > 5000 {
+ lines = lines[:5000]
+ p.Data["TruncatedLines"] = true
+ }
+ p.Data["Lines"] = lines
+ }
+ p.Data["LineCount"] = strings.Count(string(content), "\n") + 1
+ lastCommits := gitx.LastCommits(dir, ref, file, 1)
+ if c, ok := lastCommits[file]; ok {
+ p.Data["LastCommit"] = c
+ }
+ s.render(w, r, "repo_blob", p)
+}
+
+func (s *Server) handleRepoRaw(w http.ResponseWriter, r *http.Request) {
+ _, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ ref, file := resolveRefAndPath(dir, r.PathValue("ref"), splitPath(r.PathValue("path")))
+ content, err := gitx.Blob(dir, ref, file)
+ if err != nil {
+ http.Error(w, "文件不存在", http.StatusNotFound)
+ return
+ }
+ ctype := mime.TypeByExtension(strings.ToLower(path.Ext(file)))
+ if ctype == "" {
+ if gitx.IsBinary(content) {
+ ctype = "application/octet-stream"
+ } else {
+ ctype = "text/plain; charset=utf-8"
+ }
+ }
+ if strings.HasPrefix(ctype, "text/") && !strings.Contains(ctype, "charset") {
+ ctype += "; charset=utf-8"
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Header().Set("Content-Disposition", "inline")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Write(content)
+}
+
+func (s *Server) handleRepoCommits(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ ref := s.pickRef(dir, repo, r.PathValue("ref"))
+ // 空仓库还没有任何提交,HEAD 指向的分支尚不存在。仓库主页照样会给出
+ // “提交历史”入口,所以这里渲染空状态,而不是把它变成 404。
+ empty := !gitx.HasCommits(dir)
+ if !empty {
+ if _, err := gitx.Resolve(dir, ref); err != nil {
+ s.renderError(w, r, http.StatusNotFound, "找不到引用 "+ref)
+ return
+ }
+ }
+ pageNo, _ := strconv.Atoi(r.URL.Query().Get("page"))
+ if pageNo < 1 {
+ pageNo = 1
+ }
+ var commits []gitx.Commit
+ total := 0
+ if !empty {
+ var err error
+ commits, err = gitx.Log(dir, ref, commitsPerPage, (pageNo-1)*commitsPerPage)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取提交历史失败:"+err.Error())
+ return
+ }
+ total = gitx.CountCommits(dir, ref)
+ }
+ p := s.page(r, repo.Name+" · 提交历史")
+ p.Active = "repo"
+ p.Data = s.repoCommon(r, repo, dir, ref)
+ p.Data["Commits"] = commits
+ p.Data["Page"] = pageNo
+ p.Data["Total"] = total
+ p.Data["HasPrev"] = pageNo > 1
+ p.Data["HasNext"] = pageNo*commitsPerPage < total
+ p.Data["PrevPage"] = pageNo - 1
+ p.Data["NextPage"] = pageNo + 1
+ s.render(w, r, "repo_commits", p)
+}
+
+func (s *Server) handleRepoCommit(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ sha := r.PathValue("sha")
+ detail, err := gitx.Show(dir, sha)
+ if err != nil {
+ s.renderError(w, r, http.StatusNotFound, "找不到提交 "+sha)
+ return
+ }
+ p := s.page(r, repo.Name+" · "+detail.Short())
+ p.Active = "repo"
+ p.Data = s.repoCommon(r, repo, dir, s.pickRef(dir, repo, ""))
+ p.Data["Commit"] = detail
+ if len(detail.Parents) > 0 {
+ p.Data["Parent"] = detail.Parents[0]
+ }
+ additions, deletions := 0, 0
+ for _, f := range detail.Files {
+ additions += f.Additions
+ deletions += f.Deletions
+ }
+ p.Data["Additions"] = additions
+ p.Data["Deletions"] = deletions
+ p.Data["FileChanged"] = len(detail.Files)
+ s.render(w, r, "repo_commit", p)
+}
+
+func (s *Server) handleRepoBranches(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ ref := s.pickRef(dir, repo, "")
+ p := s.page(r, repo.Name+" · 分支与标签")
+ p.Active = "repo"
+ p.Data = s.repoCommon(r, repo, dir, ref)
+ s.render(w, r, "repo_branches", p)
+}
+
+func (s *Server) handleRepoActivity(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ ref := s.pickRef(dir, repo, "")
+ pushes, _ := s.st.PushesByRepo(repo.ID, 60)
+ p := s.page(r, repo.Name+" · 动态")
+ p.Active = "repo"
+ p.Data = s.repoCommon(r, repo, dir, ref)
+ p.Data["Pushes"] = pushes
+ p.Data["PushActors"] = s.pushActors(pushes)
+ s.render(w, r, "repo_activity", p)
+}
+
+func (s *Server) handleRepoSettingsPage(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ u := userFrom(r.Context())
+ if !canManageRepo(u, repo) {
+ s.renderError(w, r, http.StatusForbidden, "只有项目创建者或管理员可以修改设置")
+ return
+ }
+ p := s.page(r, repo.Name+" · 设置")
+ p.Active = "repo"
+ p.Data = s.repoCommon(r, repo, dir, s.pickRef(dir, repo, ""))
+ s.render(w, r, "repo_settings", p)
+}
+
+func (s *Server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) {
+ repo, dir, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ u := userFrom(r.Context())
+ if !canManageRepo(u, repo) {
+ s.renderError(w, r, http.StatusForbidden, "只有项目创建者或管理员可以修改设置")
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ desc := strings.TrimSpace(r.FormValue("description"))
+ if len(desc) > 300 {
+ s.renderError(w, r, http.StatusBadRequest, "项目描述不能超过 300 个字符")
+ return
+ }
+ defaultBranch := strings.TrimSpace(r.FormValue("default_branch"))
+ if defaultBranch != "" {
+ if _, err := gitx.Resolve(dir, defaultBranch); err != nil {
+ s.renderError(w, r, http.StatusBadRequest, "默认分支不存在")
+ return
+ }
+ repo.DefaultBranch = defaultBranch
+ _ = gitx.SetDefaultBranch(dir, defaultBranch)
+ }
+ repo.Description = desc
+ repo.AIEnabled = r.FormValue("ai_enabled") == "on"
+ repo.IsArchived = r.FormValue("is_archived") == "on"
+ if err := s.st.UpdateRepo(repo); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
+ return
+ }
+ setFlash(w, "项目设置已保存")
+ http.Redirect(w, r, "/"+repo.Name+"/settings", http.StatusFound)
+}
+
+func (s *Server) handleRepoDelete(w http.ResponseWriter, r *http.Request) {
+ repo, _, ok := s.loadRepo(w, r)
+ if !ok {
+ return
+ }
+ u := userFrom(r.Context())
+ if !canManageRepo(u, repo) {
+ s.renderError(w, r, http.StatusForbidden, "没有删除权限")
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ if strings.TrimSpace(r.FormValue("confirm")) != repo.Name {
+ setFlash(w, "删除失败:请输入完整的项目名以确认")
+ http.Redirect(w, r, "/"+repo.Name+"/settings", http.StatusFound)
+ return
+ }
+ if err := s.st.DeleteRepo(repo.ID); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "删除失败:"+err.Error())
+ return
+ }
+ _ = removeRepoDir(s.repoPath(repo.Name))
+ setFlash(w, fmt.Sprintf("项目 %s 已删除", repo.Name))
+ http.Redirect(w, r, "/", http.StatusFound)
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..c424f0e
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,638 @@
+// Package server 实现 gitcat 的 HTTP 服务:网页界面、Git Smart HTTP 与后台管理。
+package server
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/renderer/html"
+
+ "gitcat/internal/ai"
+ "gitcat/internal/gitx"
+ "gitcat/internal/store"
+ "gitcat/webroot"
+)
+
+// Config 是服务运行配置。
+type Config struct {
+ Addr string
+ DataDir string
+ Version string
+}
+
+// Server 持有全部运行时依赖。
+type Server struct {
+ cfg Config
+ st *store.Store
+ mux *http.ServeMux
+ tmpl map[string]*template.Template
+ repoRoot string
+ avatarDir string
+ static http.Handler
+ locks sync.Map // repo name -> *sync.Mutex
+ md goldmark.Markdown
+}
+
+// New 构造服务器。
+func New(cfg Config, st *store.Store) (*Server, error) {
+ repoRoot := filepath.Join(cfg.DataDir, "repos")
+ avatarDir := filepath.Join(cfg.DataDir, "avatars")
+ for _, dir := range []string{cfg.DataDir, repoRoot, avatarDir} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, err
+ }
+ }
+ s := &Server{
+ cfg: cfg,
+ st: st,
+ mux: http.NewServeMux(),
+ tmpl: map[string]*template.Template{},
+ repoRoot: repoRoot,
+ avatarDir: avatarDir,
+ md: goldmark.New(
+ goldmark.WithExtensions(extension.GFM, extension.Table, extension.Strikethrough, extension.TaskList),
+ goldmark.WithRendererOptions(html.WithHardWraps()),
+ ),
+ }
+ if err := s.buildTemplates(); err != nil {
+ return nil, err
+ }
+ staticFS, err := fs.Sub(webroot.FS, "static")
+ if err != nil {
+ return nil, err
+ }
+ s.static = http.StripPrefix("/static/", cacheControl(http.FileServer(http.FS(staticFS))))
+ s.routes()
+ return s, nil
+}
+
+// ---------------------------------------------------------------- 模板
+
+var tmplFuncs = template.FuncMap{
+ "dict": func(values ...any) map[string]any {
+ m := map[string]any{}
+ for i := 0; i+1 < len(values); i += 2 {
+ key, _ := values[i].(string)
+ m[key] = values[i+1]
+ }
+ return m
+ },
+ "add": func(a, b int) int { return a + b },
+ "sub": func(a, b int) int { return a - b },
+ "short": func(s string) string {
+ if len(s) > 7 {
+ return s[:7]
+ }
+ return s
+ },
+ "md": func(s string) template.HTML { return template.HTML(renderMarkdown(s)) },
+ "humanSize": func(n int64) string { return humanSize(n) },
+ "num": func(n int) string { return humanInt(n) },
+ "timeago": func(t time.Time) string { return timeAgo(t) },
+ "datetime": func(t time.Time) string {
+ if t.IsZero() {
+ return "-"
+ }
+ return t.Local().Format("2006-01-02 15:04")
+ },
+ "date": func(t time.Time) string {
+ if t.IsZero() {
+ return "-"
+ }
+ return t.Local().Format("2006-01-02")
+ },
+ "unix": func(ts int64) time.Time { return time.Unix(ts, 0) },
+ "avatar": func(u *store.User) string {
+ if u == nil || u.Avatar == "" {
+ return ""
+ }
+ return "/media/avatars/" + u.Avatar
+ },
+ "initial": func(s string) string {
+ for _, r := range s {
+ return strings.ToUpper(string(r))
+ }
+ return "?"
+ },
+ "percent": func(f float64) string { return fmt.Sprintf("%.1f", f) },
+ "both": func(a, b string) string { return a + b },
+ "deref": func(p *int64) int64 {
+ if p == nil {
+ return 0
+ }
+ return *p
+ },
+ "ava": func(s string) string {
+ h := 0
+ for _, r := range s {
+ h = (h*31 + int(r)) % 9973
+ }
+ return "g" + strconv.Itoa(h%6)
+ },
+ "oneline": func(s string) string {
+ r := strings.NewReplacer("\r", " ", "*", "", "`", "", "#", "",
+ "[", " ", "]", " ", "(", " ", ")", " ")
+ var parts []string
+ for _, l := range strings.Split(r.Replace(s), "\n") {
+ l = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(l), "- "))
+ if l != "" {
+ parts = append(parts, l)
+ }
+ }
+ out := strings.Join(parts, " · ")
+ runes := []rune(out)
+ if len(runes) > 96 {
+ out = string(runes[:96]) + "…"
+ }
+ return out
+ },
+ "hasPrefix": func(s, p string) bool { return strings.HasPrefix(s, p) },
+}
+
+func (s *Server) buildTemplates() error {
+ pages := []string{
+ "error", "install", "login", "home", "new_project",
+ "repo_home", "repo_commits", "repo_commit", "repo_blob", "repo_branches", "repo_activity", "repo_settings",
+ "profile",
+ "admin_dashboard", "admin_users", "admin_user", "admin_repos", "admin_repo", "admin_ai", "admin_settings", "admin_activity",
+ }
+ for _, page := range pages {
+ t, err := template.New("layout.html").Funcs(tmplFuncs).ParseFS(webroot.FS,
+ "templates/layout.html", "templates/"+page+".html")
+ if err != nil {
+ return fmt.Errorf("解析模板 %s: %w", page, err)
+ }
+ s.tmpl[page] = t
+ }
+ return nil
+}
+
+func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, p *Page) {
+ t, ok := s.tmpl[page]
+ if !ok {
+ http.Error(w, "模板不存在: "+page, http.StatusInternalServerError)
+ return
+ }
+ p.SiteName = s.siteName()
+ p.Version = s.cfg.Version
+ p.User = userFrom(r.Context())
+ if sess := sessionFrom(r.Context()); sess != nil {
+ p.CSRF = sess.CSRF
+ }
+ if p.Flash == "" {
+ p.Flash = takeFlash(w, r)
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ // 先渲染到缓冲区:模板一旦执行到一半出错,直接写响应会留下被截断的
+ // “半个页面”且状态码仍是 200,问题很难被发现。
+ var buf bytes.Buffer
+ if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
+ fmt.Fprintf(os.Stderr, "渲染 %s 出错: %v\n", page, err)
+ http.Error(w, "页面渲染失败,请稍后重试", http.StatusInternalServerError)
+ return
+ }
+ _, _ = w.Write(buf.Bytes())
+}
+
+func (s *Server) siteName() string {
+ if v := s.st.GetSetting("site_name"); v != "" {
+ return v
+ }
+ return "gitcat"
+}
+
+// Page 是所有页面共用的模板数据。
+type Page struct {
+ Title string
+ SiteName string
+ Version string
+ User *store.User
+ CSRF string
+ Flash string
+ Error string
+ Active string
+ Data map[string]any
+}
+
+func (s *Server) page(r *http.Request, title string) *Page {
+ return &Page{Title: title, Data: map[string]any{}}
+}
+
+// ---------------------------------------------------------------- 路由
+
+func (s *Server) routes() {
+ m := s.mux
+
+ m.HandleFunc("GET /{$}", s.handleHome)
+ m.HandleFunc("GET /install", s.handleInstallPage)
+ m.HandleFunc("POST /install", s.handleInstallSubmit)
+ m.HandleFunc("GET /login", s.handleLoginPage)
+ m.HandleFunc("POST /login", s.handleLoginSubmit)
+ m.HandleFunc("POST /logout", s.handleLogout)
+ m.HandleFunc("GET /new", s.handleNewProjectPage)
+ m.HandleFunc("POST /new", s.handleNewProjectSubmit)
+ m.HandleFunc("GET /settings", s.handleProfilePage)
+ m.HandleFunc("POST /settings/profile", s.handleProfileSave)
+ m.HandleFunc("POST /settings/avatar", s.handleAvatarUpload)
+ m.HandleFunc("POST /settings/password", s.handlePasswordChange)
+
+ // 项目页面
+ m.HandleFunc("GET /{repo}", s.handleRepoHome)
+ m.HandleFunc("GET /{repo}/tree/{ref}/{path...}", s.handleRepoTree)
+ m.HandleFunc("GET /{repo}/blob/{ref}/{path...}", s.handleRepoBlob)
+ m.HandleFunc("GET /{repo}/raw/{ref}/{path...}", s.handleRepoRaw)
+ m.HandleFunc("GET /{repo}/commits/{ref}", s.handleRepoCommits)
+ m.HandleFunc("GET /{repo}/commit/{sha}", s.handleRepoCommit)
+ m.HandleFunc("GET /{repo}/branches", s.handleRepoBranches)
+ m.HandleFunc("GET /{repo}/activity", s.handleRepoActivity)
+ m.HandleFunc("GET /{repo}/settings", s.handleRepoSettingsPage)
+ m.HandleFunc("POST /{repo}/settings", s.handleRepoSettingsSave)
+ m.HandleFunc("POST /{repo}/delete", s.handleRepoDelete)
+
+ // 管理后台
+ m.HandleFunc("GET /admin", s.handleAdminDashboard)
+ m.HandleFunc("GET /admin/users", s.handleAdminUsers)
+ m.HandleFunc("POST /admin/users", s.handleAdminUserCreate)
+ m.HandleFunc("GET /admin/users/{id}", s.handleAdminUserDetail)
+ m.HandleFunc("POST /admin/users/{id}", s.handleAdminUserSave)
+ m.HandleFunc("POST /admin/users/{id}/delete", s.handleAdminUserDelete)
+ m.HandleFunc("GET /admin/repos", s.handleAdminRepos)
+ m.HandleFunc("GET /admin/repos/{id}", s.handleAdminRepoDetail)
+ m.HandleFunc("POST /admin/repos/{id}", s.handleAdminRepoSave)
+ m.HandleFunc("POST /admin/repos/{id}/delete", s.handleAdminRepoDelete)
+ m.HandleFunc("GET /admin/ai", s.handleAdminAIPage)
+ m.HandleFunc("POST /admin/ai", s.handleAdminAISave)
+ m.HandleFunc("POST /admin/ai/test", s.handleAdminAITest)
+ m.HandleFunc("GET /admin/settings", s.handleAdminSettingsPage)
+ m.HandleFunc("POST /admin/settings", s.handleAdminSettingsSave)
+ m.HandleFunc("GET /admin/activity", s.handleAdminActivity)
+
+ // 静态资源在 Handler 中单独处理(避免与 /{repo}/tree/... 通配路由冲突)
+ m.HandleFunc("GET /media/avatars/{name}", s.handleAvatar)
+ m.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
+ http.ServeFileFS(w, r, webroot.FS, "static/favicon.svg")
+ })
+}
+
+func cacheControl(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, ".css") || strings.HasSuffix(r.URL.Path, ".js") ||
+ strings.HasSuffix(r.URL.Path, ".svg") || strings.HasSuffix(r.URL.Path, ".png") {
+ w.Header().Set("Cache-Control", "public, max-age=300")
+ }
+ h.ServeHTTP(w, r)
+ })
+}
+
+// Handler 组装完整中间件链。
+func (s *Server) Handler() http.Handler {
+ var h http.Handler = s.mux
+ h = s.withSession(h)
+ h = s.withInstallCheck(h)
+ h = s.withSecurityHeaders(h)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // 静态资源:/static/...
+ if strings.HasPrefix(r.URL.Path, "/static/") {
+ s.withSecurityHeaders(s.static).ServeHTTP(w, r)
+ return
+ }
+ // Git Smart HTTP:/{repo}.git/...
+ if name, action, ok := parseGitPath(r.URL.Path); ok {
+ s.withSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.handleGitAction(w, r, name, action)
+ })).ServeHTTP(w, r)
+ return
+ }
+ h.ServeHTTP(w, r)
+ })
+}
+
+func parseGitPath(p string) (name, action string, ok bool) {
+ p = strings.TrimPrefix(p, "/")
+ parts := strings.Split(p, "/")
+ if len(parts) < 2 {
+ return "", "", false
+ }
+ if !strings.HasSuffix(parts[0], ".git") {
+ return "", "", false
+ }
+ name = strings.TrimSuffix(parts[0], ".git")
+ if name == "" {
+ return "", "", false
+ }
+ action = strings.Join(parts[1:], "/")
+ switch action {
+ case "info/refs", "git-upload-pack", "git-receive-pack":
+ return name, action, true
+ }
+ return "", "", false
+}
+
+// ---------------------------------------------------------------- 中间件
+
+type ctxKey int
+
+const (
+ ctxUser ctxKey = iota
+ ctxSession
+)
+
+// SessionCookieName 是会话 Cookie 名。
+const SessionCookieName = "gitcat_session"
+
+func userFrom(ctx context.Context) *store.User {
+ u, _ := ctx.Value(ctxUser).(*store.User)
+ return u
+}
+
+func sessionFrom(ctx context.Context) *store.Session {
+ sess, _ := ctx.Value(ctxSession).(*store.Session)
+ return sess
+}
+
+func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "same-origin")
+ w.Header().Set("X-Frame-Options", "SAMEORIGIN")
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (s *Server) withSession(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(SessionCookieName)
+ if err == nil && c.Value != "" {
+ sess, u, err := s.st.SessionByID(c.Value)
+ switch {
+ case err == nil:
+ ctx := context.WithValue(r.Context(), ctxSession, sess)
+ ctx = context.WithValue(ctx, ctxUser, u)
+ r = r.WithContext(ctx)
+ case errors.Is(err, store.ErrNotFound):
+ // 会话确实失效(过期、被删除或账号停用)才清 Cookie。
+ http.SetCookie(w, &http.Cookie{Name: SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
+ default:
+ // 数据库临时故障等情况保留 Cookie,避免把用户无谓地踢下线。
+ fmt.Fprintf(os.Stderr, "读取会话失败: %v\n", err)
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (s *Server) withInstallCheck(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if s.st.Installed() || strings.HasPrefix(r.URL.Path, "/install") ||
+ strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/favicon.ico" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ http.Redirect(w, r, "/install", http.StatusFound)
+ })
+}
+
+// requireLogin 返回已登录用户;未登录时按需跳转或返回 401。
+func (s *Server) requireLogin(w http.ResponseWriter, r *http.Request) *store.User {
+ u := userFrom(r.Context())
+ if u == nil {
+ if strings.HasPrefix(r.URL.Path, "/admin") {
+ http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
+ return nil
+ }
+ http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
+ return nil
+ }
+ return u
+}
+
+func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User {
+ u := s.requireLogin(w, r)
+ if u == nil {
+ return nil
+ }
+ if !u.IsAdmin {
+ s.renderError(w, r, http.StatusForbidden, "需要管理员权限")
+ return nil
+ }
+ return u
+}
+
+func (s *Server) renderError(w http.ResponseWriter, r *http.Request, code int, msg string) {
+ w.WriteHeader(code)
+ p := s.page(r, fmt.Sprintf("%d", code))
+ p.Error = msg
+ p.Data["Code"] = code
+ p.Data["Message"] = msg
+ s.render(w, r, "error", p)
+}
+
+// checkCSRF 校验表单里的 csrf 令牌。
+func (s *Server) checkCSRF(r *http.Request) bool {
+ sess := sessionFrom(r.Context())
+ if sess == nil {
+ return false
+ }
+ token := r.FormValue("csrf")
+ return token != "" && token == sess.CSRF
+}
+
+// ---------------------------------------------------------------- 小工具
+
+func randomToken(n int) string {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return strconv.FormatInt(time.Now().UnixNano(), 36)
+ }
+ return hex.EncodeToString(b)
+}
+
+func setFlash(w http.ResponseWriter, msg string) {
+ http.SetCookie(w, &http.Cookie{Name: "gitcat_flash", Value: url.QueryEscape(msg), Path: "/", MaxAge: 30, HttpOnly: true})
+}
+
+func takeFlash(w http.ResponseWriter, r *http.Request) string {
+ c, err := r.Cookie("gitcat_flash")
+ if err != nil || c.Value == "" {
+ return ""
+ }
+ http.SetCookie(w, &http.Cookie{Name: "gitcat_flash", Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
+ msg, err := url.QueryUnescape(c.Value)
+ if err != nil {
+ return ""
+ }
+ return msg
+}
+
+func humanSize(n int64) string {
+ units := []string{"B", "KB", "MB", "GB", "TB"}
+ f := float64(n)
+ i := 0
+ for f >= 1024 && i < len(units)-1 {
+ f /= 1024
+ i++
+ }
+ if i == 0 {
+ return fmt.Sprintf("%d %s", n, units[0])
+ }
+ return fmt.Sprintf("%.1f %s", f, units[i])
+}
+
+func humanInt(n int) string {
+ s := strconv.Itoa(n)
+ if len(s) <= 3 {
+ return s
+ }
+ var out []byte
+ for i, c := range []byte(s) {
+ if i > 0 && (len(s)-i)%3 == 0 {
+ out = append(out, ',')
+ }
+ out = append(out, c)
+ }
+ return string(out)
+}
+
+func timeAgo(t time.Time) string {
+ if t.IsZero() {
+ return "-"
+ }
+ d := time.Since(t)
+ if d < 0 {
+ d = 0
+ }
+ switch {
+ case d < time.Minute:
+ return "刚刚"
+ case d < time.Hour:
+ return fmt.Sprintf("%d 分钟前", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%d 小时前", int(d.Hours()))
+ case d < 30*24*time.Hour:
+ return fmt.Sprintf("%d 天前", int(d.Hours()/24))
+ case d < 365*24*time.Hour:
+ return fmt.Sprintf("%d 个月前", int(d.Hours()/24/30))
+ default:
+ return fmt.Sprintf("%d 年前", int(d.Hours()/24/365))
+ }
+}
+
+func renderMarkdown(src string) string {
+ var buf strings.Builder
+ md := goldmark.New(
+ goldmark.WithExtensions(extension.GFM, extension.Table, extension.Strikethrough, extension.TaskList),
+ )
+ if err := md.Convert([]byte(src), &buf); err != nil {
+ return template.HTMLEscapeString(src)
+ }
+ return buf.String()
+}
+
+// remoteIP 取出客户端 IP。
+func remoteIP(r *http.Request) string {
+ if v := r.Header.Get("X-Forwarded-For"); v != "" {
+ return strings.TrimSpace(strings.Split(v, ",")[0])
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+// repoPath 返回仓库在磁盘上的裸仓库路径。
+func (s *Server) repoPath(name string) string {
+ return filepath.Join(s.repoRoot, name+".git")
+}
+
+// repoLock 返回仓库级别的互斥锁。
+func (s *Server) repoLock(name string) *sync.Mutex {
+ v, _ := s.locks.LoadOrStore(strings.ToLower(name), &sync.Mutex{})
+ return v.(*sync.Mutex)
+}
+
+// repoLocked 在持有仓库锁的情况下执行 f。
+func (s *Server) repoLocked(name string, f func() error) error {
+ mu := s.repoLock(name)
+ mu.Lock()
+ defer mu.Unlock()
+ return f()
+}
+
+// AIConfig 读取后台配置的 AI 参数。
+func (s *Server) AIConfig() ai.Config {
+ set := s.st.Settings()
+ return ai.Config{
+ Enabled: set["ai_enabled"] == "1",
+ BaseURL: set["ai_base_url"],
+ APIKey: set["ai_api_key"],
+ Model: set["ai_model"],
+ Language: set["ai_language"],
+ }
+}
+
+// pushActors 为推送动态解析推送者账号,用于展示真实头像。
+func (s *Server) pushActors(pushes []*store.Push) map[string]*store.User {
+ out := map[string]*store.User{}
+ users, err := s.st.ListUsers()
+ if err != nil {
+ return out
+ }
+ byName := make(map[string]*store.User, len(users))
+ for _, u := range users {
+ byName[strings.ToLower(u.Username)] = u
+ }
+ for _, p := range pushes {
+ if u, ok := byName[strings.ToLower(p.ActorUsername)]; ok {
+ out[p.ActorUsername] = u
+ }
+ }
+ return out
+}
+
+// storageUsed 统计仓库目录占用的磁盘空间。
+func (s *Server) storageUsed() int64 {
+ entries, err := os.ReadDir(s.repoRoot)
+ if err != nil {
+ return 0
+ }
+ var total int64
+ for _, e := range entries {
+ if e.IsDir() {
+ total += gitx.RepoSize(filepath.Join(s.repoRoot, e.Name()))
+ }
+ }
+ return total
+}
+
+// goVersion 返回运行时 Go 版本。
+func goVersion() string { return runtime.Version() }
+
+// sortedKeys 便于模板中稳定输出。
+func sortedKeys[V any](m map[string]V) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/internal/server/settings.go b/internal/server/settings.go
new file mode 100644
index 0000000..64280f5
--- /dev/null
+++ b/internal/server/settings.go
@@ -0,0 +1,192 @@
+package server
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+func (s *Server) handleProfilePage(w http.ResponseWriter, r *http.Request) {
+ u := s.requireLogin(w, r)
+ if u == nil {
+ return
+ }
+ fresh, err := s.st.UserByID(u.ID)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取账号信息失败")
+ return
+ }
+ myPushes, _ := s.st.PushesByUser(u.ID)
+ repos, _ := s.st.ListRepos("")
+ mine := 0
+ for _, rc := range repos {
+ if rc.OwnerID != nil && *rc.OwnerID == u.ID {
+ mine++
+ }
+ }
+ pushes, _ := s.st.RecentPushes(80)
+ var mineRecent []any
+ for _, p := range pushes {
+ if p.UserID != nil && *p.UserID == u.ID {
+ mineRecent = append(mineRecent, p)
+ }
+ if len(mineRecent) >= 5 {
+ break
+ }
+ }
+ p := s.page(r, "账号设置")
+ p.Active = "settings"
+ p.Data["Profile"] = fresh
+ p.Data["MyPushCount"] = myPushes
+ p.Data["MyRepoCount"] = mine
+ p.Data["MyRecent"] = mineRecent
+ s.render(w, r, "profile", p)
+}
+
+func (s *Server) handleProfileSave(w http.ResponseWriter, r *http.Request) {
+ u := s.requireLogin(w, r)
+ if u == nil {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ display := strings.TrimSpace(r.FormValue("display_name"))
+ email := strings.TrimSpace(r.FormValue("email"))
+ bio := strings.TrimSpace(r.FormValue("bio"))
+ switch {
+ case len(display) > 40:
+ s.renderError(w, r, http.StatusBadRequest, "昵称不能超过 40 个字符")
+ return
+ case len(email) > 120 || (email != "" && !strings.Contains(email, "@")):
+ s.renderError(w, r, http.StatusBadRequest, "邮箱格式不正确")
+ return
+ case len(bio) > 300:
+ s.renderError(w, r, http.StatusBadRequest, "个人简介不能超过 300 个字符")
+ return
+ }
+ if err := s.st.UpdateProfile(u.ID, display, email, bio, u.Avatar); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
+ return
+ }
+ setFlash(w, "账号信息已更新")
+ http.Redirect(w, r, "/settings", http.StatusFound)
+}
+
+func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
+ u := s.requireLogin(w, r)
+ if u == nil {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ current := r.FormValue("current_password")
+ next := r.FormValue("new_password")
+ confirm := r.FormValue("new_password2")
+ fresh, err := s.st.UserByID(u.ID)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取账号信息失败")
+ return
+ }
+ switch {
+ case !checkPassword(fresh.PasswordHash, current):
+ s.renderError(w, r, http.StatusBadRequest, "当前密码不正确")
+ return
+ case len(next) < 6:
+ s.renderError(w, r, http.StatusBadRequest, "新密码至少需要 6 位")
+ return
+ case next != confirm:
+ s.renderError(w, r, http.StatusBadRequest, "两次输入的新密码不一致")
+ return
+ }
+ hash, err := hashPassword(next)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "密码处理失败")
+ return
+ }
+ if err := s.st.SetPassword(u.ID, hash); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
+ return
+ }
+ setFlash(w, "密码已更新,下次 git 操作请使用新密码")
+ http.Redirect(w, r, "/settings", http.StatusFound)
+}
+
+// handleAvatarUpload 上传头像(png / jpg / gif / webp,最大 2MB)。
+func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
+ u := s.requireLogin(w, r)
+ if u == nil {
+ return
+ }
+ if !s.checkCSRF(r) {
+ s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+ return
+ }
+ if err := r.ParseMultipartForm(4 << 20); err != nil {
+ s.renderError(w, r, http.StatusBadRequest, "上传内容过大或格式错误")
+ return
+ }
+ if r.FormValue("remove_avatar") == "on" {
+ s.removeAvatarFile(u.Avatar)
+ _ = s.st.UpdateProfile(u.ID, u.DisplayName, u.Email, u.Bio, "")
+ setFlash(w, "头像已移除")
+ http.Redirect(w, r, "/settings", http.StatusFound)
+ return
+ }
+ file, header, err := r.FormFile("avatar")
+ if err != nil {
+ s.renderError(w, r, http.StatusBadRequest, "请选择要上传的图片")
+ return
+ }
+ defer file.Close()
+ data, err := io.ReadAll(io.LimitReader(file, 2<<20+1))
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "读取上传文件失败")
+ return
+ }
+ if len(data) > 2<<20 {
+ s.renderError(w, r, http.StatusBadRequest, "头像不能超过 2MB")
+ return
+ }
+ ext := ""
+ switch http.DetectContentType(data) {
+ case "image/png":
+ ext = ".png"
+ case "image/jpeg":
+ ext = ".jpg"
+ case "image/gif":
+ ext = ".gif"
+ case "image/webp":
+ ext = ".webp"
+ default:
+ s.renderError(w, r, http.StatusBadRequest, "仅支持 PNG / JPG / GIF / WebP 图片")
+ return
+ }
+ name := fmt.Sprintf("%d-%d%s", u.ID, time.Now().Unix(), ext)
+ full := filepath.Join(s.avatarDir, name)
+ if err := os.WriteFile(full, data, 0o644); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "保存头像失败:"+err.Error())
+ return
+ }
+ s.removeAvatarFile(u.Avatar)
+ if err := s.st.UpdateProfile(u.ID, u.DisplayName, u.Email, u.Bio, name); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "保存失败:"+err.Error())
+ return
+ }
+ setFlash(w, "头像已更新(原文件名:"+header.Filename+")")
+ http.Redirect(w, r, "/settings", http.StatusFound)
+}
+
+func (s *Server) removeAvatarFile(name string) {
+ if name == "" || name != filepath.Base(name) {
+ return
+ }
+ _ = os.Remove(filepath.Join(s.avatarDir, name))
+}
diff --git a/internal/store/repos.go b/internal/store/repos.go
new file mode 100644
index 0000000..b8a6a9a
--- /dev/null
+++ b/internal/store/repos.go
@@ -0,0 +1,295 @@
+package store
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Repo 表示一个 Git 仓库。
+type Repo struct {
+ ID int64
+ Name string
+ Description string
+ OwnerID *int64
+ DefaultBranch string
+ IsArchived bool
+ AIEnabled bool
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// RepoCard 是首页 / 后台列表用的仓库条目(附带展示统计)。
+type RepoCard struct {
+ Repo
+ OwnerName string
+ PushCount int
+ LastPushAt time.Time
+ LastWhatsNew string
+ LastPusher string
+}
+
+// CreateRepo 新建仓库记录。
+func (s *Store) CreateRepo(name, description string, ownerID int64) (*Repo, error) {
+ ts := nowString()
+ res, err := s.db.Exec(`INSERT INTO repos(name, description, owner_id, default_branch, created_at, updated_at)
+ VALUES(?, ?, ?, 'main', ?, ?)`, name, description, ownerID, ts, ts)
+ if err != nil {
+ if isUniqueErr(err) {
+ return nil, fmt.Errorf("项目 %q 已存在", name)
+ }
+ return nil, err
+ }
+ id, _ := res.LastInsertId()
+ return s.RepoByID(id)
+}
+
+const repoCols = `id, name, description, owner_id, default_branch, is_archived, ai_enabled, created_at, updated_at`
+
+func scanRepo(row interface {
+ Scan(dest ...any) error
+}) (*Repo, error) {
+ var r Repo
+ var owner sql.NullInt64
+ var archived, ai int
+ var created, updated string
+ if err := row.Scan(&r.ID, &r.Name, &r.Description, &owner, &r.DefaultBranch, &archived, &ai, &created, &updated); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ if owner.Valid {
+ id := owner.Int64
+ r.OwnerID = &id
+ }
+ r.IsArchived = archived == 1
+ r.AIEnabled = ai == 1
+ r.CreatedAt = parseTime(created)
+ r.UpdatedAt = parseTime(updated)
+ return &r, nil
+}
+
+// RepoByID 按主键查询仓库。
+func (s *Store) RepoByID(id int64) (*Repo, error) {
+ return scanRepo(s.db.QueryRow(`SELECT `+repoCols+` FROM repos WHERE id = ?`, id))
+}
+
+// RepoByName 按名称查询仓库(不区分大小写)。
+func (s *Store) RepoByName(name string) (*Repo, error) {
+ return scanRepo(s.db.QueryRow(`SELECT `+repoCols+` FROM repos WHERE name = ? COLLATE NOCASE`, name))
+}
+
+// ListRepos 返回仓库列表(含统计),q 非空时按名称/描述模糊搜索。
+func (s *Store) ListRepos(q string) ([]*RepoCard, error) {
+ query := `SELECT ` + repoCols + `,
+ COALESCE((SELECT username FROM users u WHERE u.id = repos.owner_id), ''),
+ (SELECT COUNT(*) FROM pushes p WHERE p.repo_id = repos.id),
+ COALESCE((SELECT MAX(created_at) FROM pushes p WHERE p.repo_id = repos.id), repos.updated_at),
+ COALESCE((SELECT whats_new FROM pushes p WHERE p.repo_id = repos.id ORDER BY id DESC LIMIT 1), ''),
+ COALESCE((SELECT actor_display FROM pushes p WHERE p.repo_id = repos.id ORDER BY id DESC LIMIT 1), '')
+ FROM repos`
+ var args []any
+ if strings.TrimSpace(q) != "" {
+ like := "%" + strings.TrimSpace(q) + "%"
+ query += ` WHERE name LIKE ? OR description LIKE ?`
+ args = append(args, like, like)
+ }
+ query += ` ORDER BY updated_at DESC, name COLLATE NOCASE ASC`
+ rows, err := s.db.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*RepoCard
+ for rows.Next() {
+ var c RepoCard
+ var owner sql.NullInt64
+ var archived, ai int
+ var created, updated, lastPush string
+ if err := rows.Scan(&c.ID, &c.Name, &c.Description, &owner, &c.DefaultBranch, &archived, &ai,
+ &created, &updated, &c.OwnerName, &c.PushCount, &lastPush, &c.LastWhatsNew, &c.LastPusher); err != nil {
+ return nil, err
+ }
+ if owner.Valid {
+ id := owner.Int64
+ c.OwnerID = &id
+ }
+ c.IsArchived = archived == 1
+ c.AIEnabled = ai == 1
+ c.CreatedAt = parseTime(created)
+ c.UpdatedAt = parseTime(updated)
+ c.LastPushAt = parseTime(lastPush)
+ out = append(out, &c)
+ }
+ return out, rows.Err()
+}
+
+// UpdateRepo 保存仓库的可编辑字段。
+func (s *Store) UpdateRepo(r *Repo) error {
+ b2i := func(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+ }
+ _, err := s.db.Exec(`UPDATE repos SET name = ?, description = ?, default_branch = ?, is_archived = ?, ai_enabled = ?, updated_at = ? WHERE id = ?`,
+ r.Name, r.Description, r.DefaultBranch, b2i(r.IsArchived), b2i(r.AIEnabled), nowString(), r.ID)
+ return err
+}
+
+// SetRepoOwner 单独更新仓库的所属者。
+func (s *Store) SetRepoOwner(id int64, ownerID *int64) error {
+ if ownerID == nil {
+ _, err := s.db.Exec(`UPDATE repos SET owner_id = NULL WHERE id = ?`, id)
+ return err
+ }
+ _, err := s.db.Exec(`UPDATE repos SET owner_id = ? WHERE id = ?`, *ownerID, id)
+ return err
+}
+
+// TouchRepo 更新仓库的最近活跃时间。
+func (s *Store) TouchRepo(id int64) error {
+ _, err := s.db.Exec(`UPDATE repos SET updated_at = ? WHERE id = ?`, nowString(), id)
+ return err
+}
+
+// DeleteRepo 删除仓库记录(git 目录由调用方清理)。
+func (s *Store) DeleteRepo(id int64) error {
+ _, err := s.db.Exec(`DELETE FROM repos WHERE id = ?`, id)
+ return err
+}
+
+// PushCommit 是推送记录中单个提交的摘要(用于留痕展示)。
+type PushCommit struct {
+ SHA string `json:"sha"`
+ Subject string `json:"subject"`
+ Author string `json:"author"`
+ When int64 `json:"when"`
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+ Files int `json:"files"`
+}
+
+// Push 表示一次推送事件。
+type Push struct {
+ ID int64
+ RepoID int64
+ RepoName string
+ UserID *int64
+ ActorUsername string
+ ActorDisplay string
+ Ref string
+ OldSHA string
+ NewSHA string
+ CommitCount int
+ Details []PushCommit
+ WhatsNew string
+ AIStatus string
+ CreatedAt time.Time
+}
+
+// CreatePush 记录一次推送。
+func (s *Store) CreatePush(repoID int64, userID *int64, actorUsername, actorDisplay, ref, oldSHA, newSHA string, details []PushCommit) (*Push, error) {
+ raw, _ := json.Marshal(details)
+ res, err := s.db.Exec(`INSERT INTO pushes(repo_id, user_id, actor_username, actor_display, ref, old_sha, new_sha, commit_count, details, ai_status, created_at)
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
+ repoID, userID, actorUsername, actorDisplay, ref, oldSHA, newSHA, len(details), string(raw), nowString())
+ if err != nil {
+ return nil, err
+ }
+ id, _ := res.LastInsertId()
+ return s.PushByID(id)
+}
+
+const pushCols = `p.id, p.repo_id, COALESCE(r.name, ''), p.user_id, p.actor_username, p.actor_display, p.ref, p.old_sha, p.new_sha,
+ p.commit_count, p.details, p.whats_new, p.ai_status, p.created_at`
+
+func scanPush(scan func(dest ...any) error) (*Push, error) {
+ var p Push
+ var userID sql.NullInt64
+ var details, created string
+ if err := scan(&p.ID, &p.RepoID, &p.RepoName, &userID, &p.ActorUsername, &p.ActorDisplay, &p.Ref,
+ &p.OldSHA, &p.NewSHA, &p.CommitCount, &details, &p.WhatsNew, &p.AIStatus, &created); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ if userID.Valid {
+ id := userID.Int64
+ p.UserID = &id
+ }
+ _ = json.Unmarshal([]byte(details), &p.Details)
+ p.CreatedAt = parseTime(created)
+ return &p, nil
+}
+
+// PushByID 按主键查询推送记录。
+func (s *Store) PushByID(id int64) (*Push, error) {
+ return scanPush(s.db.QueryRow(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id WHERE p.id = ?`, id).Scan)
+}
+
+// PushesByRepo 返回某个仓库的推送记录。
+func (s *Store) PushesByRepo(repoID int64, limit int) ([]*Push, error) {
+ rows, err := s.db.Query(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id
+ WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT ?`, repoID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*Push
+ for rows.Next() {
+ p, err := scanPush(rows.Scan)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+// LatestPush 返回仓库最近一次推送,没有则返回 nil。
+func (s *Store) LatestPush(repoID int64) (*Push, error) {
+ p, err := scanPush(s.db.QueryRow(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id
+ WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT 1`, repoID).Scan)
+ if errors.Is(err, ErrNotFound) {
+ return nil, nil
+ }
+ return p, err
+}
+
+// RecentPushes 返回全站最近的推送动态。
+func (s *Store) RecentPushes(limit int) ([]*Push, error) {
+ rows, err := s.db.Query(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id
+ ORDER BY p.id DESC LIMIT ?`, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*Push
+ for rows.Next() {
+ p, err := scanPush(rows.Scan)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+// SetPushSummary 写入 AI 生成的 whats new(或降级摘要)。
+func (s *Store) SetPushSummary(id int64, whatsNew, status string) error {
+ _, err := s.db.Exec(`UPDATE pushes SET whats_new = ?, ai_status = ? WHERE id = ?`, whatsNew, status, id)
+ return err
+}
+
+// PushesByUser 返回某用户的推送数。
+func (s *Store) PushesByUser(userID int64) (int, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM pushes WHERE user_id = ?`, userID).Scan(&n)
+ return n, err
+}
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..59ee749
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,411 @@
+// Package store 负责 SQLite 持久化:站点设置、用户、会话、仓库与推送记录。
+package store
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// ErrNotFound 表示记录不存在。
+var ErrNotFound = errors.New("not found")
+
+const timeFormat = time.RFC3339Nano
+
+// Store 是数据库访问入口。
+type Store struct {
+ db *sql.DB
+}
+
+// Open 打开(必要时创建)SQLite 数据库并完成建表。
+func Open(path string) (*Store, error) {
+ dsn := "file:" + path + "?_pragma=busy_timeout(15000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, err
+ }
+ // 小型工作室场景:单连接即可,彻底避免 SQLite 写锁竞争。
+ db.SetMaxOpenConns(1)
+ if err := db.Ping(); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ s := &Store{db: db}
+ if err := s.migrate(); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return s, nil
+}
+
+// Close 关闭数据库。
+func (s *Store) Close() error { return s.db.Close() }
+
+var schema = []string{
+ `CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL DEFAULT ''
+ )`,
+ `CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ display_name TEXT NOT NULL DEFAULT '',
+ email TEXT NOT NULL DEFAULT '',
+ bio TEXT NOT NULL DEFAULT '',
+ password_hash TEXT NOT NULL,
+ avatar TEXT NOT NULL DEFAULT '',
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ is_disabled INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ last_login_at TEXT NOT NULL DEFAULT ''
+ )`,
+ `CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ csrf TEXT NOT NULL DEFAULT '',
+ ip TEXT NOT NULL DEFAULT '',
+ user_agent TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+ )`,
+ `CREATE TABLE IF NOT EXISTS repos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ description TEXT NOT NULL DEFAULT '',
+ owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ default_branch TEXT NOT NULL DEFAULT 'main',
+ is_archived INTEGER NOT NULL DEFAULT 0,
+ ai_enabled INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )`,
+ `CREATE TABLE IF NOT EXISTS pushes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+ user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ actor_username TEXT NOT NULL DEFAULT '',
+ actor_display TEXT NOT NULL DEFAULT '',
+ ref TEXT NOT NULL DEFAULT '',
+ old_sha TEXT NOT NULL DEFAULT '',
+ new_sha TEXT NOT NULL DEFAULT '',
+ commit_count INTEGER NOT NULL DEFAULT 0,
+ details TEXT NOT NULL DEFAULT '[]',
+ whats_new TEXT NOT NULL DEFAULT '',
+ ai_status TEXT NOT NULL DEFAULT 'pending',
+ created_at TEXT NOT NULL
+ )`,
+ `CREATE INDEX IF NOT EXISTS idx_pushes_repo ON pushes(repo_id, id DESC)`,
+ `CREATE INDEX IF NOT EXISTS idx_pushes_created ON pushes(created_at DESC)`,
+ `CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)`,
+}
+
+func (s *Store) migrate() error {
+ for _, stmt := range schema {
+ if _, err := s.db.Exec(stmt); err != nil {
+ return fmt.Errorf("migrate: %w", err)
+ }
+ }
+ return nil
+}
+
+func nowString() string { return time.Now().UTC().Format(timeFormat) }
+
+func parseTime(s string) time.Time {
+ if s == "" {
+ return time.Time{}
+ }
+ if t, err := time.Parse(timeFormat, s); err == nil {
+ return t
+ }
+ if t, err := time.Parse(time.RFC3339, s); err == nil {
+ return t
+ }
+ return time.Time{}
+}
+
+func isUniqueErr(err error) bool {
+ return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
+}
+
+// ---------------------------------------------------------------- settings
+
+// GetSetting 读取配置项,不存在时返回空串。
+func (s *Store) GetSetting(key string) string {
+ var v string
+ err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
+ if err != nil {
+ return ""
+ }
+ return v
+}
+
+// SetSetting 写入(或覆盖)配置项。
+func (s *Store) SetSetting(key, value string) error {
+ _, err := s.db.Exec(`INSERT INTO settings(key, value) VALUES(?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
+ return err
+}
+
+// Settings 返回全部配置项。
+func (s *Store) Settings() map[string]string {
+ out := map[string]string{}
+ rows, err := s.db.Query(`SELECT key, value FROM settings`)
+ if err != nil {
+ return out
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var k, v string
+ if rows.Scan(&k, &v) == nil {
+ out[k] = v
+ }
+ }
+ return out
+}
+
+// Installed 判断是否已完成安装。
+func (s *Store) Installed() bool { return s.GetSetting("installed") == "1" }
+
+// ---------------------------------------------------------------- users
+
+// User 表示一个站点账号。
+type User struct {
+ ID int64
+ Username string
+ DisplayName string
+ Email string
+ Bio string
+ PasswordHash string
+ Avatar string
+ IsAdmin bool
+ IsDisabled bool
+ CreatedAt time.Time
+ LastLoginAt time.Time
+
+ // 统计字段(按需填充)
+ RepoCount int
+ PushCount int
+}
+
+// Display 返回用于展示的名字。
+func (u *User) Display() string {
+ if u != nil && u.DisplayName != "" {
+ return u.DisplayName
+ }
+ if u == nil {
+ return ""
+ }
+ return u.Username
+}
+
+// Initial 返回头像占位字符。
+func (u *User) Initial() string {
+ name := u.Display()
+ for _, r := range name {
+ return strings.ToUpper(string(r))
+ }
+ return "?"
+}
+
+func scanUser(row interface {
+ Scan(dest ...any) error
+}) (*User, error) {
+ var u User
+ var created, lastLogin string
+ var admin, disabled int
+ err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Bio, &u.PasswordHash,
+ &u.Avatar, &admin, &disabled, &created, &lastLogin)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ u.IsAdmin = admin == 1
+ u.IsDisabled = disabled == 1
+ u.CreatedAt = parseTime(created)
+ u.LastLoginAt = parseTime(lastLogin)
+ return &u, nil
+}
+
+const userCols = `id, username, display_name, email, bio, password_hash, avatar, is_admin, is_disabled, created_at, last_login_at`
+
+// CreateUser 新建账号。
+func (s *Store) CreateUser(username, display, email, hash string, isAdmin bool) (*User, error) {
+ ts := nowString()
+ admin := 0
+ if isAdmin {
+ admin = 1
+ }
+ res, err := s.db.Exec(`INSERT INTO users(username, display_name, email, password_hash, is_admin, created_at)
+ VALUES(?, ?, ?, ?, ?, ?)`, username, display, email, hash, admin, ts)
+ if err != nil {
+ if isUniqueErr(err) {
+ return nil, fmt.Errorf("用户名 %q 已存在", username)
+ }
+ return nil, err
+ }
+ id, _ := res.LastInsertId()
+ return s.UserByID(id)
+}
+
+// UserByID 按主键查询用户。
+func (s *Store) UserByID(id int64) (*User, error) {
+ return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE id = ?`, id))
+}
+
+// UserByUsername 按用户名查询(不区分大小写)。
+func (s *Store) UserByUsername(name string) (*User, error) {
+ return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE username = ? COLLATE NOCASE`, name))
+}
+
+// UserByEmail 按邮箱查询。
+func (s *Store) UserByEmail(email string) (*User, error) {
+ return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE email = ? COLLATE NOCASE AND email <> ''`, email))
+}
+
+// ListUsers 返回所有用户,附带仓库数与推送数。
+func (s *Store) ListUsers() ([]*User, error) {
+ rows, err := s.db.Query(`SELECT ` + userCols + `,
+ (SELECT COUNT(*) FROM repos r WHERE r.owner_id = users.id),
+ (SELECT COUNT(*) FROM pushes p WHERE p.user_id = users.id)
+ FROM users ORDER BY is_admin DESC, username COLLATE NOCASE ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []*User
+ for rows.Next() {
+ var u User
+ var created, lastLogin string
+ var admin, disabled int
+ if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Bio, &u.PasswordHash,
+ &u.Avatar, &admin, &disabled, &created, &lastLogin, &u.RepoCount, &u.PushCount); err != nil {
+ return nil, err
+ }
+ u.IsAdmin = admin == 1
+ u.IsDisabled = disabled == 1
+ u.CreatedAt = parseTime(created)
+ u.LastLoginAt = parseTime(lastLogin)
+ out = append(out, &u)
+ }
+ return out, rows.Err()
+}
+
+// UpdateProfile 更新用户资料(不含密码)。
+func (s *Store) UpdateProfile(id int64, display, email, bio, avatar string) error {
+ _, err := s.db.Exec(`UPDATE users SET display_name = ?, email = ?, bio = ?, avatar = ? WHERE id = ?`,
+ display, email, bio, avatar, id)
+ return err
+}
+
+// SetPassword 更新密码哈希。
+func (s *Store) SetPassword(id int64, hash string) error {
+ _, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
+ return err
+}
+
+// SetUserFlags 更新管理员 / 禁用状态。
+func (s *Store) SetUserFlags(id int64, isAdmin, isDisabled bool) error {
+ b2i := func(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+ }
+ _, err := s.db.Exec(`UPDATE users SET is_admin = ?, is_disabled = ? WHERE id = ?`, b2i(isAdmin), b2i(isDisabled), id)
+ return err
+}
+
+// TouchLogin 记录最近登录时间。
+func (s *Store) TouchLogin(id int64) error {
+ _, err := s.db.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, nowString(), id)
+ return err
+}
+
+// DeleteUser 删除用户(仓库 owner 置空,推送记录保留人名快照)。
+func (s *Store) DeleteUser(id int64) error {
+ _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
+ return err
+}
+
+// Counts 返回用户 / 仓库 / 推送总数。
+func (s *Store) Counts() (users, repos, pushes int, err error) {
+ err = s.db.QueryRow(`SELECT
+ (SELECT COUNT(*) FROM users),
+ (SELECT COUNT(*) FROM repos),
+ (SELECT COUNT(*) FROM pushes)`).Scan(&users, &repos, &pushes)
+ return
+}
+
+// CountAdmins 返回管理员数量。
+func (s *Store) CountAdmins() (int, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE is_admin = 1`).Scan(&n)
+ return n, err
+}
+
+// ---------------------------------------------------------------- sessions
+
+// Session 表示一次登录会话。
+type Session struct {
+ ID string
+ UserID int64
+ CSRF string
+ CreatedAt time.Time
+ ExpiresAt time.Time
+}
+
+// CreateSession 写入会话记录。
+func (s *Store) CreateSession(id string, userID int64, csrf string, ttl time.Duration, ip, ua string) error {
+ ts := time.Now().UTC()
+ _, err := s.db.Exec(`INSERT INTO sessions(id, user_id, csrf, ip, user_agent, created_at, expires_at)
+ VALUES(?, ?, ?, ?, ?, ?, ?)`, id, userID, csrf, ip, ua,
+ ts.Format(timeFormat), ts.Add(ttl).Format(timeFormat))
+ return err
+}
+
+// SessionByID 查询有效会话,并返回对应用户。
+func (s *Store) SessionByID(id string) (*Session, *User, error) {
+ var sess Session
+ var created, expires string
+ err := s.db.QueryRow(`SELECT id, user_id, csrf, created_at, expires_at FROM sessions WHERE id = ?`, id).
+ Scan(&sess.ID, &sess.UserID, &sess.CSRF, &created, &expires)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil, ErrNotFound
+ }
+ return nil, nil, err
+ }
+ sess.CreatedAt = parseTime(created)
+ sess.ExpiresAt = parseTime(expires)
+ if time.Now().After(sess.ExpiresAt) {
+ _ = s.DeleteSession(id)
+ return nil, nil, ErrNotFound
+ }
+ u, err := s.UserByID(sess.UserID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if u.IsDisabled {
+ _ = s.DeleteSession(id)
+ return nil, nil, ErrNotFound
+ }
+ return &sess, u, nil
+}
+
+// DeleteSession 删除会话(退出登录)。
+func (s *Store) DeleteSession(id string) error {
+ _, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
+ return err
+}
+
+// CleanupSessions 清理过期会话。
+func (s *Store) CleanupSessions() error {
+ _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, nowString())
+ return err
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..074fa5b
--- /dev/null
+++ b/main.go
@@ -0,0 +1,134 @@
+// Command gitcat 是面向小型工作室的自建 Git 服务。
+//
+// gitcat -addr :12747 -data ./data
+//
+// 首次启动后访问 http://<主机>:12747 会进入安装向导,设置站点名称与管理员账号。
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "gitcat/internal/server"
+ "gitcat/internal/store"
+)
+
+// version 可在构建时通过 -ldflags "-X main.version=..." 注入。
+var version = "1.0.0"
+
+func main() {
+ log.SetFlags(log.LstdFlags)
+ log.SetPrefix("[gitcat] ")
+
+ addr := flag.String("addr", envOr("GITCAT_ADDR", ":12747"), "HTTP 监听地址")
+ dataDir := flag.String("data", envOr("GITCAT_DATA", "./data"), "数据目录(SQLite 数据库、仓库与头像)")
+ showVersion := flag.Bool("version", false, "打印版本号后退出")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Println("gitcat", version)
+ return
+ }
+
+ if err := run(*addr, *dataDir); err != nil {
+ log.Fatalf("启动失败: %v", err)
+ }
+}
+
+func run(addr, dataDir string) error {
+ if _, err := exec.LookPath("git"); err != nil {
+ return errors.New("未找到 git 命令,请先安装 git 并确保在 PATH 中")
+ }
+ abs, err := filepath.Abs(dataDir)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(abs, 0o755); err != nil {
+ return fmt.Errorf("创建数据目录: %w", err)
+ }
+
+ st, err := store.Open(filepath.Join(abs, "gitcat.db"))
+ if err != nil {
+ return fmt.Errorf("打开数据库: %w", err)
+ }
+ defer st.Close()
+
+ srv, err := server.New(server.Config{Addr: addr, DataDir: abs, Version: version}, st)
+ if err != nil {
+ return err
+ }
+
+ httpSrv := &http.Server{
+ Addr: addr,
+ Handler: srv.Handler(),
+ ReadHeaderTimeout: 20 * time.Second,
+ IdleTimeout: 90 * time.Second,
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ // 定时清理过期会话。
+ go func() {
+ t := time.NewTicker(time.Hour)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ if err := st.CleanupSessions(); err != nil {
+ log.Printf("清理过期会话失败: %v", err)
+ }
+ }
+ }
+ }()
+
+ errCh := make(chan error, 1)
+ go func() {
+ log.Printf("gitcat %s 已启动", version)
+ log.Printf("监听地址: %s", displayAddr(addr))
+ log.Printf("数据目录: %s", abs)
+ if !st.Installed() {
+ log.Printf("尚未安装,请打开 %s 完成初始化", displayAddr(addr))
+ }
+ if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ errCh <- err
+ }
+ }()
+
+ select {
+ case err := <-errCh:
+ return err
+ case <-ctx.Done():
+ log.Println("收到退出信号,正在关闭…")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return httpSrv.Shutdown(shutdownCtx)
+ }
+}
+
+func displayAddr(addr string) string {
+ if strings.HasPrefix(addr, ":") {
+ return "http://localhost" + addr
+ }
+ return "http://" + addr
+}
+
+func envOr(key, def string) string {
+ if v := strings.TrimSpace(os.Getenv(key)); v != "" {
+ return v
+ }
+ return def
+}
diff --git a/webroot/static/app.js b/webroot/static/app.js
new file mode 100644
index 0000000..e3dbfd2
--- /dev/null
+++ b/webroot/static/app.js
@@ -0,0 +1,101 @@
+/* gitcat 前端交互:移动端菜单、用户菜单、提示条、复制与确认。 */
+(function () {
+ 'use strict';
+
+ function $(sel, root) { return (root || document).querySelector(sel); }
+ function $$(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); }
+
+ /* ---------------------------------------------------------- 移动端顶部菜单 */
+ var topbar = $('.topbar');
+ var navtoggle = $('.navtoggle');
+ if (topbar && navtoggle) {
+ navtoggle.addEventListener('click', function (e) {
+ e.stopPropagation();
+ var open = topbar.classList.toggle('open');
+ navtoggle.setAttribute('aria-expanded', open ? 'true' : 'false');
+ navtoggle.setAttribute('aria-label', open ? '关闭菜单' : '打开菜单');
+ });
+ }
+
+ /* ------------------------------------------------------------ 用户下拉菜单 */
+ var usermenu = $('.usermenu');
+ var userbtn = usermenu ? $('.userbtn', usermenu) : null;
+ if (usermenu && userbtn) {
+ userbtn.addEventListener('click', function (e) {
+ e.stopPropagation();
+ var open = usermenu.classList.toggle('open');
+ userbtn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ }
+
+ document.addEventListener('click', function () {
+ if (usermenu) usermenu.classList.remove('open');
+ if (topbar && topbar.classList.contains('open')) {
+ topbar.classList.remove('open');
+ if (navtoggle) {
+ navtoggle.setAttribute('aria-expanded', 'false');
+ navtoggle.setAttribute('aria-label', '打开菜单');
+ }
+ }
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key !== 'Escape') return;
+ if (usermenu) usermenu.classList.remove('open');
+ if (topbar) topbar.classList.remove('open');
+ });
+
+ /* ---------------------------------------------------------------- 提示条 */
+ $$('.flash-x').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var box = btn.closest('.flash');
+ if (box) box.remove();
+ });
+ });
+
+ /* ------------------------------------------------------- 危险操作二次确认 */
+ $$('form[data-confirm]').forEach(function (form) {
+ form.addEventListener('submit', function (e) {
+ if (!window.confirm(form.getAttribute('data-confirm'))) e.preventDefault();
+ });
+ });
+
+ /* ---------------------------------------------------------------- 复制按钮 */
+ function copyText(text) {
+ if (navigator.clipboard && window.isSecureContext) {
+ return navigator.clipboard.writeText(text);
+ }
+ return new Promise(function (resolve, reject) {
+ var ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ try {
+ document.execCommand('copy') ? resolve() : reject();
+ } catch (err) {
+ reject(err);
+ } finally {
+ document.body.removeChild(ta);
+ }
+ });
+ }
+
+ $$('[data-copy]').forEach(function (btn) {
+ var label = btn.textContent;
+ btn.addEventListener('click', function () {
+ copyText(btn.getAttribute('data-copy')).then(function () {
+ btn.textContent = '已复制';
+ btn.classList.add('copied');
+ }, function () {
+ btn.textContent = '复制失败';
+ }).then(function () {
+ setTimeout(function () {
+ btn.textContent = label;
+ btn.classList.remove('copied');
+ }, 1600);
+ });
+ });
+ });
+})();
\ No newline at end of file
diff --git a/webroot/static/favicon.svg b/webroot/static/favicon.svg
new file mode 100644
index 0000000..b87f8d0
--- /dev/null
+++ b/webroot/static/favicon.svg
@@ -0,0 +1,16 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
+ <defs>
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
+ <stop offset="0" stop-color="#12b886"/>
+ <stop offset="1" stop-color="#2f80ed"/>
+ </linearGradient>
+ </defs>
+ <rect width="32" height="32" rx="9" fill="url(#g)"/>
+ <g fill="none" stroke="#fff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M7 13.2 12 7l4 3 4-3 5 6.2"/>
+ <path d="M9.5 17h13v2.8a4.2 4.2 0 0 1-4.2 4.2h-4.6A4.2 4.2 0 0 1 9.5 19.8z"/>
+ <path d="M15.2 24.2c.8.9 1.6.9 2.4 0"/>
+ </g>
+ <circle cx="13.2" cy="20" r=".95" fill="#fff"/>
+ <circle cx="18.8" cy="20" r=".95" fill="#fff"/>
+</svg>
\ No newline at end of file
diff --git a/webroot/static/style.css b/webroot/static/style.css
new file mode 100644
index 0000000..6bbe6ab
--- /dev/null
+++ b/webroot/static/style.css
@@ -0,0 +1,708 @@
+/* ============================================================
+ gitcat · 设计系统
+ 主色:薄荷绿 + 湖蓝,浅色现代风,桌面 / 手机双端适配
+ ============================================================ */
+
+:root {
+ --mint-50: #f2fbf6;
+ --mint-100: #e2f6ec;
+ --mint-200: #c8ecdb;
+ --mint-300: #a3ddc4;
+ --green: #12b886;
+ --green-600: #0e9f6e;
+ --green-700: #0b7f59;
+ --blue: #2f80ed;
+ --blue-600: #1b64da;
+ --blue-100: #e6f0fe;
+ --ink: #0b2431;
+ --ink-2: #3c5768;
+ --ink-3: #5f7686;
+ --line: #dbeae3;
+ --line-2: #eaf3ee;
+ --card: #ffffff;
+ --bg: #f6fbf8;
+ --danger: #d64545;
+ --danger-100: #fdeaea;
+ --warn-100: #fff5e2;
+ --warn: #b7791f;
+ --grad: linear-gradient(120deg, var(--green) 0%, var(--blue) 100%);
+ /* 承载白色文字时使用更深的渐变,保证可读性 */
+ --grad-ink: linear-gradient(120deg, var(--green-700) 0%, var(--blue-600) 100%);
+ --grad-soft: linear-gradient(120deg, #eafaf2 0%, #eaf2fe 100%);
+ --shadow-sm: 0 1px 2px rgba(11, 36, 49, .05), 0 1px 3px rgba(11, 36, 49, .04);
+ --shadow: 0 4px 14px rgba(11, 36, 49, .06), 0 1px 3px rgba(11, 36, 49, .05);
+ --shadow-lg: 0 18px 40px -18px rgba(11, 36, 49, .22);
+ --r-sm: 8px;
+ --r: 12px;
+ --r-lg: 18px;
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", "Roboto Mono", Menlo, Consolas, monospace;
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Display", "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans SC", sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+ margin: 0;
+ font-family: var(--sans);
+ font-size: 15px;
+ line-height: 1.65;
+ color: var(--ink);
+ background: var(--bg);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+
+a { color: var(--blue-600); text-decoration: none; }
+a:hover { text-decoration: underline; }
+img { max-width: 100%; }
+code, pre, .csha, .ref { font-family: var(--mono); }
+
+h1, h2, h3, h4 { margin: 0 0 .4em; line-height: 1.25; letter-spacing: -.015em; font-weight: 700; }
+h1 { font-size: clamp(1.45rem, 1.1rem + 1.4vw, 2rem); }
+h2 { font-size: 1.2rem; }
+h3 { font-size: 1rem; }
+p { margin: 0 0 .8em; }
+
+:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; border-radius: 6px; }
+
+.bg-deco {
+ position: fixed; inset: 0; z-index: -1; pointer-events: none;
+ background:
+ radial-gradient(900px 420px at 12% -8%, rgba(18, 184, 134, .16), transparent 62%),
+ radial-gradient(760px 400px at 92% -6%, rgba(47, 128, 237, .14), transparent 60%),
+ linear-gradient(180deg, #fbfefc 0%, var(--bg) 42%);
+}
+
+.wrap { width: min(1180px, 100% - 40px); margin: 0 auto; }
+.muted { color: var(--ink-3); }
+.small { font-size: .84rem; }
+.quiet { color: var(--ink-2); }
+.quiet:hover { color: var(--blue-600); }
+.dot { color: var(--mint-300); margin: 0 .35em; }
+.cnt { display: inline-block; min-width: 22px; padding: 1px 7px; border-radius: 999px; background: var(--mint-100); color: var(--green-700); font-size: .74rem; font-weight: 700; text-align: center; }
+.hint { color: var(--ink-3); font-size: .84rem; margin-top: .5rem; }
+
+/* ---------------------------------------------------------- 顶栏 */
+
+.topbar {
+ position: sticky; top: 0; z-index: 50;
+ background: rgba(255, 255, 255, .82);
+ backdrop-filter: saturate(180%) blur(12px);
+ border-bottom: 1px solid var(--line);
+}
+.topbar-in { display: flex; align-items: center; gap: 18px; height: 62px; }
+
+.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 800; flex: none; }
+.brand:hover { text-decoration: none; }
+.brand-mark {
+ display: grid; place-items: center; width: 34px; height: 34px; border-radius: 11px;
+ background: var(--grad); color: #fff; box-shadow: 0 6px 14px -6px rgba(18, 184, 134, .8);
+}
+.brand-mark.lg { width: 46px; height: 46px; border-radius: 15px; }
+.brand-name { font-size: 1.06rem; letter-spacing: -.02em; }
+.brand.big { gap: 14px; font-size: 1.5rem; margin-bottom: 22px; }
+.brand.big span:last-child { letter-spacing: -.03em; }
+
+.navsearch {
+ flex: 1 1 auto; max-width: 380px; display: flex; align-items: center; gap: 8px;
+ padding: 0 12px; height: 38px; border: 1px solid var(--line); border-radius: 999px;
+ background: #fff; color: var(--ink-3); box-shadow: var(--shadow-sm);
+}
+.navsearch:focus-within { border-color: var(--mint-300); box-shadow: 0 0 0 3px rgba(18, 184, 134, .12); }
+.navsearch input { flex: 1; border: 0; background: transparent; font: inherit; color: var(--ink); outline: none; }
+.navsearch input::placeholder { color: var(--ink-3); }
+
+.navmenu { display: flex; align-items: center; gap: 6px; margin-left: auto; }
+.navlink {
+ padding: 8px 12px; border-radius: 10px; color: var(--ink-2); font-weight: 600; font-size: .92rem;
+}
+.navlink:hover { background: var(--mint-50); color: var(--green-700); text-decoration: none; }
+.navlink.on { background: var(--mint-100); color: var(--green-700); }
+.navactions { margin-left: 6px; display: flex; align-items: center; }
+
+.navtoggle { display: none; width: 40px; height: 40px; border: 1px solid var(--line); background: #fff; border-radius: 11px; padding: 10px; cursor: pointer; }
+.navtoggle span { display: block; height: 2px; background: var(--ink-2); border-radius: 2px; margin: 3px 0; transition: .2s; }
+
+.usermenu { position: relative; }
+.userbtn {
+ display: inline-flex; align-items: center; gap: 8px; padding: 5px 10px 5px 6px;
+ border: 1px solid var(--line); background: #fff; border-radius: 999px; cursor: pointer;
+ font: inherit; font-weight: 600; color: var(--ink); box-shadow: var(--shadow-sm);
+}
+.userbtn:hover { border-color: var(--mint-300); }
+.username { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.usermenu-pop {
+ position: absolute; right: 0; top: calc(100% + 10px); min-width: 220px; padding: 8px;
+ background: #fff; border: 1px solid var(--line); border-radius: var(--r); box-shadow: var(--shadow-lg);
+ display: none; animation: pop .16s ease-out;
+}
+.usermenu.open .usermenu-pop { display: block; }
+.usermenu-pop a, .usermenu-pop button {
+ display: block; width: 100%; text-align: left; padding: 9px 10px; border: 0; background: none;
+ font: inherit; color: var(--ink-2); border-radius: 8px; cursor: pointer;
+}
+.usermenu-pop a:hover, .usermenu-pop button:hover { background: var(--mint-50); color: var(--green-700); text-decoration: none; }
+.usermenu-pop .danger { color: var(--danger); }
+.usermenu-pop .danger:hover { background: var(--danger-100); color: var(--danger); }
+.usermenu-pop form { margin: 0; }
+.um-head { display: flex; gap: 10px; align-items: center; padding: 10px; border-bottom: 1px solid var(--line-2); margin-bottom: 6px; }
+.um-head strong { display: block; }
+.um-head .muted { font-size: .82rem; }
+@keyframes pop { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
+
+/* ---------------------------------------------------------- 布局 */
+
+.page { padding: 26px 0 60px; animation: rise .35s ease-out both; }
+@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+
+.foot { border-top: 1px solid var(--line); background: rgba(255, 255, 255, .6); }
+.foot-in { display: flex; justify-content: space-between; gap: 12px; padding: 20px 0; font-size: .86rem; color: var(--ink-3); flex-wrap: wrap; }
+
+.split { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 22px; align-items: start; }
+.col-main { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
+.col-side { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
+.home-grid { display: grid; grid-template-columns: minmax(0, 1fr) 330px; gap: 26px; align-items: start; }
+.repo-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 22px; align-items: start; }
+.repo-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
+.repo-side { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
+
+.pagehead { margin-bottom: 20px; }
+.pagehead h1 { margin-bottom: .15em; }
+
+/* ---------------------------------------------------------- 卡片 */
+
+.card {
+ background: var(--card); border: 1px solid var(--line); border-radius: var(--r-lg);
+ padding: 20px; box-shadow: var(--shadow-sm);
+}
+.card-title { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
+.empty { text-align: center; padding: 40px 24px; }
+.empty.small { padding: 24px; }
+.empty h3 { margin-bottom: .3em; }
+
+/* ---------------------------------------------------------- 按钮 */
+
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 7px;
+ padding: 9px 16px; border-radius: 11px; border: 1px solid var(--line);
+ background: #fff; color: var(--ink); font: inherit; font-weight: 650; font-size: .92rem;
+ cursor: pointer; transition: .16s; text-decoration: none; white-space: nowrap;
+}
+.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow); text-decoration: none; }
+.btn:active { transform: none; }
+.btn-primary { background: var(--grad-ink); border-color: transparent; color: #fff; box-shadow: 0 10px 20px -12px rgba(18, 184, 134, .9); }
+.btn-primary:hover { box-shadow: 0 14px 26px -12px rgba(47, 128, 237, .85); }
+.btn-ghost { background: var(--mint-50); border-color: var(--mint-200); color: var(--green-700); }
+.btn-danger { background: var(--danger); border-color: transparent; color: #fff; }
+.btn-danger:hover { background: #c23a3a; }
+.btn-mini { padding: 4px 10px; font-size: .82rem; border-radius: 8px; }
+.btn-lg { padding: 12px 22px; font-size: 1rem; border-radius: 13px; }
+.btn-block { width: 100%; }
+
+.row-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 8px; }
+
+/* ---------------------------------------------------------- 提示条 */
+
+.flash {
+ display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: var(--r);
+ margin-bottom: 18px; font-weight: 600; font-size: .93rem; border: 1px solid;
+ animation: pop .2s ease-out;
+}
+.flash.ok { background: var(--mint-50); border-color: var(--mint-200); color: var(--green-700); }
+.flash.err { background: var(--danger-100); border-color: #f6cfcf; color: #a83232; }
+.flash-ico { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: currentColor; color: #fff; font-size: .78rem; flex: none; }
+.flash.ok .flash-ico { color: var(--green); }
+.flash.err .flash-ico { color: var(--danger); }
+.flash-x { margin-left: auto; border: 0; background: none; font-size: 1.2rem; line-height: 1; cursor: pointer; color: inherit; opacity: .6; }
+.flash-x:hover { opacity: 1; }
+
+.announce {
+ display: flex; gap: 12px; align-items: baseline; padding: 13px 18px; margin-bottom: 20px;
+ border-radius: var(--r); border: 1px dashed var(--mint-300); background: var(--grad-soft);
+}
+.announce-tag { font-weight: 800; color: var(--green-700); font-size: .82rem; letter-spacing: .04em; flex: none; }
+
+/* ---------------------------------------------------------- 头像 */
+
+.av { border-radius: 50%; object-fit: cover; flex: none; display: inline-grid; place-items: center; }
+.av-txt { color: #fff; font-weight: 700; letter-spacing: .02em; background: var(--grad-ink); }
+.av-txt.g0 { background: linear-gradient(135deg, #0e9f6e, #2f80ed); }
+.av-txt.g1 { background: linear-gradient(135deg, #0b8f8f, #1b64da); }
+.av-txt.g2 { background: linear-gradient(135deg, #1fa97a, #3b7ce8); }
+.av-txt.g3 { background: linear-gradient(135deg, #0b7f59, #2f80ed); }
+.av-txt.g4 { background: linear-gradient(135deg, #23a874, #4a86e8); }
+.av-txt.g5 { background: linear-gradient(135deg, #119a78, #2b6fd4); }
+
+/* ---------------------------------------------------------- 首页 Hero */
+
+.hero {
+ display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(240px, .9fr); gap: 30px;
+ padding: 34px; border-radius: 24px; border: 1px solid var(--line);
+ background: linear-gradient(135deg, #ffffff 0%, var(--mint-50) 55%, #eef5fe 100%);
+ box-shadow: var(--shadow); margin-bottom: 22px; position: relative; overflow: hidden;
+}
+.hero::after {
+ content: ""; position: absolute; right: -80px; top: -80px; width: 260px; height: 260px; border-radius: 50%;
+ background: radial-gradient(circle at 30% 30%, rgba(18, 184, 134, .22), transparent 70%);
+}
+.eyebrow { display: inline-block; font-size: .8rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--green-700); background: var(--mint-100); padding: 5px 12px; border-radius: 999px; margin-bottom: 14px; }
+.hero h1 { font-size: clamp(1.7rem, 1.2rem + 2.4vw, 2.6rem); margin-bottom: .5em; letter-spacing: -.03em; }
+.lead { color: var(--ink-2); font-size: 1.02rem; max-width: 46ch; }
+.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 20px; }
+.hero-stats { display: grid; grid-template-columns: 1fr; gap: 12px; align-content: center; }
+.stat {
+ display: flex; align-items: baseline; gap: 10px; padding: 14px 18px; border-radius: var(--r);
+ background: rgba(255, 255, 255, .78); border: 1px solid var(--line); box-shadow: var(--shadow-sm);
+}
+.stat strong { font-size: 1.5rem; font-variant-numeric: tabular-nums; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
+.stat span { color: var(--ink-3); font-size: .9rem; }
+
+.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
+.section-head h2 { margin: 0; font-size: 1.12rem; }
+.section-head .muted { font-size: .86rem; }
+.side-title { font-size: .95rem; margin-bottom: 12px; }
+
+/* ---------------------------------------------------------- 项目卡片 */
+
+.repo-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
+.repo-card {
+ display: flex; flex-direction: column; gap: 8px; padding: 18px; border-radius: var(--r-lg);
+ background: #fff; border: 1px solid var(--line); box-shadow: var(--shadow-sm); color: inherit;
+ transition: .18s; position: relative; overflow: hidden;
+}
+.repo-card::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--grad); opacity: 0; transition: .18s; }
+.repo-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-lg); border-color: var(--mint-200); text-decoration: none; }
+.repo-card:hover::before { opacity: 1; }
+.repo-card-top { display: flex; align-items: center; gap: 9px; }
+.repo-icon { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 9px; background: var(--mint-100); color: var(--green-700); flex: none; }
+.repo-icon.lg { width: 40px; height: 40px; border-radius: 13px; background: var(--grad-soft); color: var(--green-700); }
+.repo-name { font-family: var(--mono); font-weight: 700; font-size: 1rem; color: var(--ink); word-break: break-all; }
+.repo-desc { margin: 0; color: var(--ink-2); font-size: .92rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.repo-new { display: flex; gap: 8px; align-items: baseline; padding: 9px 11px; border-radius: 10px; background: var(--grad-soft); border: 1px solid var(--mint-200); }
+.whats-tag { font-size: .7rem; font-weight: 800; letter-spacing: .05em; color: #fff; background: var(--grad-ink); padding: 2px 8px; border-radius: 999px; flex: none; text-transform: uppercase; }
+.whats-line { font-size: .84rem; color: var(--ink-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.repo-card-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; font-size: .82rem; margin-top: auto; }
+
+/* ---------------------------------------------------------- 动态流 */
+
+.feed { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
+.feed-item { display: flex; gap: 10px; padding: 10px 8px; border-radius: 10px; min-width: 0; }
+.feed-item:hover { background: var(--mint-50); }
+.feed-body { min-width: 0; }
+.feed-line { font-size: .9rem; line-height: 1.45; }
+.feed-sub { font-size: .8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* ---------------------------------------------------------- 标签 / 徽章 */
+
+.tag { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .74rem; font-weight: 700; vertical-align: middle; }
+.tag-ai { background: var(--grad-ink); color: #fff; }
+.tag-pending { background: var(--blue-100); color: var(--blue-600); }
+.tag-warn { background: var(--warn-100); color: var(--warn); }
+.tag-ok { background: var(--mint-100); color: var(--green-700); }
+.tag-plain { background: #eef3f6; color: var(--ink-3); }
+.pill { display: inline-block; padding: 1px 9px; border-radius: 999px; background: var(--blue-100); color: var(--blue-600); font-size: .76rem; font-weight: 700; }
+.add { color: #17a673; font-weight: 700; }
+.del { color: var(--danger); font-weight: 700; }
+
+/* ---------------------------------------------------------- 表单 */
+
+.field { display: block; margin-bottom: 16px; }
+.field > span { display: block; font-weight: 650; font-size: .88rem; margin-bottom: 6px; color: var(--ink-2); }
+.field-hint { display: block; font-size: .8rem; color: var(--ink-3); font-style: normal; margin-top: 5px; }
+input[type=text], input[type=password], input[type=email], input[type=search], input[type=file], select, textarea {
+ width: 100%; padding: 10px 13px; border: 1px solid var(--line); border-radius: 10px;
+ font: inherit; color: var(--ink); background: #fff; transition: .16s;
+}
+textarea { resize: vertical; }
+input:focus, select:focus, textarea:focus { border-color: var(--mint-300); box-shadow: 0 0 0 3px rgba(18, 184, 134, .12); outline: none; }
+.check { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 14px; cursor: pointer; font-size: .92rem; }
+.check input { margin-top: 4px; accent-color: var(--green); width: 16px; height: 16px; flex: none; }
+.check em { display: block; font-style: normal; font-size: .8rem; color: var(--ink-3); }
+.check.inline { margin-bottom: 0; }
+.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; }
+.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 18px; }
+fieldset { border: 0; padding: 0; margin: 0 0 18px; }
+legend { font-weight: 700; font-size: .95rem; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; padding: 0; }
+.step { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--grad); color: #fff; font-size: .76rem; font-weight: 700; flex: none; }
+
+.inline-search { display: flex; gap: 8px; }
+.inline-search input { width: 260px; }
+
+/* ---------------------------------------------------------- 安装 / 登录 */
+
+.install { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(340px, 460px); gap: 40px; align-items: center; padding: 30px 0; }
+.install-side h1 { font-size: clamp(1.9rem, 1.4rem + 2.4vw, 3rem); letter-spacing: -.035em; }
+.feature-list { list-style: none; padding: 0; margin: 26px 0 0; display: flex; flex-direction: column; gap: 16px; }
+.feature-list li { display: flex; gap: 14px; align-items: flex-start; }
+.feature-list .fi { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 10px; background: #fff; border: 1px solid var(--line); color: var(--green-700); font-weight: 800; font-size: .85rem; flex: none; box-shadow: var(--shadow-sm); }
+.feature-list strong { display: block; }
+.feature-list em { font-style: normal; color: var(--ink-3); font-size: .88rem; }
+.install-card { padding: 28px; }
+.install-card h2 { margin-bottom: .2em; }
+
+.auth { display: grid; place-items: center; padding: 30px 0; }
+.auth-card { width: min(460px, 100%); padding: 30px; }
+.auth-brand { display: flex; gap: 14px; align-items: center; margin-bottom: 22px; }
+.auth-brand h1 { font-size: 1.3rem; margin: 0; }
+.tip { margin-top: 18px; padding: 14px; border-radius: var(--r); background: var(--mint-50); border: 1px solid var(--mint-200); font-size: .88rem; }
+.tip strong { display: block; margin-bottom: 4px; color: var(--green-700); }
+.tip p { margin: 0; color: var(--ink-2); }
+
+.code {
+ font-family: var(--mono); font-size: .84rem; line-height: 1.7; background: #f7fbf9;
+ border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; overflow-x: auto; margin: 12px 0 0;
+ color: var(--ink-2); white-space: pre;
+}
+.steps { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; font-size: .9rem; }
+.steps li { display: flex; gap: 12px; align-items: flex-start; }
+/* min-width:0 允许此列收缩,否则内部 <pre> 的长命令会把整页撑宽 */
+.steps li > div { flex: 1; min-width: 0; }
+.steps .code { max-width: 100%; }
+
+/* ---------------------------------------------------------- 项目页头 */
+
+.repo-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 22px; flex-wrap: wrap; margin-bottom: 16px; }
+.repo-head.compact { margin-bottom: 12px; }
+.repo-title { display: flex; gap: 14px; align-items: center; min-width: 0; }
+.repo-title h1 { margin: 0; font-size: clamp(1.3rem, 1.1rem + 1vw, 1.7rem); display: flex; align-items: center; gap: 10px; flex-wrap: wrap; word-break: break-all; }
+.repo-sub { margin: 6px 0 0; color: var(--ink-2); font-size: .94rem; max-width: 70ch; }
+.repo-actions { display: flex; gap: 10px; align-items: center; min-width: 0; }
+
+.clonebox {
+ display: flex; align-items: center; gap: 8px; padding: 5px 6px 5px 12px; background: #fff;
+ border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow-sm); max-width: 100%;
+ /* min-width:0 让窄屏下克隆框可以被压缩,否则输入框的固有宽度会撑破容器 */
+ min-width: 0;
+}
+.clonebox-label { font-size: .74rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-3); }
+/* 输入框按 size 属性取宽(见模板),随仓库地址长短自适应,不裁切文本 */
+.clonebox input { border: 0; padding: 4px 0; font-family: var(--mono); font-size: .84rem; color: var(--ink-2); background: transparent; min-width: 0; flex: 1 1 auto; width: auto; max-width: 100%; }
+.clonebox input:focus { box-shadow: none; }
+.clonebox.side { margin-top: 12px; align-items: flex-start; }
+/* 侧栏很窄,地址用可换行的文本展示,避免被输入框裁掉 */
+.cloneurl {
+ flex: 1 1 auto; min-width: 0; font-family: var(--mono); font-size: .78rem;
+ line-height: 1.5; color: var(--ink-2); word-break: break-all;
+}
+
+.repotabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; overflow-x: auto; scrollbar-width: none; }
+.repotabs::-webkit-scrollbar { display: none; }
+.repotabs a {
+ padding: 10px 14px; color: var(--ink-2); font-weight: 650; font-size: .92rem; white-space: nowrap;
+ border-bottom: 2px solid transparent; margin-bottom: -1px; display: inline-flex; align-items: center; gap: 7px;
+}
+.repotabs a:hover { color: var(--green-700); text-decoration: none; }
+.repotabs a.on { color: var(--ink); border-color: var(--green); }
+
+.crumb-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: .9rem; margin-bottom: 12px; }
+.crumb { font-family: var(--mono); color: var(--blue-600); }
+.crumb.cur { color: var(--ink); font-weight: 700; }
+.crumb-bar .sep { color: var(--mint-300); }
+.crumb-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; }
+
+/* ---------------------------------------------------------- 文件列表 */
+
+.filebox { padding: 0; overflow: hidden; }
+.filebox-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: var(--mint-50); border-bottom: 1px solid var(--line); font-size: .88rem; }
+.filebox-head.standalone { border: 1px solid var(--line); border-radius: var(--r) var(--r) 0 0; margin-bottom: -1px; }
+.fc-author { font-weight: 650; }
+.fc-subject { color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46ch; }
+
+.filetable { width: 100%; border-collapse: collapse; font-size: .9rem; }
+.filetable td { padding: 9px 16px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
+.filetable tr:last-child td { border-bottom: 0; }
+.filetable tbody tr:hover { background: var(--mint-50); }
+.ft-link { display: inline-flex; align-items: center; gap: 9px; color: var(--ink); font-family: var(--mono); font-size: .88rem; word-break: break-all; }
+.ft-link:hover { color: var(--blue-600); text-decoration: none; }
+/* 文件 / 目录图标:用内联 SVG 背景代替文字符号,观感更完整 */
+.ico {
+ display: inline-block; width: 18px; height: 18px; flex: none;
+ font-size: 0; line-height: 0; background-repeat: no-repeat;
+ background-position: center; background-size: 16px 16px;
+}
+.ico.dir {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232f80ed' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6.5A1.5 1.5 0 0 1 5.5 5H9l1.6 2H18.5A1.5 1.5 0 0 1 20 8.5v9A1.5 1.5 0 0 1 18.5 19h-13A1.5 1.5 0 0 1 4 17.5z'/%3E%3C/svg%3E");
+}
+.ico.file {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%235f7686' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 3H7.5A1.5 1.5 0 0 0 6 4.5v15A1.5 1.5 0 0 0 7.5 21h9a1.5 1.5 0 0 0 1.5-1.5V7z'/%3E%3Cpath d='M14 3v4h4'/%3E%3C/svg%3E");
+}
+.ft-commit { color: var(--ink-3); max-width: 320px; }
+.ft-commit a { color: var(--ink-3); }
+.ft-time, .ft-size { color: var(--ink-3); font-size: .82rem; text-align: right; white-space: nowrap; }
+.diffstat .ft-size { text-align: right; font-family: var(--mono); }
+
+.readme { padding: 0; overflow: hidden; }
+.readme-head { display: flex; align-items: center; gap: 9px; padding: 12px 18px; border-bottom: 1px solid var(--line); font-weight: 700; font-family: var(--mono); font-size: .9rem; background: var(--mint-50); }
+
+/* markdown 渲染区 */
+.md-body { padding: 20px; overflow-wrap: break-word; }
+.md-body > :first-child { margin-top: 0; }
+.md-body > :last-child { margin-bottom: 0; }
+.md-body h1, .md-body h2 { border-bottom: 1px solid var(--line-2); padding-bottom: .3em; margin-top: 1.5em; }
+.md-body h3, .md-body h4 { margin-top: 1.3em; }
+.md-body code { background: #f2f7f4; padding: 2px 6px; border-radius: 6px; font-size: .86em; }
+.md-body pre { background: #f7fbf9; border: 1px solid var(--line); border-radius: 10px; padding: 14px; overflow-x: auto; }
+.md-body pre code { background: none; padding: 0; }
+.md-body ul, .md-body ol { padding-left: 1.4em; }
+.md-body li { margin: .25em 0; }
+.md-body blockquote { margin: 1em 0; padding: .2em 1em; border-left: 3px solid var(--mint-300); color: var(--ink-2); background: var(--mint-50); border-radius: 0 8px 8px 0; }
+.md-body table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: .9rem; }
+.md-body th, .md-body td { border: 1px solid var(--line); padding: 7px 11px; text-align: left; }
+.md-body th { background: var(--mint-50); }
+.md-body img { border-radius: 10px; }
+.md-body a { text-decoration: underline; text-underline-offset: 2px; }
+
+/* ---------------------------------------------------------- 侧栏卡片 */
+
+.about h3, .tip-card h3, .danger-card h3 { font-size: .95rem; margin-bottom: 10px; }
+.about p, .tip-card p { font-size: .9rem; color: var(--ink-2); }
+.stat-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px 8px; margin: 14px 0; }
+.stat-grid div { display: flex; flex-direction: column; }
+.stat-grid strong { font-size: 1.02rem; font-variant-numeric: tabular-nums; }
+.stat-grid span { font-size: .76rem; color: var(--ink-3); }
+
+.langs { margin: 14px 0; }
+.langbar { display: flex; height: 8px; border-radius: 999px; overflow: hidden; background: var(--line-2); margin-bottom: 10px; }
+.langbar span { display: block; height: 100%; }
+.langlist { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: .84rem; color: var(--ink-2); }
+.langlist em { font-style: normal; color: var(--ink-3); }
+.langdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 5px; }
+
+.contrib { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-direction: column; gap: 8px; font-size: .88rem; }
+.contrib li { display: flex; align-items: center; gap: 9px; }
+.contrib li > span:not(.av) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.contrib em { font-style: normal; color: var(--ink-3); font-size: .82rem; }
+
+.minicommits { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 9px; font-size: .86rem; }
+.minicommits li { display: flex; gap: 8px; align-items: baseline; }
+.minicommits .cmsg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.csha { font-size: .82rem; color: var(--blue-600); background: var(--blue-100); padding: 1px 7px; border-radius: 6px; }
+.csha.big { font-size: .88rem; }
+.csha:hover { text-decoration: none; background: #d8e8fd; }
+.ref { font-size: .84rem; background: var(--mint-100); color: var(--green-700); padding: 1px 8px; border-radius: 6px; }
+
+.whatsnew-card { background: linear-gradient(160deg, #ffffff, var(--mint-50) 90%); }
+.wn-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
+.wn-body { padding: 0; font-size: .92rem; }
+.wn-body ul { padding-left: 1.1em; }
+.wn-foot { font-size: .8rem; margin-top: 12px; border-top: 1px solid var(--line-2); padding-top: 10px; }
+
+.kv { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 9px; font-size: .88rem; }
+.kv li { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
+.kv span { color: var(--ink-3); }
+.kv code { font-size: .78rem; word-break: break-all; text-align: right; }
+
+.danger-card { border-color: #f3d9d9; background: #fffafa; }
+.danger-card input { margin-bottom: 12px; }
+
+/* ---------------------------------------------------------- 提交列表 */
+
+.list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
+.branchpick { display: flex; align-items: center; gap: 8px; }
+.bp-label { font-size: .8rem; font-weight: 700; color: var(--ink-3); text-transform: uppercase; letter-spacing: .05em; }
+
+.commitbox { padding: 0; overflow: hidden; }
+.commitrows { list-style: none; padding: 0; margin: 0; }
+.commitrows li { display: flex; align-items: center; gap: 12px; padding: 13px 16px; border-bottom: 1px solid var(--line-2); }
+.commitrows li:last-child { border-bottom: 0; }
+.commitrows li:hover { background: var(--mint-50); }
+.crow-main { flex: 1; min-width: 0; }
+.crow-subject { font-weight: 650; color: var(--ink); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.crow-subject:hover { color: var(--blue-600); text-decoration: none; }
+.crow-meta { font-size: .8rem; color: var(--ink-3); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.crow-actions { display: flex; align-items: center; gap: 10px; }
+
+.pager { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 18px; }
+
+.branchlist { list-style: none; padding: 0; margin: 0 0 14px; display: flex; flex-direction: column; gap: 2px; font-size: .88rem; }
+.branchlist li { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; padding: 7px 9px; border-radius: 8px; }
+.branchlist li:hover { background: var(--mint-50); }
+.branchlist li.on { background: var(--mint-100); }
+.branchlist a { font-family: var(--mono); font-size: .84rem; }
+
+/* ---------------------------------------------------------- 提交详情 */
+
+.commit-detail { padding: 0; overflow: hidden; }
+.cd-head { padding: 20px 22px 16px; border-bottom: 1px solid var(--line-2); }
+.cd-subject { font-size: 1.25rem; margin-bottom: .5em; word-break: break-word; }
+.cd-meta { display: flex; align-items: center; gap: 10px; font-size: .88rem; color: var(--ink-2); flex-wrap: wrap; }
+.cd-message { padding: 14px 22px; }
+.cd-message .code { margin: 0; background: #fbfdfc; color: var(--ink-2); }
+.cd-stats { display: flex; align-items: center; gap: 14px; padding: 0 22px 14px; font-size: .86rem; }
+
+.diffbox { padding: 0; overflow: hidden; }
+.diffbox-head { display: flex; align-items: center; gap: 10px; padding: 12px 18px; border-bottom: 1px solid var(--line); background: var(--mint-50); font-weight: 700; font-size: .9rem; }
+.diff {
+ margin: 0; padding: 16px 18px; font-size: .82rem; line-height: 1.6; overflow-x: auto;
+ background: #fbfdfc; color: var(--ink-2); white-space: pre; max-height: 70vh; overflow-y: auto;
+}
+
+/* ---------------------------------------------------------- 文件查看器 */
+
+.fileview { padding: 0; overflow: hidden; }
+.codeview { font-family: var(--mono); font-size: .82rem; line-height: 1.7; overflow-x: auto; }
+.codeline { display: flex; min-width: max-content; }
+.codeline:hover { background: var(--mint-50); }
+.codeline .ln {
+ flex: none; width: 58px; text-align: right; padding: 0 14px 0 8px; color: #9db3a9;
+ background: #fbfdfc; border-right: 1px solid var(--line-2); user-select: none; position: sticky; left: 0;
+}
+.codeline .lc { padding: 0 16px; white-space: pre; color: var(--ink); }
+.codeline.more .lc { color: var(--ink-3); font-style: italic; }
+.imgview { padding: 24px; text-align: center; background: repeating-conic-gradient(#fbfdfc 0 25%, #f4f9f6 0 50%) 50% / 20px 20px; }
+.imgview img { max-height: 60vh; box-shadow: var(--shadow); border-radius: 8px; }
+
+/* ---------------------------------------------------------- 推送卡片(留痕) */
+
+.pushcard {
+ background: #fff; border: 1px solid var(--line); border-radius: var(--r-lg);
+ padding: 18px 20px; box-shadow: var(--shadow-sm); margin-bottom: 14px;
+}
+.pushcard-h { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 12px; }
+.pushcard-meta { min-width: 0; }
+.pushcard-line { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; font-size: .94rem; }
+.pushcard-sub { display: flex; align-items: center; gap: 7px; font-size: .82rem; flex-wrap: wrap; margin-top: 2px; }
+.whatsnew { background: var(--grad-soft); border: 1px solid var(--mint-200); border-radius: var(--r); padding: 12px 16px; }
+.whatsnew ul { margin: 0; padding-left: 1.15em; }
+.whatsnew li { margin: .2em 0; }
+.whatsnew p { margin: 0; }
+.commitlist-wrap { margin-top: 12px; }
+.commitlist-wrap summary { cursor: pointer; font-size: .86rem; color: var(--blue-600); font-weight: 650; }
+.commitlist { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 7px; font-size: .86rem; }
+.commitlist li { display: flex; align-items: baseline; gap: 10px; }
+.commitlist .cmsg { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.commitlist .cmeta { color: var(--ink-3); font-size: .78rem; white-space: nowrap; }
+.commitlist .cstat { font-family: var(--mono); font-size: .78rem; white-space: nowrap; }
+
+/* ---------------------------------------------------------- 分支/标签列表 */
+
+.reflist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; }
+.reflist li { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px 4px; border-bottom: 1px solid var(--line-2); }
+.reflist li:last-child { border-bottom: 0; }
+.ref-main { min-width: 0; }
+.ref-name { font-family: var(--mono); font-weight: 700; font-size: .92rem; }
+.ref-sub { color: var(--ink-3); font-size: .84rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60ch; }
+.ref-side { display: flex; align-items: center; gap: 10px; font-size: .82rem; }
+
+/* ---------------------------------------------------------- 后台 */
+
+.adminnav { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 20px; overflow-x: auto; scrollbar-width: none; }
+.adminnav::-webkit-scrollbar { display: none; }
+.adminnav a { padding: 9px 14px; font-weight: 650; font-size: .9rem; color: var(--ink-2); border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
+.adminnav a:hover { color: var(--green-700); text-decoration: none; }
+.adminnav a.on { color: var(--ink); border-color: var(--blue); }
+
+.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 16px; margin-bottom: 22px; }
+.stat-card { display: flex; flex-direction: column; gap: 2px; padding: 18px; }
+.stat-card strong { font-size: 1.7rem; font-variant-numeric: tabular-nums; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
+.stat-card span { color: var(--ink-3); font-size: .86rem; }
+.stat-card a { font-size: .84rem; margin-top: 8px; font-weight: 650; }
+
+.activitylist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; }
+.activitylist li { display: flex; gap: 11px; align-items: flex-start; padding: 10px 8px; border-radius: 10px; }
+.activitylist li:hover { background: var(--mint-50); }
+.activitylist .small { font-size: .8rem; }
+
+.table-wrap { overflow-x: auto; margin: 0 -20px -20px; padding: 0 20px 8px; }
+.datatable { width: 100%; border-collapse: collapse; font-size: .9rem; }
+.datatable th { text-align: left; padding: 10px 12px; color: var(--ink-3); font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; border-bottom: 1px solid var(--line); white-space: nowrap; }
+.datatable td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
+.datatable tr:last-child td { border-bottom: 0; }
+.datatable tbody tr:hover { background: var(--mint-50); }
+.cell-user { display: flex; align-items: center; gap: 10px; }
+.cell-user div { display: flex; flex-direction: column; }
+.cell-actions { text-align: right; white-space: nowrap; }
+.repo-name-cell { font-family: var(--mono); font-weight: 700; }
+
+/* ---------------------------------------------------------- 个人页 */
+
+.profile-card { background: linear-gradient(160deg, #ffffff 40%, var(--mint-50)); }
+.profile-top { display: flex; gap: 18px; align-items: center; margin-bottom: 18px; flex-wrap: wrap; }
+.profile-id h2 { margin: 0 0 .2em; }
+.profile-stats { display: flex; gap: 16px; align-items: center; font-size: .88rem; color: var(--ink-2); flex-wrap: wrap; }
+.avatar-form { border-top: 1px solid var(--line-2); padding-top: 16px; }
+
+/* ---------------------------------------------------------- 错误页 */
+
+.empty-hero { text-align: center; padding: 60px 20px; }
+.err-code { font-size: clamp(3rem, 8vw, 5.5rem); font-weight: 800; letter-spacing: -.04em; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; line-height: 1; }
+.empty-hero h1 { margin: .3em 0; }
+
+/* ============================================================
+ 响应式
+ ============================================================ */
+
+@media (max-width: 1024px) {
+ .home-grid, .repo-grid, .split { grid-template-columns: minmax(0, 1fr); }
+ .col-side, .repo-side {
+ display: grid; gap: 16px; align-items: start;
+ /* minmax(0, 1fr) 让轨道可收缩,避免内容把布局撑出横向滚动 */
+ grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
+ }
+ .col-side > *, .repo-side > * { min-width: 0; }
+ .install { grid-template-columns: 1fr; gap: 26px; }
+ .install-side { order: 2; }
+ .install-card { order: 1; }
+ .hero { grid-template-columns: 1fr; padding: 28px; }
+ .hero-stats { grid-template-columns: repeat(3, 1fr); }
+ .stat { flex-direction: column; gap: 0; align-items: flex-start; }
+}
+
+@media (max-width: 860px) {
+ .navtoggle { display: block; margin-left: auto; }
+ .navsearch { display: none; }
+ .navmenu {
+ position: absolute; top: 62px; left: 0; right: 0; flex-direction: column; align-items: stretch;
+ gap: 2px; padding: 12px 20px 18px; background: rgba(255, 255, 255, .98);
+ border-bottom: 1px solid var(--line); box-shadow: var(--shadow-lg); display: none;
+ }
+ .topbar.open .navmenu { display: flex; animation: pop .18s ease-out; }
+ .topbar.open .navtoggle span:nth-child(1) { transform: translateY(5px) rotate(45deg); }
+ .topbar.open .navtoggle span:nth-child(2) { opacity: 0; }
+ .topbar.open .navtoggle span:nth-child(3) { transform: translateY(-5px) rotate(-45deg); }
+ .navactions { margin: 8px 0 0; }
+ .usermenu { width: 100%; }
+ .userbtn { width: 100%; justify-content: flex-start; }
+ .usermenu-pop { position: static; box-shadow: none; border-radius: var(--r); margin-top: 8px; width: 100%; }
+ /* 纵向排列时必须关掉换行:column + wrap 会让 flex line 按内容固有宽度
+ 撑开,导致克隆框比容器还宽、页面出现横向滚动 */
+ .repo-head { flex-direction: column; align-items: stretch; flex-wrap: nowrap; }
+ .repo-title, .repo-actions { width: 100%; min-width: 0; }
+ .clonebox input { width: 100%; min-width: 0; }
+ .repo-actions .clonebox { width: 100%; }
+}
+
+@media (max-width: 640px) {
+ body { font-size: 14.5px; }
+ .wrap { width: calc(100% - 28px); }
+ /* 手机上省掉 "clone" 标签并缩小字号,把宽度让给仓库地址,方便看清完整 URL */
+ .clonebox-label { display: none; }
+ .clonebox { padding-left: 10px; }
+ .clonebox input { font-size: .78rem; }
+ .page { padding: 18px 0 48px; }
+ .hero { padding: 22px; border-radius: 18px; }
+ .hero-stats { grid-template-columns: 1fr; }
+ .grid-2, .grid-3 { grid-template-columns: 1fr; }
+ .card { padding: 16px; border-radius: 14px; }
+ .filetable .ft-commit, .filetable .ft-time { display: none; }
+ .filetable td { padding: 10px 14px; }
+ .crow-actions .btn { display: none; }
+ .cd-head { padding: 16px; }
+ .md-body { padding: 16px; }
+ .foot-in { flex-direction: column; }
+ .stat-grid { grid-template-columns: repeat(3, 1fr); }
+ .repotabs a { padding: 9px 11px; }
+ .topbar-in { height: 58px; }
+ .navmenu { top: 58px; }
+ .col-side, .repo-side { grid-template-columns: minmax(0, 1fr); }
+ .feed-item { min-width: 0; }
+ .whatsnew-card, .about { overflow: hidden; }
+ .commitlist li { flex-wrap: wrap; }
+ .commitlist .cmsg { flex-basis: 100%; order: 3; white-space: normal; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
+}
\ No newline at end of file
diff --git a/webroot/templates/admin_activity.html b/webroot/templates/admin_activity.html
new file mode 100644
index 0000000..11f020d
--- /dev/null
+++ b/webroot/templates/admin_activity.html
@@ -0,0 +1,13 @@
+{{define "content"}}
+{{template "adminnav" (dict "Tab" "activity")}}
+<div class="pagehead">
+ <h1>操作动态</h1>
+ <p class="muted">全站最近的推送留痕:谁、在哪个项目、改了什么。</p>
+</div>
+
+{{if .Data.Pushes}}
+{{range .Data.Pushes}}{{template "pushbox" (dict "P" . "U" (index $.Data.PushActors .ActorUsername))}}{{end}}
+{{else}}
+<div class="card empty"><h3>暂无动态</h3><p class="muted">成员推送代码后,这里会显示完整记录。</p></div>
+{{end}}
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/admin_ai.html b/webroot/templates/admin_ai.html
new file mode 100644
index 0000000..ac20856
--- /dev/null
+++ b/webroot/templates/admin_ai.html
@@ -0,0 +1,64 @@
+{{define "content"}}
+{{template "adminnav" (dict "Tab" "ai")}}
+<div class="pagehead">
+ <h1>AI 接入</h1>
+ <p class="muted">接入任意 OpenAI 兼容接口(OpenAI、DeepSeek、通义、本地 Ollama 等),成员每次推送后自动生成「What's New」更新说明。</p>
+</div>
+
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <form method="post" action="/admin/ai" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="check"><input type="checkbox" name="ai_enabled" {{if .Data.Enabled}}checked{{end}}><span>启用 AI 自动更新说明<em>关闭后仍会显示根据提交信息生成的简要说明</em></span></label>
+ <label class="field">
+ <span>接口地址(Base URL)</span>
+ <input type="text" name="ai_base_url" value="{{.Data.BaseURL}}" placeholder="https://api.openai.com/v1">
+ <em class="field-hint">可填到 /v1 为止,例如 https://api.deepseek.com/v1;也可直接填写完整的 /chat/completions 地址。</em>
+ </label>
+ <div class="grid-2">
+ <label class="field"><span>模型名称</span><input type="text" name="ai_model" value="{{.Data.Model}}" placeholder="gpt-4o-mini / deepseek-chat"></label>
+ <label class="field">
+ <span>说明语言</span>
+ <select name="ai_language">
+ <option value="zh" {{if ne .Data.Language "en"}}selected{{end}}>简体中文</option>
+ <option value="en" {{if eq .Data.Language "en"}}selected{{end}}>English</option>
+ </select>
+ </label>
+ </div>
+ <label class="field">
+ <span>API Key</span>
+ <input type="password" name="ai_api_key" placeholder="{{if .Data.HasKey}}已保存,留空则保持不变{{else}}sk-...{{end}}" autocomplete="new-password">
+ {{if .Data.HasKey}}<em class="field-hint">当前已配置密钥。</em>{{end}}
+ </label>
+ {{if .Data.HasKey}}<label class="check"><input type="checkbox" name="clear_key"><span>清除已保存的密钥</span></label>{{end}}
+ <div class="row-actions">
+ <button class="btn btn-primary" type="submit">保存设置</button>
+ <button class="btn btn-ghost" type="submit" name="action" value="test">保存并测试连接</button>
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <aside class="col-side">
+ <div class="card tip-card">
+ <h3>它是如何工作的</h3>
+ <ol class="steps">
+ <li><span class="step">1</span><div>成员 <code class="csha">git push</code> 到 gitcat。</div></li>
+ <li><span class="step">2</span><div>服务端记录本次推送的提交与变更统计。</div></li>
+ <li><span class="step">3</span><div>调用你在上面配置的模型,生成 1-3 条面向访客的更新说明。</div></li>
+ <li><span class="step">4</span><div>说明展示在项目主页与「动态」页面,并标注是 AI 生成。</div></li>
+ </ol>
+ <p class="hint">若接口不可用,gitcat 会自动降级为基于提交信息的简要说明,不影响推送。</p>
+ </div>
+ <div class="card about">
+ <h3>常见服务示例</h3>
+ <ul class="kv">
+ <li><span>OpenAI</span><code class="csha">https://api.openai.com/v1</code></li>
+ <li><span>DeepSeek</span><code class="csha">https://api.deepseek.com/v1</code></li>
+ <li><span>Ollama 本地</span><code class="csha">http://127.0.0.1:11434/v1</code></li>
+ </ul>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/admin_dashboard.html b/webroot/templates/admin_dashboard.html
new file mode 100644
index 0000000..a4bbefb
--- /dev/null
+++ b/webroot/templates/admin_dashboard.html
@@ -0,0 +1,76 @@
+{{define "content"}}
+{{template "adminnav" (dict "Tab" "dash")}}
+<div class="pagehead">
+ <h1>管理后台</h1>
+ <p class="muted">用户、项目、AI 接入与站点设置都在这里管理。</p>
+</div>
+
+<div class="stat-cards">
+ <div class="card stat-card"><strong>{{num .Data.StatUsers}}</strong><span>位成员</span><a href="/admin/users">管理用户 →</a></div>
+ <div class="card stat-card"><strong>{{num .Data.StatRepos}}</strong><span>个项目</span><a href="/admin/repos">管理项目 →</a></div>
+ <div class="card stat-card"><strong>{{num .Data.StatPushes}}</strong><span>次推送留痕</span><a href="/admin/activity">查看动态 →</a></div>
+ <div class="card stat-card"><strong>{{.Data.StorageText}}</strong><span>仓库占用</span><span class="muted small">{{.Data.RepoRoot}}</span></div>
+</div>
+
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <h3 class="card-title">最近推送</h3>
+ {{if .Data.RecentPushes}}
+ <ul class="activitylist">
+ {{range .Data.RecentPushes}}
+ <li>
+ {{template "avin" (dict "N" .ActorDisplay "S" 28)}}
+ <div>
+ <div><strong>{{.ActorDisplay}}</strong> <span class="muted">→</span> <a class="quiet" href="/{{.RepoName}}">{{.RepoName}}</a> <code class="ref">{{.Ref}}</code></div>
+ <div class="muted small">{{timeago .CreatedAt}}{{if .WhatsNew}} · {{oneline .WhatsNew}}{{end}}</div>
+ </div>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="muted">还没有推送记录。</p>{{end}}
+ </div>
+
+ <div class="card">
+ <h3 class="card-title">最新成员</h3>
+ {{if .Data.RecentUsers}}
+ <ul class="activitylist">
+ {{range .Data.RecentUsers}}
+ <li>
+ {{template "av" (dict "U" . "S" 28)}}
+ <div>
+ <div><a class="quiet" href="/admin/users/{{.ID}}">{{.Display}}</a> <span class="muted">@{{.Username}}</span> {{if .IsAdmin}}<span class="tag tag-ok">管理员</span>{{end}}{{if .IsDisabled}}<span class="tag tag-warn">已停用</span>{{end}}</div>
+ <div class="muted small">{{date .CreatedAt}} 创建 · {{.PushCount}} 次推送</div>
+ </div>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="muted">还没有用户。</p>{{end}}
+ <a class="btn btn-ghost" href="/admin/users">新建用户</a>
+ </div>
+ </div>
+
+ <aside class="col-side">
+ <div class="card about">
+ <h3>AI 接入状态</h3>
+ {{if .Data.AIReady}}
+ <p><span class="tag tag-ok">已就绪</span></p>
+ <p class="muted small">模型:<code class="csha">{{.Data.AIModel}}</code></p>
+ <p class="muted small">每次推送后会自动生成「What's New」更新说明。</p>
+ {{else}}
+ <p><span class="tag tag-warn">{{if .Data.AIEnabled}}配置不完整{{else}}未启用{{end}}</span></p>
+ <p class="muted small">配置 OpenAI 兼容接口后,推送会自动生成访客可读的更新说明。</p>
+ {{end}}
+ <a class="btn btn-ghost btn-block" href="/admin/ai">配置 AI 接入</a>
+ </div>
+ <div class="card about">
+ <h3>运行信息</h3>
+ <ul class="kv">
+ <li><span>运行环境</span><code class="csha">{{.Data.GoVersion}}</code></li>
+ <li><span>git 版本</span><code class="csha">{{.Data.GitVersion}}</code></li>
+ <li><span>数据目录</span><code class="csha">{{.Data.DataDir}}</code></li>
+ </ul>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/admin_repo.html b/webroot/templates/admin_repo.html
new file mode 100644
index 0000000..9a54575
--- /dev/null
+++ b/webroot/templates/admin_repo.html
@@ -0,0 +1,79 @@
+{{define "content"}}
+{{$r := .Data.Repo}}
+{{template "adminnav" (dict "Tab" "repos")}}
+<div class="pagehead">
+ <h1>{{$r.Name}}</h1>
+ <p class="muted"><a class="quiet" href="/{{$r.Name}}">查看项目主页 →</a></p>
+</div>
+
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <h3 class="card-title">项目设置</h3>
+ <form method="post" action="/admin/repos/{{$r.ID}}">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="field"><span>项目描述</span><textarea name="description" rows="3" maxlength="300">{{$r.Description}}</textarea></label>
+ <div class="grid-2">
+ <label class="field">
+ <span>归属成员</span>
+ <select name="owner_id">
+ <option value="">(保持不变)</option>
+ {{range .Data.Users}}<option value="{{.ID}}" {{if and $r.OwnerID (eq .ID (deref $r.OwnerID))}}selected{{end}}>@{{.Username}} · {{.Display}}</option>{{end}}
+ </select>
+ </label>
+ <label class="field">
+ <span>默认分支</span>
+ <select name="default_branch">
+ {{range .Data.Branches}}<option value="{{.Name}}" {{if eq .Name $r.DefaultBranch}}selected{{end}}>{{.Name}}</option>{{end}}
+ </select>
+ </label>
+ </div>
+ <label class="check"><input type="checkbox" name="ai_enabled" {{if $r.AIEnabled}}checked{{end}}><span>推送后自动生成「What's New」</span></label>
+ <label class="check"><input type="checkbox" name="is_archived" {{if $r.IsArchived}}checked{{end}}><span>归档项目(拒绝推送,访客可浏览)</span></label>
+ <label class="check"><input type="checkbox" name="owner_none"><span>清除归属(变为工作室公共项目)</span></label>
+ <button class="btn btn-primary" type="submit">保存设置</button>
+ </form>
+ </div>
+
+ <div class="card">
+ <h3 class="card-title">推送记录</h3>
+ {{if .Data.Pushes}}
+ <ul class="activitylist">
+ {{range .Data.Pushes}}
+ <li>
+ {{template "avin" (dict "N" .ActorDisplay "S" 28)}}
+ <div>
+ <div><strong>{{.ActorDisplay}}</strong> <code class="ref">{{.Ref}}</code> <span class="muted">· {{.CommitCount}} 个提交</span></div>
+ <div class="muted small">{{timeago .CreatedAt}}{{if .WhatsNew}} · {{oneline .WhatsNew}}{{end}}</div>
+ </div>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="muted">暂无推送记录。</p>{{end}}
+ </div>
+ </div>
+
+ <aside class="col-side">
+ <div class="card about">
+ <h3>仓库信息</h3>
+ <ul class="kv">
+ <li><span>提交数</span><strong>{{num .Data.CommitCount}}</strong></li>
+ <li><span>体积</span><strong>{{.Data.SizeText}}</strong></li>
+ <li><span>默认分支</span><code class="csha">{{$r.DefaultBranch}}</code></li>
+ <li><span>创建时间</span><span class="muted small">{{date $r.CreatedAt}}</span></li>
+ </ul>
+ <pre class="code">git clone {{.Data.CloneURL}}</pre>
+ {{if .Data.Missing}}<p class="tag tag-warn">磁盘上的仓库目录缺失</p>{{end}}
+ </div>
+ <div class="card danger-card">
+ <h3>删除项目</h3>
+ <p class="muted">将删除数据库记录与磁盘上的裸仓库,不可恢复。</p>
+ <form method="post" action="/admin/repos/{{$r.ID}}/delete">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="confirm" placeholder="输入 {{$r.Name}} 确认" autocomplete="off">
+ <button class="btn btn-danger btn-block" type="submit">彻底删除</button>
+ </form>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/admin_repos.html b/webroot/templates/admin_repos.html
new file mode 100644
index 0000000..570a2e7
--- /dev/null
+++ b/webroot/templates/admin_repos.html
@@ -0,0 +1,41 @@
+{{define "content"}}
+{{template "adminnav" (dict "Tab" "repos")}}
+<div class="pagehead">
+ <h1>项目管理</h1>
+ <p class="muted">可以编辑描述、归档、转移归属或彻底删除仓库。</p>
+</div>
+
+<div class="card">
+ <div class="list-head">
+ <form class="inline-search" method="get" action="/admin/repos">
+ <input type="search" name="q" value="{{if .Data.Query}}{{.Data.Query}}{{end}}" placeholder="搜索项目名或描述…">
+ <button class="btn btn-mini" type="submit">搜索</button>
+ </form>
+ <span class="muted">共 {{len .Data.Rows}} 个项目</span>
+ </div>
+ <div class="table-wrap">
+ <table class="datatable">
+ <thead><tr><th>项目</th><th>归属</th><th>提交/推送</th><th>体积</th><th>最近更新</th><th>状态</th><th></th></tr></thead>
+ <tbody>
+ {{range .Data.Rows}}
+ <tr>
+ <td>
+ <a class="quiet repo-name-cell" href="/{{.Card.Name}}">{{.Card.Name}}</a>
+ {{if .Card.Description}}<div class="muted small">{{.Card.Description}}</div>{{end}}
+ </td>
+ <td class="muted small">{{if .Card.OwnerName}}@{{.Card.OwnerName}}{{else}}—{{end}}</td>
+ <td class="muted small">{{.Card.PushCount}} 次推送</td>
+ <td class="muted small">{{.Size}}</td>
+ <td class="muted small">{{timeago .Card.UpdatedAt}}</td>
+ <td>
+ {{if .Card.IsArchived}}<span class="tag tag-warn">已归档</span>{{else}}<span class="tag tag-ok">活跃</span>{{end}}
+ {{if not .Card.AIEnabled}}<span class="tag tag-plain">AI 关</span>{{end}}
+ </td>
+ <td class="cell-actions"><a class="btn btn-mini" href="/admin/repos/{{.Card.ID}}">管理</a></td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/admin_settings.html b/webroot/templates/admin_settings.html
new file mode 100644
index 0000000..500ad88
--- /dev/null
+++ b/webroot/templates/admin_settings.html
@@ -0,0 +1,34 @@
+{{define "content"}}
+{{template "adminnav" (dict "Tab" "settings")}}
+<div class="pagehead">
+ <h1>站点设置</h1>
+ <p class="muted">站点名称会显示在导航栏与页面标题上。</p>
+</div>
+
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <form method="post" action="/admin/settings">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="field"><span>站点名称</span><input type="text" name="site_name" required maxlength="60" value="{{.Data.SiteName}}"></label>
+ <label class="field"><span>首页公告(可选)</span><textarea name="announcement" rows="3" maxlength="500" placeholder="例如:本周五前请把 demo 分支合并到 main">{{.Data.Announcement}}</textarea></label>
+ <button class="btn btn-primary" type="submit">保存设置</button>
+ </form>
+ </div>
+ </div>
+ <aside class="col-side">
+ <div class="card about">
+ <h3>部署信息</h3>
+ <ul class="kv">
+ <li><span>监听地址</span><code class="csha">{{.Data.Addr}}</code></li>
+ <li><span>数据目录</span><code class="csha">{{.Data.DataDir}}</code></li>
+ </ul>
+ <p class="hint">部署参数可通过命令行参数调整:<code class="csha">gitcat -addr :12747 -data /var/lib/gitcat</code></p>
+ </div>
+ <div class="card tip-card">
+ <h3>账号与权限</h3>
+ <p>所有成员账号均由管理员创建;成员登录后可修改自己的昵称、头像、简介与密码。</p>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/admin_user.html b/webroot/templates/admin_user.html
new file mode 100644
index 0000000..8b522c6
--- /dev/null
+++ b/webroot/templates/admin_user.html
@@ -0,0 +1,65 @@
+{{define "content"}}
+{{$u := .Data.U}}
+{{template "adminnav" (dict "Tab" "users")}}
+<div class="pagehead">
+ <h1>{{$u.Display}}</h1>
+ <p class="muted">@{{$u.Username}} · 创建于 {{datetime $u.CreatedAt}}</p>
+</div>
+
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <h3 class="card-title">账号信息</h3>
+ <form method="post" action="/admin/users/{{$u.ID}}">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <div class="grid-2">
+ <label class="field"><span>昵称</span><input type="text" name="display_name" maxlength="40" value="{{$u.DisplayName}}"></label>
+ <label class="field"><span>邮箱</span><input type="email" name="email" maxlength="120" value="{{$u.Email}}"></label>
+ </div>
+ <label class="field"><span>个人简介</span><textarea name="bio" rows="2" maxlength="300">{{$u.Bio}}</textarea></label>
+ <div class="grid-2">
+ <label class="check"><input type="checkbox" name="is_admin" {{if $u.IsAdmin}}checked{{end}}><span>管理员权限<em>可以进入 /admin 后台</em></span></label>
+ <label class="check"><input type="checkbox" name="is_disabled" {{if $u.IsDisabled}}checked{{end}}><span>停用账号<em>停用后无法登录与推送</em></span></label>
+ </div>
+ <label class="field"><span>重置密码(留空则不修改)</span><input type="text" name="new_password" minlength="6" placeholder="新的登录密码"></label>
+ <button class="btn btn-primary" type="submit">保存修改</button>
+ </form>
+ </div>
+
+ <div class="card">
+ <h3 class="card-title">TA 的最近推送</h3>
+ {{if .Data.RecentPushes}}
+ <ul class="activitylist">
+ {{range .Data.RecentPushes}}
+ <li>
+ <div>
+ <div><a class="quiet" href="/{{.RepoName}}">{{.RepoName}}</a> <code class="ref">{{.Ref}}</code> <span class="muted">· {{.CommitCount}} 个提交</span></div>
+ <div class="muted small">{{timeago .CreatedAt}}{{if .WhatsNew}} · {{oneline .WhatsNew}}{{end}}</div>
+ </div>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="muted">暂无推送记录。</p>{{end}}
+ </div>
+ </div>
+
+ <aside class="col-side">
+ <div class="card about">
+ <h3>所属项目</h3>
+ {{if .Data.OwnedRepos}}
+ <ul class="branchlist">
+ {{range .Data.OwnedRepos}}<li><a href="/{{.Name}}">{{.Name}}</a><span class="muted">{{timeago .UpdatedAt}}</span></li>{{end}}
+ </ul>
+ {{else}}<p class="muted small">没有创建过项目。</p>{{end}}
+ </div>
+ <div class="card danger-card">
+ <h3>删除账号</h3>
+ <p class="muted">删除后该成员无法登录,历史推送记录会保留其名字。</p>
+ <form method="post" action="/admin/users/{{$u.ID}}/delete" data-confirm="确定要删除账号 {{$u.Username}} 吗?此操作不可恢复。">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button class="btn btn-danger btn-block" type="submit">删除该账号</button>
+ </form>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/admin_users.html b/webroot/templates/admin_users.html
new file mode 100644
index 0000000..2782b4d
--- /dev/null
+++ b/webroot/templates/admin_users.html
@@ -0,0 +1,53 @@
+{{define "content"}}
+{{template "adminnav" (dict "Tab" "users")}}
+<div class="pagehead">
+ <h1>用户管理</h1>
+ <p class="muted">站点不开放注册,所有成员账号在这里创建。成员可以自行修改昵称、头像与密码。</p>
+</div>
+
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <h3 class="card-title">全部成员 <span class="cnt">{{len .Data.Users}}</span></h3>
+ <div class="table-wrap">
+ <table class="datatable">
+ <thead><tr><th>成员</th><th>项目</th><th>推送</th><th>最近登录</th><th>状态</th><th></th></tr></thead>
+ <tbody>
+ {{range .Data.Users}}
+ <tr>
+ <td class="cell-user">
+ {{template "av" (dict "U" . "S" 30)}}
+ <div><a class="quiet" href="/admin/users/{{.ID}}">{{.Display}}</a><span class="muted small">@{{.Username}}</span></div>
+ </td>
+ <td>{{.RepoCount}}</td>
+ <td>{{.PushCount}}</td>
+ <td class="muted small">{{if .LastLoginAt}}{{timeago .LastLoginAt}}{{else}}从未登录{{end}}</td>
+ <td>
+ {{if .IsAdmin}}<span class="tag tag-ok">管理员</span>{{end}}
+ {{if .IsDisabled}}<span class="tag tag-warn">已停用</span>{{else}}<span class="tag tag-plain">正常</span>{{end}}
+ </td>
+ <td class="cell-actions"><a class="btn btn-mini" href="/admin/users/{{.ID}}">编辑</a></td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
+ <aside class="col-side">
+ <div class="card">
+ <h3 class="card-title">新建用户</h3>
+ <form method="post" action="/admin/users" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="field"><span>用户名</span><input type="text" name="username" required maxlength="32" placeholder="登录名,如 xiaoming"></label>
+ <label class="field"><span>昵称</span><input type="text" name="display_name" maxlength="40" placeholder="显示名,如 小明"></label>
+ <label class="field"><span>邮箱(可选)</span><input type="email" name="email" maxlength="120" placeholder="you@studio.com"></label>
+ <label class="field"><span>初始密码</span><input type="text" name="password" required minlength="6" placeholder="至少 6 位,创建后请告知本人"></label>
+ <label class="check"><input type="checkbox" name="is_admin"><span>设为管理员</span></label>
+ <button class="btn btn-primary btn-block" type="submit">创建账号</button>
+ </form>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/error.html b/webroot/templates/error.html
new file mode 100644
index 0000000..652111c
--- /dev/null
+++ b/webroot/templates/error.html
@@ -0,0 +1,8 @@
+{{define "content"}}
+<div class="empty-hero">
+ <div class="err-code">{{.Data.Code}}</div>
+ <h1>{{.Data.Message}}</h1>
+ <p class="muted">你可以返回首页,或检查地址是否输入正确。</p>
+ <a class="btn btn-primary" href="/">回到首页</a>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/home.html b/webroot/templates/home.html
new file mode 100644
index 0000000..61d089f
--- /dev/null
+++ b/webroot/templates/home.html
@@ -0,0 +1,97 @@
+{{define "content"}}
+<section class="hero">
+ <div class="hero-main">
+ <span class="eyebrow">工作室自建 Git 服务</span>
+ <h1>工作室的代码<br>都在这台服务器上</h1>
+ <p class="lead">所有项目对访客可见,登录成员可以推送代码。每一次推送都会记录是谁、改了什么,并由 AI 自动生成「What's New」。</p>
+ <div class="hero-actions">
+ {{if .User}}
+ <a class="btn btn-primary btn-lg" href="/new">新建项目</a>
+ <a class="btn btn-ghost btn-lg" href="/settings">账号设置</a>
+ {{else}}
+ <a class="btn btn-primary btn-lg" href="/login">登录并参与协作</a>
+ {{end}}
+ </div>
+ </div>
+ <div class="hero-stats">
+ <div class="stat"><strong>{{num .Data.StatRepos}}</strong><span>个项目</span></div>
+ <div class="stat"><strong>{{num .Data.StatUsers}}</strong><span>位成员</span></div>
+ <div class="stat"><strong>{{num .Data.StatPushes}}</strong><span>次推送留痕</span></div>
+ </div>
+</section>
+
+{{if .Data.Announcement}}
+<div class="announce">
+ <span class="announce-tag">公告</span>
+ <span>{{.Data.Announcement}}</span>
+</div>
+{{end}}
+
+<div class="home-grid">
+ <section class="col-main">
+ <div class="section-head">
+ <h2>{{if .Data.Query}}搜索「{{.Data.Query}}」{{else}}全部项目{{end}}</h2>
+ {{if .Data.Query}}<a class="quiet" href="/">清除搜索</a>{{end}}
+ </div>
+ {{if .Data.HasRepos}}
+ <div class="repo-cards">
+ {{range .Data.Repos}}
+ <a class="repo-card" href="/{{.Name}}">
+ <div class="repo-card-top">
+ <span class="repo-icon" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9l1.5 2H18a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 18 18H5.5A1.5 1.5 0 0 1 4 16.5z"/></svg>
+ </span>
+ <span class="repo-name">{{.Name}}</span>
+ {{if .IsArchived}}<span class="tag tag-warn">已归档</span>{{end}}
+ </div>
+ {{if .Description}}<p class="repo-desc">{{.Description}}</p>{{else}}<p class="repo-desc muted">暂无项目描述</p>{{end}}
+ {{if .LastWhatsNew}}
+ <div class="repo-new">
+ <span class="whats-tag">What's New</span>
+ <span class="whats-line">{{oneline .LastWhatsNew}}</span>
+ </div>
+ {{end}}
+ <div class="repo-card-foot">
+ <span class="muted">{{if .OwnerName}}@{{.OwnerName}}{{else}}工作室{{end}}</span>
+ <span class="dot">·</span>
+ <span class="muted">{{timeago .LastPushAt}}</span>
+ <span class="dot">·</span>
+ <span class="muted">{{.PushCount}} 次推送</span>
+ </div>
+ </a>
+ {{end}}
+ </div>
+ {{else}}
+ <div class="card empty">
+ <h3>{{if .Data.Query}}没有找到匹配的项目{{else}}还没有任何项目{{end}}</h3>
+ <p class="muted">{{if .Data.Query}}换个关键词试试。{{else}}创建第一个项目,然后把本地代码推上来吧。{{end}}</p>
+ {{if and .User (not .Data.Query)}}<a class="btn btn-primary" href="/new">新建项目</a>{{end}}
+ </div>
+ {{end}}
+ </section>
+
+ <aside class="col-side">
+ <h3 class="side-title">最新动态</h3>
+ {{if .Data.RecentPushes}}
+ <ul class="feed">
+ {{range .Data.RecentPushes}}
+ <li class="feed-item">
+ {{$pu := index $.Data.PushActors .ActorUsername}}
+ {{if $pu}}{{template "av" (dict "U" $pu "S" 26)}}{{else}}{{template "avin" (dict "N" .ActorDisplay "S" 26)}}{{end}}
+ <div class="feed-body">
+ <div class="feed-line">
+ <strong>{{.ActorDisplay}}</strong>
+ <span class="muted">推送了 {{.CommitCount}} 个提交到</span>
+ <a class="quiet" href="/{{.RepoName}}">{{.RepoName}}</a>
+ </div>
+ <div class="feed-sub muted">{{timeago .CreatedAt}}{{if .WhatsNew}} · {{oneline .WhatsNew}}{{end}}</div>
+ </div>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}
+ <div class="card empty small"><p class="muted">还没有推送记录,等待第一条更新。</p></div>
+ {{end}}
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/install.html b/webroot/templates/install.html
new file mode 100644
index 0000000..b1190d4
--- /dev/null
+++ b/webroot/templates/install.html
@@ -0,0 +1,62 @@
+{{define "content"}}
+<section class="install">
+ <div class="install-side">
+ <div class="brand big">
+ <span class="brand-mark lg" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M4 8.5 8 4l4 3 4-3 4 4.5"/><path d="M6 12h12v4.5a3.5 3.5 0 0 1-3.5 3.5h-5A3.5 3.5 0 0 1 6 16.5z"/>
+ <circle cx="10" cy="15" r="0.6" fill="currentColor" stroke="none"/><circle cx="14" cy="15" r="0.6" fill="currentColor" stroke="none"/>
+ <path d="M12 18.2c.7.7 1.6.7 2.3 0"/>
+ </svg>
+ </span>
+ <span>gitcat</span>
+ </div>
+ <h1>把代码留<br>在自己的服务器上</h1>
+ <p class="lead">gitcat 是给小型工作室用的自建 Git 服务:项目公开可看、成员登录后可推送、每次推送自动生成「What's New」更新说明。</p>
+ <ul class="feature-list">
+ <li><span class="fi">1</span><div><strong>轻量部署</strong><em>单个二进制 + SQLite,默认监听 12747 端口</em></div></li>
+ <li><span class="fi">2</span><div><strong>成员协作</strong><em>账号由管理员创建,推送全程留痕,谁改了什么一目了然</em></div></li>
+ <li><span class="fi">3</span><div><strong>AI 摘要</strong><em>接入 OpenAI 兼容接口,自动为访客生成更新说明</em></div></li>
+ </ul>
+ </div>
+
+ <div class="card install-card">
+ <h2>初始化安装</h2>
+ <p class="muted">只需两步,完成后即可开始使用。</p>
+ <form method="post" action="/install" autocomplete="off">
+ <fieldset>
+ <legend><span class="step">1</span> 站点信息</legend>
+ <label class="field">
+ <span>站点名称</span>
+ <input type="text" name="site_name" required maxlength="60" placeholder="例如:山猫工作室 · 代码仓库" value="{{if .Data.SiteName}}{{.Data.SiteName}}{{end}}">
+ </label>
+ </fieldset>
+ <fieldset>
+ <legend><span class="step">2</span> 管理员账号</legend>
+ <div class="grid-2">
+ <label class="field">
+ <span>管理员用户名</span>
+ <input type="text" name="username" required maxlength="32" placeholder="admin" value="{{if .Data.Username}}{{.Data.Username}}{{end}}">
+ </label>
+ <label class="field">
+ <span>显示昵称(可选)</span>
+ <input type="text" name="display_name" maxlength="40" placeholder="例如:老猫" value="{{if .Data.DisplayName}}{{.Data.DisplayName}}{{end}}">
+ </label>
+ </div>
+ <div class="grid-2">
+ <label class="field">
+ <span>密码</span>
+ <input type="password" name="password" required minlength="6" placeholder="至少 6 位">
+ </label>
+ <label class="field">
+ <span>确认密码</span>
+ <input type="password" name="password2" required minlength="6" placeholder="再输入一次">
+ </label>
+ </div>
+ </fieldset>
+ <button class="btn btn-primary btn-lg btn-block" type="submit">完成安装,进入 gitcat</button>
+ <p class="hint">安装后所有成员账号均由管理员在 /admin 后台创建,站点不开放注册。</p>
+ </form>
+ </div>
+</section>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/layout.html b/webroot/templates/layout.html
new file mode 100644
index 0000000..f4892a4
--- /dev/null
+++ b/webroot/templates/layout.html
@@ -0,0 +1,149 @@
+{{define "av"}}{{if .U}}{{if .U.Avatar}}<img class="av" style="width:{{.S}}px;height:{{.S}}px" src="/media/avatars/{{.U.Avatar}}" alt="{{.U.Display}}">{{else}}<span class="av av-txt {{ava .U.Display}}" style="width:{{.S}}px;height:{{.S}}px;font-size:{{sub .S 14}}px">{{.U.Initial}}</span>{{end}}{{else}}<span class="av av-txt g0" style="width:{{.S}}px;height:{{.S}}px;font-size:{{sub .S 14}}px">?</span>{{end}}{{end}}
+
+{{define "flash"}}{{if .Flash}}<div class="flash ok" role="status"><span class="flash-ico">✓</span><span>{{.Flash}}</span><button class="flash-x" type="button" aria-label="关闭">×</button></div>{{end}}{{if .Error}}<div class="flash err" role="alert"><span class="flash-ico">!</span><span>{{.Error}}</span><button class="flash-x" type="button" aria-label="关闭">×</button></div>{{end}}{{end}}
+
+{{define "avin"}}<span class="av av-txt {{ava .N}}" style="width:{{.S}}px;height:{{.S}}px;font-size:{{sub .S 12}}px">{{initial .N}}</span>{{end}}
+
+{{define "pushbox"}}
+<article class="pushcard">
+ <header class="pushcard-h">
+ {{if .U}}{{template "av" (dict "U" .U "S" 34)}}{{else}}{{template "avin" (dict "N" .P.ActorDisplay "S" 34)}}{{end}}
+ <div class="pushcard-meta">
+ <div class="pushcard-line">
+ <strong>{{if .U}}{{.U.Display}}{{else}}{{.P.ActorDisplay}}{{end}}</strong>
+ <span class="muted">推送了</span>
+ <span class="pill">{{.P.CommitCount}} 个提交</span>
+ <span class="muted">到</span>
+ <code class="ref">{{.P.Ref}}</code>
+ </div>
+ <div class="pushcard-sub">
+ <a class="quiet" href="/{{.P.RepoName}}">{{.P.RepoName}}</a>
+ <span class="dot">·</span>
+ <span class="muted" title="{{datetime .P.CreatedAt}}">{{timeago .P.CreatedAt}}</span>
+ {{if eq .P.AIStatus "done"}}<span class="tag tag-ai">AI 摘要</span>{{else if eq .P.AIStatus "pending"}}<span class="tag tag-pending">AI 生成中…</span>{{else if eq .P.AIStatus "failed"}}<span class="tag tag-warn">AI 不可用 · 自动摘要</span>{{else}}<span class="tag tag-plain">自动摘要</span>{{end}}
+ </div>
+ </div>
+ </header>
+ {{if .P.WhatsNew}}<div class="whatsnew">{{md .P.WhatsNew}}</div>{{end}}
+ {{if .P.Details}}
+ <details class="commitlist-wrap">
+ <summary>查看这次改动的 {{len .P.Details}} 个提交</summary>
+ <ul class="commitlist">
+ {{range .P.Details}}
+ <li>
+ <a class="csha" href="/{{$.P.RepoName}}/commit/{{.SHA}}">{{short .SHA}}</a>
+ <span class="cmsg">{{.Subject}}</span>
+ <span class="cmeta">{{.Author}} · {{timeago (unix .When)}}</span>
+ <span class="cstat"><span class="add">+{{.Additions}}</span> <span class="del">-{{.Deletions}}</span></span>
+ </li>
+ {{end}}
+ </ul>
+ </details>
+ {{end}}
+</article>
+{{end}}
+
+{{define "repotabs"}}
+<nav class="repotabs">
+ <a class="{{if eq .Tab "code"}}on{{end}}" href="/{{.Repo.Name}}{{if .Ref}}?ref={{.Ref}}{{end}}">代码</a>
+ <a class="{{if eq .Tab "commits"}}on{{end}}" href="/{{.Repo.Name}}/commits/{{.Ref}}">提交 <span class="cnt">{{.CommitCount}}</span></a>
+ <a class="{{if eq .Tab "branches"}}on{{end}}" href="/{{.Repo.Name}}/branches">分支 <span class="cnt">{{.BranchCount}}</span></a>
+ <a class="{{if eq .Tab "activity"}}on{{end}}" href="/{{.Repo.Name}}/activity">动态</a>
+ {{if .CanManage}}<a class="{{if eq .Tab "settings"}}on{{end}}" href="/{{.Repo.Name}}/settings">设置</a>{{end}}
+</nav>
+{{end}}
+
+{{define "adminnav"}}
+<nav class="adminnav">
+ <a class="{{if eq .Tab "dash"}}on{{end}}" href="/admin">概览</a>
+ <a class="{{if eq .Tab "users"}}on{{end}}" href="/admin/users">用户</a>
+ <a class="{{if eq .Tab "repos"}}on{{end}}" href="/admin/repos">项目</a>
+ <a class="{{if eq .Tab "activity"}}on{{end}}" href="/admin/activity">操作动态</a>
+ <a class="{{if eq .Tab "ai"}}on{{end}}" href="/admin/ai">AI 接入</a>
+ <a class="{{if eq .Tab "settings"}}on{{end}}" href="/admin/settings">站点设置</a>
+</nav>
+{{end}}
+
+{{define "layout.html"}}<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<meta name="color-scheme" content="light">
+<title>{{.Title}} · {{.SiteName}}</title>
+<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
+<link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+<div class="bg-deco" aria-hidden="true"></div>
+<header class="topbar">
+ <div class="wrap topbar-in">
+ <a class="brand" href="/">
+ <span class="brand-mark" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M4 8.5 8 4l4 3 4-3 4 4.5"/><path d="M6 12h12v4.5a3.5 3.5 0 0 1-3.5 3.5h-5A3.5 3.5 0 0 1 6 16.5z"/>
+ <circle cx="10" cy="15" r="0.6" fill="currentColor" stroke="none"/><circle cx="14" cy="15" r="0.6" fill="currentColor" stroke="none"/>
+ <path d="M12 18.2c.7.7 1.6.7 2.3 0"/>
+ </svg>
+ </span>
+ <span class="brand-name">{{.SiteName}}</span>
+ </a>
+ <form class="navsearch" action="/" method="get" role="search">
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.6-3.6"/></svg>
+ <input type="search" name="q" placeholder="搜索项目…" value="{{if .Data}}{{if .Data.Query}}{{.Data.Query}}{{end}}{{end}}" aria-label="搜索项目">
+ </form>
+ <button class="navtoggle" type="button" aria-label="打开菜单" aria-expanded="false">
+ <span></span><span></span><span></span>
+ </button>
+ <nav class="navmenu">
+ <a class="navlink {{if eq .Active "home"}}on{{end}}" href="/">项目</a>
+ {{if .User}}
+ <a class="navlink {{if eq .Active "new"}}on{{end}}" href="/new">新建项目</a>
+ {{if .User.IsAdmin}}<a class="navlink {{if eq .Active "admin"}}on{{end}}" href="/admin">管理后台</a>{{end}}
+ {{end}}
+ <div class="navactions">
+ {{if .User}}
+ <div class="usermenu">
+ <button class="userbtn" type="button" aria-haspopup="true" aria-expanded="false">
+ {{template "av" (dict "U" .User "S" 28)}}
+ <span class="username">{{.User.Display}}</span>
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
+ </button>
+ <div class="usermenu-pop" role="menu">
+ <div class="um-head">
+ {{template "av" (dict "U" .User "S" 40)}}
+ <div><strong>{{.User.Display}}</strong><span class="muted">@{{.User.Username}}</span></div>
+ </div>
+ <a href="/settings" role="menuitem">账号设置</a>
+ {{if .User.IsAdmin}}<a href="/admin" role="menuitem">管理后台</a>{{end}}
+ <form method="post" action="/logout">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit" role="menuitem" class="danger">退出登录</button>
+ </form>
+ </div>
+ </div>
+ {{else}}
+ <a class="btn btn-primary" href="/login">登录</a>
+ {{end}}
+ </div>
+ </nav>
+ </div>
+</header>
+
+<main class="page">
+ <div class="wrap">
+ {{template "flash" .}}
+ {{template "content" .}}
+ </div>
+</main>
+
+<footer class="foot">
+ <div class="wrap foot-in">
+ <span><strong>{{.SiteName}}</strong> · 工作室自建 Git 服务</span>
+ <span class="muted">Powered by gitcat {{.Version}}</span>
+ </div>
+</footer>
+<script src="/static/app.js" defer></script>
+</body>
+</html>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/login.html b/webroot/templates/login.html
new file mode 100644
index 0000000..a798d22
--- /dev/null
+++ b/webroot/templates/login.html
@@ -0,0 +1,36 @@
+{{define "content"}}
+<section class="auth">
+ <div class="card auth-card">
+ <div class="auth-brand">
+ <span class="brand-mark" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M4 8.5 8 4l4 3 4-3 4 4.5"/><path d="M6 12h12v4.5a3.5 3.5 0 0 1-3.5 3.5h-5A3.5 3.5 0 0 1 6 16.5z"/>
+ <circle cx="10" cy="15" r="0.6" fill="currentColor" stroke="none"/><circle cx="14" cy="15" r="0.6" fill="currentColor" stroke="none"/>
+ <path d="M12 18.2c.7.7 1.6.7 2.3 0"/>
+ </svg>
+ </span>
+ <div>
+ <h1>登录 {{.SiteName}}</h1>
+ <p class="muted">使用工作室账号登录后即可推送代码</p>
+ </div>
+ </div>
+ <form method="post" action="/login" autocomplete="off">
+ <input type="hidden" name="next" value="{{if .Data.Next}}{{.Data.Next}}{{end}}">
+ <label class="field">
+ <span>用户名</span>
+ <input type="text" name="username" required autofocus autocomplete="username" value="{{if .Data.Username}}{{.Data.Username}}{{end}}" placeholder="你的 gitcat 账号">
+ </label>
+ <label class="field">
+ <span>密码</span>
+ <input type="password" name="password" required autocomplete="current-password" placeholder="登录密码">
+ </label>
+ <button class="btn btn-primary btn-block btn-lg" type="submit">登录</button>
+ </form>
+ <p class="hint">还没有账号?本站不开放注册,请联系管理员在后台创建。</p>
+ <div class="tip">
+ <strong>git 推送提示</strong>
+ <p>命令行推送时,弹出账号密码输入框,填写同样的用户名和密码即可。</p>
+ </div>
+ </div>
+</section>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/new_project.html b/webroot/templates/new_project.html
new file mode 100644
index 0000000..a07bf5f
--- /dev/null
+++ b/webroot/templates/new_project.html
@@ -0,0 +1,38 @@
+{{define "content"}}
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <h1 class="card-title">新建项目</h1>
+ <p class="muted">新项目默认对所有人可见,工作室成员登录后即可推送代码。</p>
+ <form method="post" action="/new" autocomplete="off">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="field">
+ <span>项目名称</span>
+ <input type="text" name="name" required maxlength="64" placeholder="例如:studio-site" value="{{if .Data.Name}}{{.Data.Name}}{{end}}" autofocus>
+ <em class="field-hint">只允许字母、数字、点、下划线、短横线;创建后不建议修改。</em>
+ </label>
+ <label class="field">
+ <span>项目描述(可选)</span>
+ <textarea name="description" rows="3" maxlength="300" placeholder="一句话说明这个项目是做什么的">{{if .Data.Description}}{{.Data.Description}}{{end}}</textarea>
+ </label>
+ <button class="btn btn-primary btn-lg" type="submit">创建项目</button>
+ </form>
+ </div>
+ </div>
+ <aside class="col-side">
+ <div class="card tip-card">
+ <h3>创建后如何推送?</h3>
+ <ol class="steps">
+ <li><span class="step">1</span><div>在本地初始化仓库
+ <pre class="code">git init -b main
+git add .
+git commit -m "first commit"</pre></div></li>
+ <li><span class="step">2</span><div>关联 gitcat 远程地址
+ <pre class="code">git remote add origin http://{{"<服务器地址>"}}/你的项目名.git</pre></div></li>
+ <li><span class="step">3</span><div>推送(输入 gitcat 账号密码)
+ <pre class="code">git push -u origin main</pre></div></li>
+ </ol>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/profile.html b/webroot/templates/profile.html
new file mode 100644
index 0000000..f54a3f9
--- /dev/null
+++ b/webroot/templates/profile.html
@@ -0,0 +1,91 @@
+{{define "content"}}
+{{$me := .Data.Profile}}
+<div class="pagehead">
+ <h1>账号设置</h1>
+ <p class="muted">你可以修改自己的昵称、头像、简介与密码。用户名是登录凭据,不可更改。</p>
+</div>
+
+<div class="split">
+ <div class="col-main">
+ <div class="card profile-card">
+ <div class="profile-top">
+ {{template "av" (dict "U" $me "S" 84)}}
+ <div class="profile-id">
+ <h2>{{$me.Display}}</h2>
+ <p class="muted">@{{$me.Username}} · 注册于 {{date $me.CreatedAt}}{{if $me.LastLoginAt}} · 最近登录 {{timeago $me.LastLoginAt}}{{end}}</p>
+ <div class="profile-stats">
+ <span><strong>{{num .Data.MyRepoCount}}</strong> 个项目</span>
+ <span><strong>{{num .Data.MyPushCount}}</strong> 次推送</span>
+ {{if $me.IsAdmin}}<span class="tag tag-ok">管理员</span>{{end}}
+ </div>
+ </div>
+ </div>
+ <form class="avatar-form" method="post" action="/settings/avatar" enctype="multipart/form-data">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="field">
+ <span>更换头像</span>
+ <input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp">
+ <em class="field-hint">支持 PNG / JPG / GIF / WebP,不超过 2MB。</em>
+ </label>
+ <div class="row-actions">
+ <button class="btn btn-primary" type="submit">上传头像</button>
+ {{if $me.Avatar}}
+ <label class="check inline"><input type="checkbox" name="remove_avatar"><span>移除当前头像</span></label>
+ {{end}}
+ </div>
+ </form>
+ </div>
+
+ <div class="card">
+ <h3 class="card-title">基本资料</h3>
+ <form method="post" action="/settings/profile">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <div class="grid-2">
+ <label class="field"><span>昵称</span><input type="text" name="display_name" maxlength="40" value="{{$me.DisplayName}}" placeholder="展示给其他成员的名字"></label>
+ <label class="field"><span>邮箱(用于匹配 git 提交)</span><input type="email" name="email" maxlength="120" value="{{$me.Email}}" placeholder="you@studio.com"></label>
+ </div>
+ <label class="field"><span>个人简介</span><textarea name="bio" rows="3" maxlength="300" placeholder="一句话介绍自己">{{$me.Bio}}</textarea></label>
+ <button class="btn btn-primary" type="submit">保存资料</button>
+ </form>
+ </div>
+
+ <div class="card">
+ <h3 class="card-title">修改密码</h3>
+ <form method="post" action="/settings/password">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <div class="grid-3">
+ <label class="field"><span>当前密码</span><input type="password" name="current_password" required autocomplete="current-password"></label>
+ <label class="field"><span>新密码</span><input type="password" name="new_password" required minlength="6" autocomplete="new-password"></label>
+ <label class="field"><span>确认新密码</span><input type="password" name="new_password2" required minlength="6" autocomplete="new-password"></label>
+ </div>
+ <button class="btn btn-primary" type="submit">更新密码</button>
+ <p class="hint">修改后,git 推送时请使用新密码。</p>
+ </form>
+ </div>
+ </div>
+
+ <aside class="col-side">
+ <div class="card about">
+ <h3>我的最近推送</h3>
+ {{if .Data.MyRecent}}
+ <ul class="feed">
+ {{range .Data.MyRecent}}
+ <li class="feed-item">
+ <div class="feed-body">
+ <div class="feed-line"><a class="quiet" href="/{{.RepoName}}">{{.RepoName}}</a><span class="muted"> · {{.CommitCount}} 个提交</span></div>
+ <div class="feed-sub muted">{{timeago .CreatedAt}}{{if .WhatsNew}} · {{oneline .WhatsNew}}{{end}}</div>
+ </div>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="muted small">还没有推送记录。</p>{{end}}
+ </div>
+ <div class="card tip-card">
+ <h3>让提交认得出你</h3>
+ <p>把 git 的提交邮箱设置成上面填写的邮箱,贡献者统计就能把提交对应到你的账号:</p>
+ <pre class="code">git config --global user.name "{{$me.Display}}"
+git config --global user.email "{{if $me.Email}}{{$me.Email}}{{else}}you@studio.com{{end}}"</pre>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/repo_activity.html b/webroot/templates/repo_activity.html
new file mode 100644
index 0000000..8cb0dbb
--- /dev/null
+++ b/webroot/templates/repo_activity.html
@@ -0,0 +1,47 @@
+{{define "content"}}
+{{$repo := .Data.Repo}}
+<header class="repo-head compact">
+ <div class="repo-title">
+ <span class="repo-icon lg" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9l1.5 2H18a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 18 18H5.5A1.5 1.5 0 0 1 4 16.5z"/></svg>
+ </span>
+ <div><h1>{{$repo.Name}}</h1></div>
+ </div>
+</header>
+{{template "repotabs" (dict "Repo" $repo "Ref" .Data.Ref "Tab" "activity" "CanManage" .Data.CanManage "CommitCount" .Data.CommitCount "BranchCount" .Data.BranchCount)}}
+
+<div class="repo-grid">
+ <div class="repo-main">
+ <div class="section-head">
+ <h2>推送动态与 What's New</h2>
+ <span class="muted">谁在什么时候改了什么,都会记录在这里</span>
+ </div>
+ {{if .Data.Pushes}}
+ {{range .Data.Pushes}}{{template "pushbox" (dict "P" . "U" (index $.Data.PushActors .ActorUsername))}}{{end}}
+ {{else}}
+ <div class="card empty"><h3>还没有推送记录</h3><p class="muted">当成员推送第一个提交后,这里会自动出现更新说明。</p></div>
+ {{end}}
+ </div>
+ <aside class="repo-side">
+ {{with .Data.LatestPush}}
+ <div class="card whatsnew-card">
+ <div class="wn-head"><span class="whats-tag">最新 What's New</span></div>
+ <div class="md-body wn-body">{{if .WhatsNew}}{{md .WhatsNew}}{{else}}<span class="muted">生成中…</span>{{end}}</div>
+ </div>
+ {{end}}
+ <div class="card about">
+ <h3>仓库信息</h3>
+ <div class="stat-grid">
+ <div><strong>{{num .Data.CommitCount}}</strong><span>提交</span></div>
+ <div><strong>{{num .Data.BranchCount}}</strong><span>分支</span></div>
+ <div><strong>{{num .Data.TagCount}}</strong><span>标签</span></div>
+ <div><strong>{{.Data.SizeText}}</strong><span>体积</span></div>
+ </div>
+ <div class="clonebox side">
+ <span class="cloneurl">{{.Data.CloneURL}}</span>
+ <button class="btn btn-mini" type="button" data-copy="{{.Data.CloneURL}}">复制</button>
+ </div>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/repo_blob.html b/webroot/templates/repo_blob.html
new file mode 100644
index 0000000..60e7332
--- /dev/null
+++ b/webroot/templates/repo_blob.html
@@ -0,0 +1,45 @@
+{{define "content"}}
+{{$repo := .Data.Repo}}
+<header class="repo-head compact">
+ <div class="repo-title">
+ <span class="repo-icon lg" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9l1.5 2H18a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 18 18H5.5A1.5 1.5 0 0 1 4 16.5z"/></svg>
+ </span>
+ <div><h1>{{$repo.Name}}</h1></div>
+ </div>
+</header>
+{{template "repotabs" (dict "Repo" $repo "Ref" .Data.Ref "Tab" "code" "CanManage" .Data.CanManage "CommitCount" .Data.CommitCount "BranchCount" .Data.BranchCount)}}
+
+<div class="crumb-bar">
+ <a class="crumb" href="/{{$repo.Name}}/tree/{{.Data.Ref}}">{{$repo.Name}}</a><span class="sep">/</span>
+ {{range $i, $c := .Data.Breadcrumbs}}{{if $i}}<span class="sep">/</span>{{end}}{{if eq (add $i 1) (len $.Data.Breadcrumbs)}}<span class="crumb cur">{{$c.Name}}</span>{{else}}<a class="crumb" href="/{{$repo.Name}}/tree/{{$.Data.Ref}}/{{$c.Path}}">{{$c.Name}}</a>{{end}}{{end}}
+ <span class="crumb-actions">
+ <span class="muted">{{.Data.BlobSize}}{{if not .Data.IsBinary}} · {{num .Data.LineCount}} 行{{end}}</span>
+ <a class="btn btn-mini" href="/{{$repo.Name}}/raw/{{.Data.Ref}}/{{.Data.Path}}">原始文件</a>
+ </span>
+</div>
+
+{{if .Data.LastCommit}}
+<div class="filebox-head standalone">
+ {{template "avin" (dict "N" .Data.LastCommit.Author "S" 26)}}
+ <span class="fc-author">{{.Data.LastCommit.Author}}</span>
+ <a class="fc-subject" href="/{{$repo.Name}}/commit/{{.Data.LastCommit.SHA}}">{{.Data.LastCommit.Subject}}</a>
+ <span class="muted">{{timeago .Data.LastCommit.When}}</span>
+</div>
+{{end}}
+
+<div class="card fileview">
+ {{if .Data.TooLarge}}
+ <div class="empty small"><p class="muted">文件过大,暂不在页面中预览。</p><a class="btn btn-ghost" href="/{{$repo.Name}}/raw/{{.Data.Ref}}/{{.Data.Path}}">下载原始文件</a></div>
+ {{else if .Data.IsImage}}
+ <div class="imgview"><img src="/{{$repo.Name}}/raw/{{.Data.Ref}}/{{.Data.Path}}" alt="{{.Data.FileName}}"></div>
+ {{else if .Data.IsBinary}}
+ <div class="empty small"><p class="muted">这是一个二进制文件,无法直接预览。</p><a class="btn btn-ghost" href="/{{$repo.Name}}/raw/{{.Data.Ref}}/{{.Data.Path}}">下载文件</a></div>
+ {{else}}
+ <div class="codeview">
+ {{range $i, $line := .Data.Lines}}<div class="codeline"><span class="ln">{{add $i 1}}</span><span class="lc">{{$line}}</span></div>{{end}}
+ {{if .Data.TruncatedLines}}<div class="codeline more"><span class="ln">…</span><span class="lc">文件过长,仅显示前 5000 行</span></div>{{end}}
+ </div>
+ {{end}}
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/repo_branches.html b/webroot/templates/repo_branches.html
new file mode 100644
index 0000000..6ef9470
--- /dev/null
+++ b/webroot/templates/repo_branches.html
@@ -0,0 +1,59 @@
+{{define "content"}}
+{{$repo := .Data.Repo}}
+<header class="repo-head compact">
+ <div class="repo-title">
+ <span class="repo-icon lg" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9l1.5 2H18a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 18 18H5.5A1.5 1.5 0 0 1 4 16.5z"/></svg>
+ </span>
+ <div><h1>{{$repo.Name}}</h1></div>
+ </div>
+</header>
+{{template "repotabs" (dict "Repo" $repo "Ref" .Data.Ref "Tab" "branches" "CanManage" .Data.CanManage "CommitCount" .Data.CommitCount "BranchCount" .Data.BranchCount)}}
+
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <h3 class="card-title">分支 <span class="cnt">{{num .Data.BranchCount}}</span></h3>
+ {{if .Data.Branches}}
+ <ul class="reflist">
+ {{range .Data.Branches}}
+ <li>
+ <div class="ref-main">
+ <a class="ref-name" href="/{{$repo.Name}}/tree/{{.Name}}">{{.Name}}</a>
+ {{if eq .Name $repo.DefaultBranch}}<span class="tag tag-ok">默认</span>{{end}}
+ <div class="ref-sub">{{.Subject}}</div>
+ </div>
+ <div class="ref-side">
+ <span class="muted">{{timeago .When}}</span>
+ <code class="csha">{{short .SHA}}</code>
+ </div>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="muted">还没有分支,推送第一个提交后会自动出现。</p>{{end}}
+ </div>
+
+ <div class="card">
+ <h3 class="card-title">标签 <span class="cnt">{{num .Data.TagCount}}</span></h3>
+ {{if .Data.Tags}}
+ <ul class="reflist">
+ {{range .Data.Tags}}
+ <li>
+ <div class="ref-main"><a class="ref-name" href="/{{$repo.Name}}/commit/{{.SHA}}">{{.Name}}</a><div class="ref-sub">{{.Subject}}</div></div>
+ <div class="ref-side"><span class="muted">{{timeago .When}}</span><code class="csha">{{short .SHA}}</code></div>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="muted">还没有标签。发布版本时可以在本地执行 <code class="csha">git tag v1.0 && git push --tags</code>。</p>{{end}}
+ </div>
+ </div>
+ <aside class="col-side">
+ <div class="card about">
+ <h3>快速开始</h3>
+ <pre class="code">git clone {{.Data.CloneURL}}
+cd {{$repo.Name}}</pre>
+ <p class="hint">推送前请确保已登录 gitcat 账号。</p>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/repo_commit.html b/webroot/templates/repo_commit.html
new file mode 100644
index 0000000..2aca5a7
--- /dev/null
+++ b/webroot/templates/repo_commit.html
@@ -0,0 +1,49 @@
+{{define "content"}}
+{{$repo := .Data.Repo}}
+{{$c := .Data.Commit}}
+<div class="card commit-detail">
+ <div class="cd-head">
+ <h1 class="cd-subject">{{$c.Subject}}</h1>
+ <div class="cd-meta">
+ {{template "avin" (dict "N" $c.Author "S" 30)}}
+ <span><strong>{{$c.Author}}</strong> 提交于 {{datetime $c.When}}</span>
+ <span class="dot">·</span>
+ <code class="csha big">{{short $c.SHA}}</code>
+ {{if .Data.Parent}}<span class="dot">·</span><span class="muted">父提交 <a class="csha" href="/{{$repo.Name}}/commit/{{short .Data.Parent}}">{{short .Data.Parent}}</a></span>{{end}}
+ </div>
+ </div>
+
+ {{if $c.Message}}
+ <div class="cd-message"><pre class="code">{{$c.Message}}</pre></div>
+ {{end}}
+
+ <div class="cd-stats">
+ <span>{{num .Data.FileChanged}} 个文件变更</span>
+ <span class="add">+{{.Data.Additions}}</span>
+ <span class="del">-{{.Data.Deletions}}</span>
+ <span class="muted">{{$c.Email}}</span>
+ </div>
+
+ <table class="filetable diffstat">
+ <tbody>
+ {{range $c.Files}}
+ <tr>
+ <td class="ft-name"><span class="ico file">•</span>{{.Path}}</td>
+ <td class="ft-size">
+ {{if .Binary}}<span class="muted">二进制</span>
+ {{else}}<span class="add">+{{.Additions}}</span> <span class="del">-{{.Deletions}}</span>{{end}}
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+</div>
+
+<div class="card diffbox">
+ <div class="diffbox-head">
+ <span>变更内容</span>
+ {{if $c.Truncated}}<span class="tag tag-warn">已截断显示(文件过大)</span>{{end}}
+ </div>
+ <pre class="diff">{{$c.Diff}}</pre>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/repo_commits.html b/webroot/templates/repo_commits.html
new file mode 100644
index 0000000..247fffa
--- /dev/null
+++ b/webroot/templates/repo_commits.html
@@ -0,0 +1,75 @@
+{{define "content"}}
+{{$repo := .Data.Repo}}
+<header class="repo-head compact">
+ <div class="repo-title">
+ <span class="repo-icon lg" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9l1.5 2H18a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 18 18H5.5A1.5 1.5 0 0 1 4 16.5z"/></svg>
+ </span>
+ <div><h1>{{$repo.Name}}</h1></div>
+ </div>
+</header>
+{{template "repotabs" (dict "Repo" $repo "Ref" .Data.Ref "Tab" "commits" "CanManage" .Data.CanManage "CommitCount" .Data.CommitCount "BranchCount" .Data.BranchCount)}}
+
+<div class="repo-grid">
+ <div class="repo-main">
+ <div class="list-head">
+ <div class="branchpick">
+ <span class="bp-label">分支</span>
+ <code class="ref">{{.Data.Ref}}</code>
+ </div>
+ <span class="muted">共 {{num .Data.Total}} 次提交</span>
+ </div>
+ <div class="card commitbox">
+ <ul class="commitrows">
+ {{range .Data.Commits}}
+ <li>
+ {{template "avin" (dict "N" .Author "S" 30)}}
+ <div class="crow-main">
+ <a class="crow-subject" href="/{{$repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a>
+ <div class="crow-meta">
+ <span>{{.Author}}</span><span class="dot">·</span>
+ <span title="{{datetime .When}}">{{timeago .When}}</span>
+ {{if .IsMerge}}<span class="tag tag-plain">merge</span>{{end}}
+ </div>
+ </div>
+ <div class="crow-actions">
+ <code class="csha">{{short .SHA}}</code>
+ <a class="btn btn-mini" href="/{{$repo.Name}}/tree/{{short .SHA}}">浏览</a>
+ </div>
+ </li>
+ {{else}}
+ <li class="crow-empty">
+ <div class="crow-main">
+ <span class="muted">这个仓库还没有提交记录,推送第一个版本后就会出现在这里。</span>
+ <div class="crow-meta"><a href="/{{$repo.Name}}">返回仓库主页查看推送指引 →</a></div>
+ </div>
+ </li>
+ {{end}}
+ </ul>
+ </div>
+ {{if or .Data.HasPrev .Data.HasNext}}
+ <nav class="pager">
+ {{if .Data.HasPrev}}<a class="btn btn-ghost" href="/{{$repo.Name}}/commits/{{.Data.Ref}}?page={{.Data.PrevPage}}">← 上一页</a>{{end}}
+ <span class="muted">第 {{.Data.Page}} 页</span>
+ {{if .Data.HasNext}}<a class="btn btn-ghost" href="/{{$repo.Name}}/commits/{{.Data.Ref}}?page={{.Data.NextPage}}">下一页 →</a>{{end}}
+ </nav>
+ {{end}}
+ </div>
+ <aside class="repo-side">
+ <div class="card about">
+ <h3>切换分支</h3>
+ <ul class="branchlist">
+ {{range .Data.Branches}}
+ <li class="{{if eq .Name $.Data.Ref}}on{{end}}"><a href="/{{$repo.Name}}/commits/{{.Name}}">{{.Name}}</a><span class="muted">{{timeago .When}}</span></li>
+ {{end}}
+ </ul>
+ {{if .Data.Tags}}
+ <h3>标签</h3>
+ <ul class="branchlist">
+ {{range .Data.Tags}}<li><a href="/{{$repo.Name}}/commit/{{.SHA}}">{{.Name}}</a><span class="muted">{{timeago .When}}</span></li>{{end}}
+ </ul>
+ {{end}}
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/repo_home.html b/webroot/templates/repo_home.html
new file mode 100644
index 0000000..55594a4
--- /dev/null
+++ b/webroot/templates/repo_home.html
@@ -0,0 +1,133 @@
+{{define "content"}}
+{{$repo := .Data.Repo}}
+<header class="repo-head">
+ <div class="repo-title">
+ <span class="repo-icon lg" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9l1.5 2H18a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 18 18H5.5A1.5 1.5 0 0 1 4 16.5z"/></svg>
+ </span>
+ <div>
+ <h1>{{$repo.Name}}{{if $repo.IsArchived}}<span class="tag tag-warn">已归档</span>{{end}}{{if not $repo.AIEnabled}}<span class="tag tag-plain">AI 摘要已关闭</span>{{end}}</h1>
+ {{if $repo.Description}}<p class="repo-sub">{{$repo.Description}}</p>{{else}}<p class="repo-sub muted">暂无项目描述{{if .Data.CanManage}} · <a class="quiet" href="/{{$repo.Name}}/settings">去添加</a>{{end}}</p>{{end}}
+ </div>
+ </div>
+ <div class="repo-actions">
+ <div class="clonebox">
+ <span class="clonebox-label">clone</span>
+ <input type="text" readonly size="{{len .Data.CloneURL}}" value="{{.Data.CloneURL}}" aria-label="仓库地址">
+ <button class="btn btn-mini" type="button" data-copy="{{.Data.CloneURL}}">复制</button>
+ </div>
+ </div>
+</header>
+
+{{template "repotabs" (dict "Repo" $repo "Ref" .Data.Ref "Tab" "code" "CanManage" .Data.CanManage "CommitCount" .Data.CommitCount "BranchCount" .Data.BranchCount)}}
+
+<div class="repo-grid">
+ <div class="repo-main">
+ {{if .Data.IsEmpty}}
+ <div class="card empty">
+ <h3>仓库还是空的</h3>
+ <p class="muted">在本地执行下面的命令,把第一个版本推上来。</p>
+ <pre class="code">git init -b main
+git add .
+git commit -m "first commit"
+git remote add origin {{.Data.CloneURL}}
+git push -u origin main</pre>
+ {{if not .User}}<p class="hint">推送时需要登录:账号由管理员创建,<a href="/login">点此登录</a>。</p>{{end}}
+ </div>
+ {{else}}
+ {{with .Data.Path}}
+ <div class="crumb-bar">
+ {{range $.Data.Breadcrumbs}}<a class="crumb" href="/{{$repo.Name}}/tree/{{$.Data.Ref}}/{{.Path}}">{{.Name}}</a><span class="sep">/</span>{{end}}
+ </div>
+ {{end}}
+ <div class="card filebox">
+ <div class="filebox-head">
+ {{if .Data.LastCommit}}
+ {{template "avin" (dict "N" .Data.LastCommit.Author "S" 26)}}
+ <span class="fc-author">{{.Data.LastCommit.Author}}</span>
+ <a class="fc-subject" href="/{{$repo.Name}}/commit/{{.Data.LastCommit.SHA}}">{{.Data.LastCommit.Subject}}</a>
+ <span class="muted">{{timeago .Data.LastCommit.When}}</span>
+ {{else}}<span class="muted">目录内容</span>{{end}}
+ </div>
+ <table class="filetable">
+ <tbody>
+ {{range .Data.Entries}}
+ <tr>
+ <td class="ft-name">
+ {{if .IsDir}}
+ <a href="/{{$repo.Name}}/tree/{{$.Data.Ref}}/{{.Path}}" class="ft-link"><span class="ico dir">▸</span>{{.Name}}</a>
+ {{else}}
+ <a href="/{{$repo.Name}}/blob/{{$.Data.Ref}}/{{.Path}}" class="ft-link"><span class="ico file">•</span>{{.Name}}</a>
+ {{end}}
+ </td>
+ <td class="ft-commit">
+ {{if .HasCommit}}<a class="quiet" href="/{{$repo.Name}}/commit/{{.LastCommit.SHA}}">{{.LastCommit.Subject}}</a>{{end}}
+ </td>
+ <td class="ft-time">{{if .HasCommit}}{{timeago .LastCommit.When}}{{end}}</td>
+ <td class="ft-size">{{if not .IsDir}}{{humanSize .Size}}{{end}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+
+ {{if .Data.ReadmeName}}
+ <div class="card readme">
+ <div class="readme-head"><span class="ico file">•</span>{{.Data.ReadmeName}}</div>
+ <div class="md-body">
+ {{if .Data.ReadmeIsMarkdown}}{{.Data.ReadmeHTML}}{{else}}<pre class="code">{{.Data.ReadmeText}}</pre>{{end}}
+ </div>
+ </div>
+ {{end}}
+ {{end}}
+ </div>
+
+ <aside class="repo-side">
+ {{with .Data.LatestPush}}
+ <div class="card whatsnew-card">
+ <div class="wn-head">
+ <span class="whats-tag">What's New</span>
+ {{if eq .AIStatus "done"}}<span class="tag tag-ai">AI 生成</span>{{else if eq .AIStatus "pending"}}<span class="tag tag-pending">生成中…</span>{{end}}
+ </div>
+ {{if .WhatsNew}}<div class="md-body wn-body">{{md .WhatsNew}}</div>{{else}}<p class="muted small">AI 正在生成这次更新的说明…</p>{{end}}
+ <div class="wn-foot muted">{{.ActorDisplay}} · {{timeago .CreatedAt}} · <a class="quiet" href="/{{$repo.Name}}/activity">全部动态</a></div>
+ </div>
+ {{end}}
+
+ <div class="card about">
+ <h3>关于</h3>
+ <p>{{if $repo.Description}}{{$repo.Description}}{{else}}<span class="muted">暂无描述</span>{{end}}</p>
+ <div class="stat-grid">
+ <div><strong>{{num .Data.CommitCount}}</strong><span>提交</span></div>
+ <div><strong>{{num .Data.BranchCount}}</strong><span>分支</span></div>
+ <div><strong>{{num .Data.TagCount}}</strong><span>标签</span></div>
+ <div><strong>{{.Data.SizeText}}</strong><span>体积</span></div>
+ <div><strong>{{num .Data.FileCount}}</strong><span>文件</span></div>
+ <div><strong>{{len .Data.Contributors}}</strong><span>贡献者</span></div>
+ </div>
+ {{if .Data.Languages}}
+ <div class="langs">
+ <div class="langbar">
+ {{range .Data.Languages}}<span style="flex:{{percent .Percent}};background:{{.Color}}" title="{{.Name}} {{percent .Percent}}%"></span>{{end}}
+ </div>
+ <ul class="langlist">
+ {{range .Data.Languages}}<li><span class="langdot" style="background:{{.Color}}"></span>{{.Name}} <em>{{percent .Percent}}%</em></li>{{end}}
+ </ul>
+ </div>
+ {{end}}
+ {{if .Data.Contributors}}
+ <h3>贡献者</h3>
+ <ul class="contrib">
+ {{range .Data.Contributors}}<li>{{template "avin" (dict "N" .Name "S" 22)}}<span>{{.Name}}</span><em>{{.Count}}</em></li>{{end}}
+ </ul>
+ {{end}}
+ <h3>最近提交</h3>
+ <ul class="minicommits">
+ {{range .Data.LatestCommits}}
+ <li><a class="csha" href="/{{$repo.Name}}/commit/{{.SHA}}">{{short .SHA}}</a><span class="cmsg">{{.Subject}}</span><span class="muted">{{timeago .When}}</span></li>
+ {{end}}
+ </ul>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/templates/repo_settings.html b/webroot/templates/repo_settings.html
new file mode 100644
index 0000000..2821661
--- /dev/null
+++ b/webroot/templates/repo_settings.html
@@ -0,0 +1,54 @@
+{{define "content"}}
+{{$repo := .Data.Repo}}
+<header class="repo-head compact">
+ <div class="repo-title">
+ <span class="repo-icon lg" aria-hidden="true">
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9l1.5 2H18a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 18 18H5.5A1.5 1.5 0 0 1 4 16.5z"/></svg>
+ </span>
+ <div><h1>{{$repo.Name}}</h1></div>
+ </div>
+</header>
+{{template "repotabs" (dict "Repo" $repo "Ref" .Data.Ref "Tab" "settings" "CanManage" .Data.CanManage "CommitCount" .Data.CommitCount "BranchCount" .Data.BranchCount)}}
+
+<div class="split">
+ <div class="col-main">
+ <div class="card">
+ <h3 class="card-title">项目设置</h3>
+ <form method="post" action="/{{$repo.Name}}/settings">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="field">
+ <span>项目描述</span>
+ <textarea name="description" rows="3" maxlength="300" placeholder="一句话说明这个项目">{{$repo.Description}}</textarea>
+ </label>
+ <label class="field">
+ <span>默认分支</span>
+ <select name="default_branch">
+ {{range .Data.Branches}}<option value="{{.Name}}" {{if eq .Name $repo.DefaultBranch}}selected{{end}}>{{.Name}}</option>{{end}}
+ </select>
+ <em class="field-hint">克隆与项目主页默认展示这个分支。</em>
+ </label>
+ <label class="check">
+ <input type="checkbox" name="ai_enabled" {{if $repo.AIEnabled}}checked{{end}}>
+ <span>推送后自动生成「What's New」<em>需要管理员在后台配置 AI 接口</em></span>
+ </label>
+ <label class="check">
+ <input type="checkbox" name="is_archived" {{if $repo.IsArchived}}checked{{end}}>
+ <span>归档项目(只读)<em>归档后将拒绝所有推送,但访客仍可浏览</em></span>
+ </label>
+ <button class="btn btn-primary" type="submit">保存设置</button>
+ </form>
+ </div>
+ </div>
+ <aside class="col-side">
+ <div class="card danger-card">
+ <h3>危险操作</h3>
+ <p class="muted">删除后仓库数据不可恢复,请输入完整项目名确认。</p>
+ <form method="post" action="/{{$repo.Name}}/delete">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="confirm" placeholder="{{$repo.Name}}" autocomplete="off">
+ <button class="btn btn-danger" type="submit">永久删除该项目</button>
+ </form>
+ </div>
+ </aside>
+</div>
+{{end}}
\ No newline at end of file
diff --git a/webroot/webroot.go b/webroot/webroot.go
new file mode 100644
index 0000000..8e102d8
--- /dev/null
+++ b/webroot/webroot.go
@@ -0,0 +1,9 @@
+// Package webroot 通过 embed 把模板与静态资源打包进二进制,方便单文件部署。
+package webroot
+
+import "embed"
+
+// FS 内嵌了 templates 与 static 目录。
+//
+//go:embed templates static
+var FS embed.FS