diff --git a/internal/controller/space_controller.go b/internal/controller/space_controller.go
index 342f94b..7dfeca5 100644
--- a/internal/controller/space_controller.go
+++ b/internal/controller/space_controller.go
@@ -5,6 +5,8 @@ import (
"strconv"
"metazone.cc/metalab/internal/common"
+ "metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
@@ -12,6 +14,7 @@ import (
// SpaceController 用户空间控制器
type SpaceController struct {
spaceService spaceUseCase
+ followSvc followUseCase
}
// NewSpaceController 构造函数
@@ -19,6 +22,11 @@ func NewSpaceController(spaceService spaceUseCase) *SpaceController {
return &SpaceController{spaceService: spaceService}
}
+// WithFollowService 注入关注服务(可选依赖)
+func (ctrl *SpaceController) WithFollowService(svc followUseCase) {
+ ctrl.followSvc = svc
+}
+
// MySpace 自己的空间(/space)— 需登录,重定向到 /space/{uid}
func (ctrl *SpaceController) MySpace(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
@@ -83,22 +91,30 @@ func (ctrl *SpaceController) ShowSpace(c *gin.Context) {
}
// 检查是否是自己的空间
- isOwnSpace := false
- if currentUID, _, ok := common.GetGinUser(c); ok {
- isOwnSpace = currentUID == uint(uid)
+ currentUID, _, _ := common.GetGinUser(c)
+ isOwnSpace := currentUID == uint(uid)
+
+ // 获取关注列表数据
+ var followingResult *service.FollowListResult
+ if ctrl.followSvc != nil {
+ res, err := ctrl.followSvc.ListFollowing(uint(uid), currentUID, 1, 24)
+ if err != nil || res == nil {
+ res = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0, Accessible: true}
+ }
+ followingResult = res
}
c.HTML(http.StatusOK, "space/index.html", common.BuildPageData(c, gin.H{
- "Title": spaceUser.Username + " 的空间",
- "SpaceUser": spaceUser,
- "Posts": posts,
- "Total": total,
- "Page": page,
- "TotalPages": totalPages,
- "PrevPage": prevPage,
- "NextPage": nextPage,
- "IsOwnSpace": isOwnSpace,
- "ExtraCSS": "/static/css/space.css",
- "ExtraCSS2": "/static/css/follow.css",
+ "Title": spaceUser.Username + " 的空间",
+ "SpaceUser": spaceUser,
+ "Posts": posts,
+ "Total": total,
+ "Page": page,
+ "TotalPages": totalPages,
+ "PrevPage": prevPage,
+ "NextPage": nextPage,
+ "IsOwnSpace": isOwnSpace,
+ "FollowingResult": followingResult,
+ "ExtraCSS": "/static/css/space.css",
}))
}
diff --git a/internal/model/user_follow.go b/internal/model/user_follow.go
index 19b109f..87ef291 100644
--- a/internal/model/user_follow.go
+++ b/internal/model/user_follow.go
@@ -11,4 +11,8 @@ type UserFollow struct {
// 联表查询填充(非数据库字段)
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`
FolloweeUsername string `gorm:"-:migration;<-:false;column:followee_username" json:"followee_username,omitempty"`
+ FollowerAvatar string `gorm:"-:migration;<-:false;column:follower_avatar" json:"follower_avatar,omitempty"`
+ FolloweeAvatar string `gorm:"-:migration;<-:false;column:followee_avatar" json:"followee_avatar,omitempty"`
+ FollowerBio string `gorm:"-:migration;<-:false;column:follower_bio" json:"follower_bio,omitempty"`
+ FolloweeBio string `gorm:"-:migration;<-:false;column:followee_bio" json:"followee_bio,omitempty"`
}
diff --git a/internal/repository/follow_repo.go b/internal/repository/follow_repo.go
index 2031818..9bc615b 100644
--- a/internal/repository/follow_repo.go
+++ b/internal/repository/follow_repo.go
@@ -56,7 +56,7 @@ func (r *FollowRepo) CountFollowing(userID uint) (int64, error) {
return count, err
}
-// ListFollowers 粉丝列表(分页,JOIN users 获取用户名)
+// ListFollowers 粉丝列表(分页,JOIN users 获取用户名、头像、简介)
func (r *FollowRepo) ListFollowers(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
var total int64
if err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&total).Error; err != nil {
@@ -64,7 +64,7 @@ func (r *FollowRepo) ListFollowers(userID uint, offset, limit int) ([]model.User
}
var items []model.UserFollow
err := r.db.Table("user_follows").
- Select("user_follows.*, u.username AS follower_username").
+ Select("user_follows.*, u.username AS follower_username, u.avatar AS follower_avatar, u.bio AS follower_bio").
Joins("INNER JOIN users u ON u.uid = user_follows.follower_id").
Where("user_follows.followee_id = ?", userID).
Order("user_follows.created_at DESC").
@@ -73,7 +73,7 @@ func (r *FollowRepo) ListFollowers(userID uint, offset, limit int) ([]model.User
return items, total, err
}
-// ListFollowing 关注列表(分页,JOIN users 获取用户名)
+// ListFollowing 关注列表(分页,JOIN users 获取用户名、头像、简介)
func (r *FollowRepo) ListFollowing(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
var total int64
if err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&total).Error; err != nil {
@@ -81,7 +81,7 @@ func (r *FollowRepo) ListFollowing(userID uint, offset, limit int) ([]model.User
}
var items []model.UserFollow
err := r.db.Table("user_follows").
- Select("user_follows.*, u.username AS followee_username").
+ Select("user_follows.*, u.username AS followee_username, u.avatar AS followee_avatar, u.bio AS followee_bio").
Joins("INNER JOIN users u ON u.uid = user_follows.followee_id").
Where("user_follows.follower_id = ?", userID).
Order("user_follows.created_at DESC").
diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go
index d0252aa..080f7a8 100644
--- a/internal/router/deps_extra.go
+++ b/internal/router/deps_extra.go
@@ -85,6 +85,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
// 用户空间
spaceService := service.NewSpaceService(userRepo, postRepo)
spaceCtrl := controller.NewSpaceController(spaceService)
+ spaceCtrl.WithFollowService(followService)
// 收藏夹系统
favoriteRepo := repository.NewFavoriteRepo(db)
diff --git a/templates/MetaLab-2026/html/space/index.html b/templates/MetaLab-2026/html/space/index.html
index 10cdd4d..8733d13 100644
--- a/templates/MetaLab-2026/html/space/index.html
+++ b/templates/MetaLab-2026/html/space/index.html
@@ -12,86 +12,175 @@
{{else}}
-
-
-
-
-
-
-
-
{{.Total}}
-
稿件
+
+ {{if .IsOwnSpace}}
+
-
-
-
稿件
+ {{if .FollowingResult}}
+ {{if .FollowingResult.Accessible}}
- {{if eq (len .Posts) 0}}
-
暂无稿件
+ {{if eq (len .FollowingResult.Items) 0}}
+
{{else}}
-
- {{range .Posts}}
-
-
-
-
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
-
- 已发布
- {{.CreatedAt.Format "2006-01-02"}}
-
+
+
+ {{range $i, $item := .FollowingResult.Items}}
+
{{end}}
{{end}}
-
- {{if gt .TotalPages 1}}
-
+
+
+ {{else}}
+
+
+
+
+
+
+ 全部关注
+
+ {{if .FollowingResult}}
+ {{if .FollowingResult.Accessible}}
+
+ {{if eq (len .FollowingResult.Items) 0}}
+
+ {{else}}
+
+ {{range $i, $item := .FollowingResult.Items}}
+
{{end}}
{{end}}
-
+
+ {{else}}
+
+
+ {{end}}
+ {{else}}
+
+ {{end}}
+
+ {{end}}
+
{{end}}
@@ -99,13 +188,12 @@
{{if not .IsOwnSpace}}
+
diff --git a/templates/MetaLab-2026/static/css/follow.css b/templates/MetaLab-2026/static/css/follow.css
index e29f963..d6e5414 100644
--- a/templates/MetaLab-2026/static/css/follow.css
+++ b/templates/MetaLab-2026/static/css/follow.css
@@ -1,79 +1,3 @@
-/* 关注按钮 */
-.follow-btn-wrapper {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin-top: 0.5rem;
-}
-
-.follow-btn {
- display: inline-flex;
- align-items: center;
- gap: .35rem;
- padding: .4rem 1rem;
- border: 1.5px solid var(--color-accent);
- border-radius: 6px;
- font-size: .85rem;
- font-weight: 600;
- cursor: pointer;
- transition: all .15s ease;
- background: transparent;
- color: var(--color-accent);
-}
-
-.follow-btn:hover {
- background: var(--color-accent);
- color: #fff;
-}
-
-.follow-btn.following {
- background: var(--color-accent);
- color: #fff;
-}
-
-.follow-btn.following:hover {
- background: var(--color-danger, #e74c3c);
- border-color: var(--color-danger, #e74c3c);
-}
-
-.follow-btn.mutual {
- background: var(--color-accent);
- color: #fff;
-}
-
-.follow-btn.mutual:hover {
- background: var(--color-danger, #e74c3c);
- border-color: var(--color-danger, #e74c3c);
-}
-
-.follow-btn.waiting {
- opacity: .6;
- pointer-events: none;
-}
-
-/* 关注/粉丝链接 */
-.follow-links {
- display: flex;
- gap: 1rem;
- margin-top: .35rem;
-}
-
-.follow-links a {
- color: var(--color-secondary);
- font-size: .85rem;
- text-decoration: none;
- transition: color .15s;
-}
-
-.follow-links a:hover {
- color: var(--color-accent);
-}
-
-.follow-links strong {
- color: var(--color-text);
- font-weight: 600;
-}
-
/* 用户列表(粉丝/关注页) */
.follow-list-page {
max-width: 700px;
diff --git a/templates/MetaLab-2026/static/css/space.css b/templates/MetaLab-2026/static/css/space.css
index 4f90998..dc87e70 100644
--- a/templates/MetaLab-2026/static/css/space.css
+++ b/templates/MetaLab-2026/static/css/space.css
@@ -1,6 +1,5 @@
/* ============================================================
- MetaLab Space Styles — 用户空间页面
- 参考 Bilibili 空间布局,轻量版
+ MetaLab Space Styles — 卡片式关注列表布局
============================================================ */
/* ---------- Error State ---------- */
@@ -40,210 +39,297 @@
}
/* ============================================================
- Header — 用户信息横幅(B站风格)
+ Card Layout — 左右分栏(参考B站关注页)
============================================================ */
-.space-header {
- background: linear-gradient(135deg, #e8f0f8 0%, #f0f4f8 100%);
- border-bottom: 1px solid var(--color-border);
- padding: 2rem 0;
-}
-
-.space-header-inner {
+.space-card-layout {
display: flex;
- align-items: center;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 1.5rem 1rem;
gap: 1.5rem;
+ min-height: calc(100vh - 160px);
}
-/* Avatar */
-.space-avatar {
+/* ---------- Sidebar (左侧导航) ---------- */
+.space-sidebar {
+ width: 200px;
flex-shrink: 0;
}
-.space-avatar img {
- width: 80px;
- height: 80px;
- border-radius: 50%;
- object-fit: cover;
- border: 3px solid #fff;
- box-shadow: 0 2px 8px rgba(0,0,0,.1);
+.space-sidebar-title {
+ font-size: .95rem;
+ font-weight: 600;
+ color: var(--color-primary);
+ margin-bottom: .75rem;
+ padding-left: .25rem;
}
-.space-avatar-placeholder {
- width: 80px;
- height: 80px;
+.space-sidebar-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.space-sidebar-link {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ padding: .65rem .75rem;
+ border-radius: var(--radius);
+ text-decoration: none;
+ color: var(--color-text);
+ font-size: .9rem;
+ transition: background-color .15s ease;
+}
+
+.space-sidebar-link:hover {
+ background-color: #f5f5f5;
+}
+
+.space-sidebar-link.active {
+ background-color: var(--color-accent);
+ color: #fff;
+ font-weight: 500;
+}
+
+.space-sidebar-link.active:hover {
+ background-color: #2980b9;
+}
+
+.space-sidebar-link.disabled {
+ opacity: .6;
+ pointer-events: none;
+}
+
+.space-sidebar-icon {
+ flex-shrink: 0;
+ opacity: .8;
+}
+
+.space-sidebar-link.active .space-sidebar-icon {
+ opacity: 1;
+}
+
+.space-sidebar-count {
+ margin-left: auto;
+ font-size: .8rem;
+ opacity: .7;
+}
+
+.space-sidebar-hint {
+ margin-top: 1rem;
+ padding: 0 .25rem;
+ font-size: .78rem;
+ color: var(--color-secondary);
+}
+
+/* ---------- Main Content (右侧内容) ---------- */
+.space-main {
+ flex: 1;
+ min-width: 0;
+}
+
+.space-main-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1.25rem;
+}
+
+.space-main-title {
+ font-size: 1.35rem;
+ font-weight: 700;
+ color: var(--color-primary);
+}
+
+.space-settings-link {
+ font-size: .85rem;
+ color: var(--color-accent);
+ text-decoration: none;
+ padding: .3rem .75rem;
+ border: 1px solid var(--color-border, #e5e7eb);
+ border-radius: var(--radius);
+ transition: all .15s;
+}
+
+.space-settings-link:hover {
+ background-color: rgba(52, 152, 219, .05);
+ border-color: var(--color-accent);
+}
+
+/* ============================================================
+ User Card Grid — 用户卡片网格
+ ============================================================ */
+.space-user-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1rem;
+}
+
+.space-user-card {
+ display: flex;
+ align-items: flex-start;
+ gap: .75rem;
+ padding: 1rem 1.1rem;
+ background: var(--color-surface, #fff);
+ border: 1px solid var(--color-border, #e5e7eb);
+ border-radius: var(--radius);
+ transition: box-shadow .2s ease, border-color .2s ease;
+}
+
+.space-user-card:hover {
+ box-shadow: 0 2px 12px rgba(0, 0, 0, .06);
+ border-color: #ccc;
+}
+
+/* Avatar */
+.space-user-avatar-wrap {
+ flex-shrink: 0;
+ text-decoration: none;
+ display: block;
+}
+
+.space-user-avatar-img {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+ display: block;
+}
+
+.space-user-avatar-placeholder {
+ width: 48px;
+ height: 48px;
border-radius: 50%;
background: var(--color-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
- font-size: 2rem;
+ font-size: 1.15rem;
font-weight: 700;
- border: 3px solid #fff;
- box-shadow: 0 2px 8px rgba(0,0,0,.1);
- text-transform: uppercase;
}
-/* Info */
-.space-info {
- min-width: 0;
-}
-
-.space-username {
- font-size: 1.5rem;
- font-weight: 700;
- color: var(--color-primary);
- line-height: 1.3;
- margin-bottom: .25rem;
-}
-
-.space-bio {
- font-size: .9rem;
- color: var(--color-text-light);
- line-height: 1.5;
- margin-bottom: .5rem;
- word-break: break-word;
-}
-
-.space-meta {
- display: flex;
- align-items: center;
- gap: .3rem;
- font-size: .8rem;
- color: var(--color-secondary);
- flex-wrap: wrap;
-}
-
-.space-divider {
- color: var(--color-border);
-}
-
-/* ============================================================
- Stats Bar
- ============================================================ */
-.space-stats {
- display: flex;
- gap: 2rem;
- padding: 1rem 0;
- border-bottom: 1px solid var(--color-border);
- margin-bottom: 1.5rem;
-}
-
-.space-stat-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: .15rem;
-}
-
-.space-stat-num {
- font-size: 1.4rem;
- font-weight: 700;
- color: var(--color-primary);
-}
-
-.space-stat-label {
- font-size: .8rem;
- color: var(--color-secondary);
-}
-
-/* ============================================================
- Content Section — 稿件列表
- ============================================================ */
-.space-content {
- padding-bottom: 3rem;
-}
-
-.space-section {
- margin-top: .5rem;
-}
-
-.space-section-title {
- font-size: 1.1rem;
- font-weight: 600;
- color: var(--color-primary);
- margin-bottom: 1rem;
- padding-bottom: .5rem;
- border-bottom: 2px solid var(--color-accent);
- display: inline-block;
-}
-
-/* ---------- Post Card ---------- */
-.space-posts-list {
- display: flex;
- flex-direction: column;
- gap: .75rem;
-}
-
-.space-post-card {
- display: flex;
- align-items: center;
- background: var(--color-surface);
- border: 1px solid var(--color-border);
- border-radius: var(--radius);
- padding: 1rem 1.25rem;
- transition: box-shadow .2s ease, border-color .2s ease;
-}
-
-.space-post-card:hover {
- box-shadow: 0 2px 12px rgba(0,0,0,.06);
- border-color: #ccc;
-}
-
-.space-post-main {
+/* User Info */
+.space-user-info {
flex: 1;
min-width: 0;
}
-.space-post-title {
- font-size: 1rem;
+.space-user-name {
+ display: block;
+ font-size: .92rem;
font-weight: 600;
- margin-bottom: .3rem;
- line-height: 1.4;
-}
-
-.space-post-title a {
color: var(--color-primary);
- transition: var(--transition);
+ text-decoration: none;
+ line-height: 1.3;
+ margin-bottom: .2rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.space-post-title a:hover {
+.space-user-name:hover {
color: var(--color-accent);
}
-.space-post-excerpt {
- font-size: .85rem;
+.space-user-bio {
+ font-size: .8rem;
color: var(--color-text-light);
- line-height: 1.5;
- margin-bottom: .5rem;
+ line-height: 1.4;
+ margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
+ word-break: break-word;
}
-.space-post-meta {
- display: flex;
- align-items: center;
- gap: .75rem;
- font-size: .75rem;
+/* Follow Button in Card */
+.space-follow-btn {
+ flex-shrink: 0;
+ padding: .28rem .7rem;
+ border: 1.5px solid var(--color-accent);
+ border-radius: 6px;
+ font-size: .8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all .15s ease;
+ background: transparent;
+ color: var(--color-accent);
+ line-height: 1.4;
+}
+
+.space-follow-btn:hover {
+ background: var(--color-accent);
+ color: #fff;
+}
+
+.space-follow-btn.followed {
+ background: transparent;
+ border-color: var(--color-border, #e5e7eb);
+ color: var(--color-secondary);
+ cursor: default;
+}
+
+.space-follow-btn.followed:hover {
+ background: transparent;
color: var(--color-secondary);
}
-.space-post-status {
- color: var(--color-accent);
- font-weight: 500;
+.space-follow-btn.waiting {
+ opacity: .6;
+ pointer-events: none;
}
-/* ---------- Empty State ---------- */
-.empty-state {
+/* ============================================================
+ Empty / Private States
+ ============================================================ */
+.space-empty-state {
text-align: center;
- padding: 3rem 1rem;
+ padding: 4rem 1rem;
color: var(--color-secondary);
font-size: .95rem;
}
-/* ---------- Pagination ---------- */
+.space-private-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 5rem 1rem;
+ text-align: center;
+}
+
+.space-private-illustration {
+ margin-bottom: 1.25rem;
+ opacity: .6;
+}
+
+.space-private-text {
+ color: var(--color-secondary);
+ font-size: .95rem;
+ margin-bottom: 1rem;
+}
+
+.space-private-setting-btn {
+ display: inline-block;
+ padding: .5rem 1.25rem;
+ background: var(--color-accent);
+ color: #fff;
+ border-radius: var(--radius);
+ font-size: .88rem;
+ text-decoration: none;
+ transition: background .15s;
+}
+
+.space-private-setting-btn:hover {
+ background: #2980b9;
+}
+
+/* ============================================================
+ Pagination
+ ============================================================ */
.pagination {
display: flex;
justify-content: center;
@@ -274,40 +360,46 @@
Responsive
============================================================ */
@media (max-width: 768px) {
- .space-header {
- padding: 1.5rem 0;
- }
-
- .space-header-inner {
+ .space-card-layout {
flex-direction: column;
- text-align: center;
- gap: .75rem;
+ padding: 1rem .75rem;
}
- .space-avatar img,
- .space-avatar-placeholder {
- width: 64px;
- height: 64px;
- font-size: 1.5rem;
+ .space-sidebar {
+ width: 100%;
}
- .space-username {
- font-size: 1.25rem;
+ .space-sidebar-nav {
+ flex-direction: row;
+ overflow-x: auto;
+ gap: .5rem;
+ -webkit-overflow-scrolling: touch;
}
- .space-meta {
- justify-content: center;
+ .space-sidebar-link {
+ white-space: nowrap;
+ flex-shrink: 0;
}
- .space-stats {
- justify-content: center;
+ .space-sidebar-title {
+ margin-bottom: .5rem;
}
- .space-post-card {
+ .space-sidebar-hint {
+ display: none;
+ }
+
+ .space-user-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .space-user-card {
padding: .85rem 1rem;
}
- .space-post-title {
- font-size: .95rem;
+ .space-main-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: .5rem;
}
}