仰望星辰工作室

init: gitcat 自建 Git 服务源码

F fqh 提交于 2026-10-02 16:03 · b6a0ede
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 &amp;&amp; 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