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) }