@@ -62,6 +72,17 @@ export default function ProjectsPage() {
论坛会员在 Gitea 上的公开仓库
{total > 0 ? ` · 共 ${total} 个` : ''}
+
{loading ? (
@@ -69,38 +90,18 @@ export default function ProjectsPage() {
) : list.length === 0 ? (
+
{list.map(p => (
-
-
-
{p.full_name || p.name}
- {p.description ? (
-
{p.description}
- ) : null}
-
- {p.owner_login}
- {p.updated_at_remote && (
- 更新于 {formatRemoteTime(p.updated_at_remote)}
- )}
-
-
-
- 在 Gitea 打开
-
-
-
+
))}
{totalPages > 1 && (
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 72b8efb..77f9425 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -4132,57 +4132,64 @@ a.post-title:visited {
margin-top: 12px;
}
-.projects-list .project-row {
+.projects-search {
display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- padding: 16px 18px;
- border-bottom: 1px solid var(--color-border);
+ align-items: center;
+ gap: 8px;
+ margin-top: 12px;
+ max-width: 420px;
+ padding: 0 12px;
+ height: 36px;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--color-bg);
}
-.projects-list .project-row:last-child {
- border-bottom: none;
+.projects-search__icon {
+ flex-shrink: 0;
+ color: var(--color-text-4);
}
-.project-row-body {
- min-width: 0;
+.projects-search__input {
flex: 1;
-}
-.project-row-title {
- margin: 0 0 6px;
- font-size: 15px;
- font-weight: 600;
+ min-width: 0;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-size: 13px;
color: var(--color-text);
}
-.project-row-desc {
- margin: 0 0 8px;
- font-size: 13px;
- line-height: 1.5;
- color: var(--color-text-2);
- display: -webkit-box;
+.projects-search__input::placeholder {
+ color: var(--color-text-4);
+}
+
+/* 码桶行复用帖子列表结构,仅补充语言徽标与徽章间距 */
+.projects-list .project-list-item .post-excerpt {
-webkit-line-clamp: 2;
+ display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
-.project-row-meta {
- display: flex;
+.projects-list .project-list-item .post-meta-left {
flex-wrap: wrap;
- gap: 12px;
- font-size: 12px;
- color: var(--color-text-3);
+ gap: 4px 6px;
+ overflow: visible;
}
-.project-row-link {
+.projects-list .project-list-item .post-meta-author {
+ max-width: none;
+}
+.project-lang-badge {
display: inline-flex;
align-items: center;
- gap: 6px;
flex-shrink: 0;
- font-size: 13px;
- color: var(--color-primary);
- text-decoration: none;
- white-space: nowrap;
- padding-top: 2px;
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+ color: var(--color-text-2);
+ background: color-mix(in srgb, var(--color-border) 45%, transparent);
}
-.project-row-link:hover {
- text-decoration: underline;
+.project-list-badges {
+ margin-left: 2px;
}
.projects-pager {
display: flex;
diff --git a/handler/api.go b/handler/api.go
index 698dbd0..3887d5d 100644
--- a/handler/api.go
+++ b/handler/api.go
@@ -730,6 +730,7 @@ func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) {
func (h *Handlers) APIProjects(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("limit", c.DefaultQuery("size", "30")))
+ q := strings.TrimSpace(c.Query("q"))
if page < 1 {
page = 1
}
@@ -743,11 +744,12 @@ func (h *Handlers) APIProjects(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"projects": []any{}, "total": 0, "page": page, "total_pages": 0})
return
}
- list, total, err := h.Gitea.ListPublic(page, size)
+ list, total, err := h.Gitea.ListPublic(page, size, q)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
return
}
+ list = service.AttachGiteaOwners(list, h.Badge)
c.JSON(http.StatusOK, gin.H{
"projects": list,
"total": total,
diff --git a/model/gitea.go b/model/gitea.go
index bc12b47..5ceacbe 100644
--- a/model/gitea.go
+++ b/model/gitea.go
@@ -4,19 +4,22 @@ import "time"
// GiteaRepo 从 Gitea 同步的公开仓库缓存(侧栏 /projects 读取)
type GiteaRepo struct {
- ID uint `gorm:"primaryKey" json:"id"`
- GiteaID int64 `gorm:"uniqueIndex;not null" json:"gitea_id"`
- OwnerLogin string `gorm:"size:128;not null;index" json:"owner_login"`
- Name string `gorm:"size:255;not null" json:"name"`
- FullName string `gorm:"size:512;not null" json:"full_name"`
- Description string `gorm:"size:2048;default:''" json:"description"`
- HTMLURL string `gorm:"size:1024;not null" json:"html_url"`
- Private bool `gorm:"default:false" json:"private"`
- UpdatedAtRemote *time.Time `json:"updated_at_remote"`
- ForumUserID *uint `gorm:"index" json:"forum_user_id"`
- SyncedAt time.Time `json:"synced_at"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ GiteaID int64 `gorm:"uniqueIndex;not null" json:"gitea_id"`
+ OwnerLogin string `gorm:"size:128;not null;index" json:"owner_login"`
+ Name string `gorm:"size:255;not null" json:"name"`
+ FullName string `gorm:"size:512;not null" json:"full_name"`
+ Description string `gorm:"size:2048;default:''" json:"description"`
+ HTMLURL string `gorm:"size:1024;not null" json:"html_url"`
+ Language string `gorm:"size:64;default:''" json:"language"`
+ StarsCount int `gorm:"default:0" json:"stars_count"`
+ ForksCount int `gorm:"default:0" json:"forks_count"`
+ Private bool `gorm:"default:false" json:"private"`
+ UpdatedAtRemote *time.Time `json:"updated_at_remote"`
+ ForumUserID *uint `gorm:"index" json:"forum_user_id"`
+ SyncedAt time.Time `json:"synced_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
func (GiteaRepo) TableName() string {
diff --git a/service/gitea.go b/service/gitea.go
index 12b378c..77a9526 100644
--- a/service/gitea.go
+++ b/service/gitea.go
@@ -21,18 +21,34 @@ var (
ErrGiteaSyncBusy = errors.New("同步正在进行中,请稍后再试")
)
+// GiteaOwnerView 仓库关联的论坛用户摘要(列表展示头像/徽标)
+type GiteaOwnerView struct {
+ ID uint `json:"id"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ Role model.Role `json:"role"`
+ Verified bool `json:"verified"`
+ Exp int `json:"exp"`
+ Level int `json:"level"`
+ Badges []model.UserBadgeView `json:"badges,omitempty"`
+}
+
// GiteaRepoView 前台展示
type GiteaRepoView struct {
- ID uint `json:"id"`
- GiteaID int64 `json:"gitea_id"`
- OwnerLogin string `json:"owner_login"`
- Name string `json:"name"`
- FullName string `json:"full_name"`
- Description string `json:"description"`
- HTMLURL string `json:"html_url"`
- UpdatedAtRemote *time.Time `json:"updated_at_remote"`
- ForumUserID *uint `json:"forum_user_id,omitempty"`
- SyncedAt time.Time `json:"synced_at"`
+ ID uint `json:"id"`
+ GiteaID int64 `json:"gitea_id"`
+ OwnerLogin string `json:"owner_login"`
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ Description string `json:"description"`
+ HTMLURL string `json:"html_url"`
+ Language string `json:"language"`
+ StarsCount int `json:"stars_count"`
+ ForksCount int `json:"forks_count"`
+ UpdatedAtRemote *time.Time `json:"updated_at_remote"`
+ ForumUserID *uint `json:"forum_user_id,omitempty"`
+ Owner *GiteaOwnerView `json:"owner,omitempty"`
+ SyncedAt time.Time `json:"synced_at"`
}
// GiteaService 从 Gitea API 同步会员公开仓库
@@ -92,8 +108,8 @@ func (g *GiteaService) Stop() {
g.wg.Wait()
}
-// ListPublic 列出已同步的公开仓库
-func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error) {
+// ListPublic 列出已绑定论坛用户的公开仓库;q 模糊匹配仓库字段与论坛昵称/用户名
+func (g *GiteaService) ListPublic(page, size int, q string) ([]GiteaRepoView, int64, error) {
if page < 1 {
page = 1
}
@@ -103,14 +119,27 @@ func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error
if size > 100 {
size = 100
}
+ // 打开列表时自愈:按 owner_login≈username 回填缺失的 forum_user_id
+ BackfillForumUserIDs()
+
+ q = strings.TrimSpace(q)
+ db := model.DB.Model(&model.GiteaRepo{}).Where("private = ? AND forum_user_id IS NOT NULL AND forum_user_id > 0", false)
+ if q != "" {
+ like := "%" + escapeLikePattern(q) + "%"
+ db = db.Where(
+ `(full_name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\' OR owner_login LIKE ? ESCAPE '\'
+ OR forum_user_id IN (
+ SELECT id FROM users WHERE nickname LIKE ? ESCAPE '\' OR username LIKE ? ESCAPE '\'
+ ))`,
+ like, like, like, like, like,
+ )
+ }
var total int64
- q := model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false)
- if err := q.Count(&total).Error; err != nil {
+ if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.GiteaRepo
- err := model.DB.Where("private = ?", false).
- Order("updated_at_remote desc, id desc").
+ err := db.Order("updated_at_remote desc, id desc").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error
@@ -124,6 +153,176 @@ func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error
return out, total, nil
}
+// BackfillForumUserIDs 将缺失 forum_user_id 的公开仓按 owner_login(忽略大小写)匹配论坛 username
+func BackfillForumUserIDs() int {
+ var rows []model.GiteaRepo
+ if err := model.DB.Where("private = ? AND (forum_user_id IS NULL OR forum_user_id = 0)", false).
+ Find(&rows).Error; err != nil || len(rows) == 0 {
+ return 0
+ }
+ var users []model.User
+ if err := model.DB.Select("id", "username").Where("banned = ?", false).Find(&users).Error; err != nil || len(users) == 0 {
+ return 0
+ }
+ byLogin := make(map[string]uint, len(users))
+ for _, u := range users {
+ key := strings.ToLower(strings.TrimSpace(u.Username))
+ if key == "" {
+ continue
+ }
+ byLogin[key] = u.ID
+ }
+ n := 0
+ for i := range rows {
+ key := strings.ToLower(strings.TrimSpace(rows[i].OwnerLogin))
+ uid, ok := byLogin[key]
+ if !ok || uid == 0 {
+ continue
+ }
+ if err := model.DB.Model(&rows[i]).Update("forum_user_id", uid).Error; err != nil {
+ log.Printf("[gitea] 回填 forum_user_id 失败 repo=%s: %v", rows[i].FullName, err)
+ continue
+ }
+ n++
+ }
+ if n > 0 {
+ log.Printf("[gitea] 回填 forum_user_id:%d 条", n)
+ }
+ return n
+}
+
+// AttachGiteaOwners 为列表项批量填充论坛用户摘要;丢弃无法解析到论坛用户的条目。
+// 优先 forum_user_id;缺失时按 owner_login≈username 兜底,并回写 forum_user_id。
+func AttachGiteaOwners(list []GiteaRepoView, badge *BadgeService) []GiteaRepoView {
+ if len(list) == 0 {
+ return list
+ }
+
+ idSet := make(map[uint]struct{})
+ ids := make([]uint, 0, len(list))
+ loginSet := make(map[string]struct{})
+ logins := make([]string, 0, len(list))
+ for _, item := range list {
+ if item.ForumUserID != nil && *item.ForumUserID > 0 {
+ id := *item.ForumUserID
+ if _, ok := idSet[id]; !ok {
+ idSet[id] = struct{}{}
+ ids = append(ids, id)
+ }
+ continue
+ }
+ key := strings.ToLower(strings.TrimSpace(item.OwnerLogin))
+ if key == "" {
+ continue
+ }
+ if _, ok := loginSet[key]; ok {
+ continue
+ }
+ loginSet[key] = struct{}{}
+ logins = append(logins, key)
+ }
+
+ byID := make(map[uint]*model.User)
+ byLogin := make(map[string]*model.User)
+ ptrs := make([]*model.User, 0, len(ids)+len(logins))
+
+ if len(ids) > 0 {
+ var users []model.User
+ if err := model.DB.Where("id IN ? AND banned = ?", ids, false).Find(&users).Error; err == nil {
+ for i := range users {
+ u := &users[i]
+ byID[u.ID] = u
+ ptrs = append(ptrs, u)
+ key := strings.ToLower(strings.TrimSpace(u.Username))
+ if key != "" {
+ byLogin[key] = u
+ }
+ }
+ }
+ }
+ if len(logins) > 0 {
+ var users []model.User
+ if err := model.DB.Where("banned = ? AND LOWER(username) IN ?", false, logins).Find(&users).Error; err == nil {
+ for i := range users {
+ u := &users[i]
+ key := strings.ToLower(strings.TrimSpace(u.Username))
+ if key == "" {
+ continue
+ }
+ if _, exists := byLogin[key]; exists {
+ continue
+ }
+ byLogin[key] = u
+ byID[u.ID] = u
+ ptrs = append(ptrs, u)
+ }
+ }
+ }
+
+ if len(ptrs) == 0 {
+ return nil
+ }
+ // 去重 ptrs
+ seenPtr := make(map[uint]struct{}, len(ptrs))
+ uniq := make([]*model.User, 0, len(ptrs))
+ for _, u := range ptrs {
+ if u == nil || u.ID == 0 {
+ continue
+ }
+ if _, ok := seenPtr[u.ID]; ok {
+ continue
+ }
+ seenPtr[u.ID] = struct{}{}
+ uniq = append(uniq, u)
+ }
+ if badge != nil {
+ badge.AttachBadgeSummaries(uniq, 3)
+ } else {
+ for _, u := range uniq {
+ u.Level = model.LevelFromExp(u.Exp)
+ }
+ }
+
+ out := make([]GiteaRepoView, 0, len(list))
+ for i := range list {
+ item := list[i]
+ var u *model.User
+ if item.ForumUserID != nil && *item.ForumUserID > 0 {
+ u = byID[*item.ForumUserID]
+ }
+ if u == nil {
+ key := strings.ToLower(strings.TrimSpace(item.OwnerLogin))
+ u = byLogin[key]
+ if u != nil {
+ uid := u.ID
+ item.ForumUserID = &uid
+ // 回写缺失关联,便于下次列表过滤命中
+ _ = model.DB.Model(&model.GiteaRepo{}).Where("id = ?", item.ID).
+ Update("forum_user_id", uid).Error
+ }
+ }
+ if u == nil {
+ continue
+ }
+ nick := strings.TrimSpace(u.Nickname)
+ if nick == "" {
+ nick = u.Username
+ }
+ item.Owner = &GiteaOwnerView{
+ ID: u.ID,
+ Nickname: nick,
+ Avatar: u.Avatar,
+ Role: u.Role,
+ Verified: u.Verified,
+ Exp: u.Exp,
+ Level: model.LevelFromExp(u.Exp),
+ Badges: u.Badges,
+ }
+ out = append(out, item)
+ }
+ return out
+}
+
// SyncRepos 按论坛用户名拉取 Gitea 公开仓并 upsert
func (g *GiteaService) SyncRepos() (int, error) {
cfg := g.settings.GiteaSyncConfig()
@@ -144,6 +343,9 @@ func (g *GiteaService) SyncRepos() (int, error) {
g.mu.Unlock()
}()
+ // 同步前先回填历史缺失关联
+ BackfillForumUserIDs()
+
var users []model.User
if err := model.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil {
return 0, err
@@ -183,6 +385,9 @@ func (g *GiteaService) SyncRepos() (int, error) {
FullName: gr.FullName,
Description: truncStr(gr.Description, 2048),
HTMLURL: gr.HTMLURL,
+ Language: truncStr(gr.Language, 64),
+ StarsCount: gr.StarsCount,
+ ForksCount: gr.ForksCount,
Private: false,
UpdatedAtRemote: parseGiteaTime(gr.UpdatedAt),
ForumUserID: &uid,
@@ -203,9 +408,12 @@ func (g *GiteaService) SyncRepos() (int, error) {
"full_name": row.FullName,
"description": row.Description,
"html_url": row.HTMLURL,
+ "language": row.Language,
+ "stars_count": row.StarsCount,
+ "forks_count": row.ForksCount,
"private": false,
"updated_at_remote": row.UpdatedAtRemote,
- "forum_user_id": row.ForumUserID,
+ "forum_user_id": uid, // 写死 uint,避免 *uint 进 map 未落库
"synced_at": row.SyncedAt,
}).Error; err != nil {
log.Printf("[gitea] 更新仓库失败 %s: %v", gr.FullName, err)
@@ -231,6 +439,9 @@ func (g *GiteaService) SyncRepos() (int, error) {
}
}
+ // 同步后再回填一次(覆盖 owner_login 大小写等边角)
+ BackfillForumUserIDs()
+
log.Printf("[gitea] 同步完成:upsert %d 个公开仓库", upserted)
return upserted, nil
}
@@ -241,6 +452,9 @@ type giteaAPIRepo struct {
FullName string `json:"full_name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
+ Language string `json:"language"`
+ StarsCount int `json:"stars_count"`
+ ForksCount int `json:"forks_count"`
Private bool `json:"private"`
UpdatedAt string `json:"updated_at"`
Owner struct {
@@ -310,6 +524,9 @@ func toGiteaRepoView(r model.GiteaRepo) GiteaRepoView {
FullName: r.FullName,
Description: r.Description,
HTMLURL: r.HTMLURL,
+ Language: r.Language,
+ StarsCount: r.StarsCount,
+ ForksCount: r.ForksCount,
UpdatedAtRemote: r.UpdatedAtRemote,
ForumUserID: r.ForumUserID,
SyncedAt: r.SyncedAt,