diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 5f21ff2..f5f7c84 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -35,9 +35,9 @@ - [ ] 忘记密码:邮箱验证码 + 重置 - [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP) - [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员 -- [ ] 个人中心:改昵称、签名、密码、上传头像(可裁剪) -- [ ] 个人活动统计:帖数、评数、收藏数、获赞 -- [ ] 公开用户主页 `/user/:id`(无邮箱) +- [x] 个人中心:改昵称、签名、密码、上传头像 — SSR `/profile`(裁剪未做) +- [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id` +- [x] 公开用户主页 `/user/:id`(无邮箱) - [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控) --- @@ -60,7 +60,7 @@ - [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit` - [ ] 删除帖子 → 软删进回收站 - [ ] 修订历史列表与单条详情(可做 diff) -- [x] 点赞切换;收藏切换;(收藏列表页未迁) +- [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites` - [x] 浏览量 — 详情页计数 - [ ] 举报帖子 - [ ] 内容审核状态展示(作者可见待审/被拒) diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 7155843..e9ecb41 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -29,9 +29,9 @@ | `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 | | `/compose` | 发帖(normal;Markdown textarea + 图片上传) | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 | -| `/profile` | 个人中心 | pending | -| `/user/:id` | 公开用户页 | 未注册 | -| `/favorites` | 收藏 | pending | +| `/profile` | 个人中心(资料/密码/头像;无钱包/裁剪) | 已迁 | +| `/user/:id` | 公开用户页 | 已迁 | +| `/favorites` | 收藏 | 已迁 | | `/projects` | Gitea 码桶 | 后置 | | `/links` | 友链 | pending | | `/messages` | 私信 | pending | @@ -157,10 +157,10 @@ ## 6. 个人中心 / 公开主页 -- 资料编辑、头像裁剪、密码 -- 积分钱包面板(流水、签到状态) -- 徽章与等级徽记展示 -- 公开页:签名、徽章、统计、最近帖(按现实现) +- 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile` +- 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱) +- 收藏列表 — SSR `/favorites` +- 积分钱包面板(流水、签到)、徽章展示:**未迁** --- diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index 4c923cc..82f814f 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -45,7 +45,7 @@ modules/ templates/ install.tmpl post-install.tmpl - base/ home/ post/ shared/ status/ auth/ admin/ + base/ home/ post/ shared/ status/ auth/ admin/ profile/ user/ favorites/ services/ web_src/ → public/assets/ ``` @@ -83,4 +83,6 @@ web_src/ → public/assets/ 公开写:`/install`、`/login`、`/logout`、`/register`、`/compose`、`/post/:id/edit`、帖详情评论/赞/藏。 +个人闭环:`/profile`(昵称/签名/密码/头像)、`/user/:id`、`/favorites`。 + Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)。 \ No newline at end of file diff --git a/modules/webrender/render.go b/modules/webrender/render.go index 27dafe8..e22dc9c 100644 --- a/modules/webrender/render.go +++ b/modules/webrender/render.go @@ -66,6 +66,9 @@ var parseGlobs = []string{ "status/*.tmpl", "auth/*.tmpl", "admin/*.tmpl", + "profile/*.tmpl", + "user/*.tmpl", + "favorites/*.tmpl", } // Load 解析全部模板(进程内一次) diff --git a/public/assets/site.css b/public/assets/site.css index c30437d..1dd9433 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -344,4 +344,39 @@ body.j13-body { .j13-admin-reject input { width: auto; min-width: 12rem; margin: 0; } .j13-admin-reject button { margin: 0; } +.j13-profile { max-width: 720px; margin: 0 auto; padding: 1rem 1rem 2.5rem; } +.j13-profile h1 { margin: 0 0 0.75rem; font-size: 1.4rem; } +.j13-profile h2 { margin: 1.5rem 0 0.65rem; font-size: 1.05rem; } +.j13-profile__card { + margin: 1rem 0; + padding: 0.85rem 0; + border-bottom: 1px solid var(--j13-border); +} +.j13-avatar { + width: 64px; + height: 64px; + object-fit: cover; + border-radius: 50%; + border: 1px solid var(--j13-border); +} +.j13-avatar--lg { width: 88px; height: 88px; } +.j13-avatar--placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--j13-surface); + color: var(--j13-muted); + font-weight: 600; + font-size: 1.5rem; +} +.j13-user-hero { + display: flex; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1.25rem; +} +.j13-user-hero h1 { margin: 0 0 0.35rem; font-size: 1.35rem; } +.j13-user-sig { margin: 0.5rem 0 0; white-space: pre-wrap; } + + diff --git a/routers/setup.go b/routers/setup.go index 57702ff..f8dc063 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -96,7 +96,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { webpages.Register(r, webpages.Deps{ DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret, - Settings: settingsSvc, Auth: authSvc, + Settings: settingsSvc, Auth: authSvc, User: userSvc, Board: boardSvc, Post: postSvc, Comment: commentSvc, Message: messageSvc, Filter: filter, Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore, diff --git a/routers/web/chrome.go b/routers/web/chrome.go index 68fb141..74223af 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -14,6 +14,7 @@ type Deps struct { JWTSecret string Settings *services.ForumSettingsService Auth *services.AuthService + User *services.UserService Board *services.BoardService Post *services.PostService Comment *services.CommentService diff --git a/routers/web/favorites.go b/routers/web/favorites.go new file mode 100644 index 0000000..f64ac09 --- /dev/null +++ b/routers/web/favorites.go @@ -0,0 +1,69 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +type favItem struct { + PostID uint + Title string + AuthorName string + BoardName string + CreatedLabel string +} + +type favoritesData struct { + PageChrome + Items []favItem + Page int + PrevPage int + NextPage int + HasPrev bool + HasMore bool + Total int64 +} + +// FavoritesGet 我的收藏 +func (d Deps) FavoritesGet(c *gin.Context) { + ctx := d.ctx(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + size := d.Settings.PageSizeDefault() + favs, total, err := d.Post.ListFavorites(ctx.UserID(), page, size) + if err != nil { + chrome := d.chrome(ctx, "收藏 · "+d.Settings.SiteBranding().Name, "", "") + chrome.Error = err.Error() + ctx.HTML(http.StatusOK, "favorites/list", favoritesData{PageChrome: chrome}) + return + } + items := make([]favItem, 0, len(favs)) + for _, f := range favs { + title := f.Post.Title + author := strings.TrimSpace(f.Post.User.Nickname) + if author == "" { + author = f.Post.User.Username + } + board := "" + if f.Post.Board.ID > 0 { + board = f.Post.Board.Name + } + items = append(items, favItem{ + PostID: f.PostID, Title: title, AuthorName: author, BoardName: board, + CreatedLabel: formatTime(f.CreatedAt), + }) + } + chrome := d.chrome(ctx, "收藏 · "+d.Settings.SiteBranding().Name, "", "") + hasMore := int64(page*size) < total + ctx.HTML(http.StatusOK, "favorites/list", favoritesData{ + PageChrome: chrome, + Items: items, + Page: page, PrevPage: page - 1, NextPage: page + 1, + HasPrev: page > 1, HasMore: hasMore, Total: total, + }) +} diff --git a/routers/web/home.go b/routers/web/home.go index 547e419..4d7c235 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -52,9 +52,14 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) } - g.GET("/profile", deps.PendingPage) + g.GET("/user/:id", deps.UserPublic) + g.GET("/profile", authMW.RequireAuth(), deps.ProfileGet) + g.POST("/profile/nickname", authMW.RequireAuth(), deps.ProfileNicknamePost) + g.POST("/profile/signature", authMW.RequireAuth(), deps.ProfileSignaturePost) + g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost) + g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost) + g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet) g.GET("/messages", deps.PendingPage) - g.GET("/favorites", deps.PendingPage) g.GET("/projects", deps.PendingPage) g.GET("/links", deps.PendingPage) g.GET("/boards", deps.PendingPage) diff --git a/routers/web/post.go b/routers/web/post.go index ce0c587..138ea51 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -20,6 +20,7 @@ type PostPageData struct { PostPath string PostTitle string AuthorName string + AuthorID uint BoardID uint BoardName string Pinned bool @@ -41,6 +42,7 @@ type PostPageData struct { type CommentView struct { Floor int AuthorName string + AuthorID uint CreatedLabel string Content string ContentHidden bool @@ -75,7 +77,8 @@ func (d Deps) PostView(c *gin.Context) { an = cm.User.Username } cv = append(cv, CommentView{ - Floor: cm.Floor, AuthorName: an, CreatedLabel: formatTime(cm.CreatedAt), + Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID, + CreatedLabel: formatTime(cm.CreatedAt), Content: cm.Content, ContentHidden: cm.ContentHidden, }) } @@ -95,7 +98,8 @@ func (d Deps) PostView(c *gin.Context) { ctx.HTML(http.StatusOK, "post", PostPageData{ PageChrome: chrome, PostID: post.ID, PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)), - PostTitle: post.Title, AuthorName: author, BoardID: post.BoardID, BoardName: boardName, + PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID, + BoardID: post.BoardID, BoardName: boardName, Pinned: post.Pinned || post.BoardPinned, Featured: post.Featured, PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt), ViewCount: post.ViewCount, LikeCount: post.LikeCount, diff --git a/routers/web/profile.go b/routers/web/profile.go new file mode 100644 index 0000000..9eeac45 --- /dev/null +++ b/routers/web/profile.go @@ -0,0 +1,168 @@ +package web + +import ( + "fmt" + "net/http" + "strings" + + "git.iioio.com/freefire/jiang13-forum/models" + "git.iioio.com/freefire/jiang13-forum/modules/webctx" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +type profileData struct { + PageChrome + UserID uint + Username string + Nickname string + Signature string + Avatar string + Email string + Level int + Exp int + Points int + PostCount int64 + CommentCount int64 + FavoriteCount int64 + LikeReceived int64 + PublicURL string + AvatarMaxMB int + SignatureMax int +} + +// ProfileGet 个人中心 +func (d Deps) ProfileGet(c *gin.Context) { + ctx := d.ctx(c) + d.renderProfile(ctx, "") +} + +func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) { + uid := ctx.UserID() + user, err := d.User.GetByID(uid) + if err != nil { + ctx.SetFlash("用户不存在") + ctx.Redirect("/") + return + } + st, _ := d.User.ActivityStats(uid) + chrome := d.chrome(ctx, "个人中心 · "+d.Settings.SiteBranding().Name, "", "") + chrome.Error = errMsg + nick := strings.TrimSpace(user.Nickname) + if nick == "" { + nick = user.Username + } + data := profileData{ + PageChrome: chrome, + UserID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Signature: user.Signature, + Avatar: user.Avatar, + Email: user.Email, + Level: models.LevelFromExp(user.Exp), + Exp: user.Exp, + Points: user.Points, + PostCount: st.PostCount, + CommentCount: st.CommentCount, + FavoriteCount: st.FavoriteCount, + LikeReceived: st.LikeReceived, + PublicURL: fmt.Sprintf("/user/%d", user.ID), + AvatarMaxMB: d.Settings.AvatarMaxMB(), + SignatureMax: d.Settings.SignatureMax(), + } + // 导航显示最新昵称 + data.ViewerName = nick + ctx.HTML(http.StatusOK, "profile/view", data) +} + +// ProfileNicknamePost 改昵称 +func (d Deps) ProfileNicknamePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderProfile(ctx, "无效请求,请重试") + return + } + if err := d.User.UpdateNickname(ctx.UserID(), strings.TrimSpace(c.PostForm("nickname"))); err != nil { + d.renderProfile(ctx, err.Error()) + return + } + ctx.SetFlash("昵称已更新") + ctx.Redirect("/profile") +} + +// ProfileSignaturePost 改签名 +func (d Deps) ProfileSignaturePost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderProfile(ctx, "无效请求,请重试") + return + } + if err := d.User.UpdateSignature(ctx.UserID(), c.PostForm("signature")); err != nil { + d.renderProfile(ctx, err.Error()) + return + } + ctx.SetFlash("签名已更新") + ctx.Redirect("/profile") +} + +// ProfilePasswordPost 改密码:吊销全部 session 后为本端重建 +func (d Deps) ProfilePasswordPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderProfile(ctx, "无效请求,请重试") + return + } + oldPass := c.PostForm("old_password") + newPass := c.PostForm("new_password") + confirm := c.PostForm("new_password2") + if newPass != confirm { + d.renderProfile(ctx, "两次输入的新密码不一致") + return + } + uid := ctx.UserID() + if err := d.User.UpdatePassword(uid, oldPass, newPass); err != nil { + d.renderProfile(ctx, err.Error()) + return + } + services.RevokeUserSessions(uid) + user, err := d.User.GetByID(uid) + if err != nil { + ctx.SetFlash("密码已更新,请重新登录") + ctx.Redirect("/login?redirect=/profile") + return + } + sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + ctx.SetFlash("密码已更新,请重新登录") + ctx.Redirect("/login?redirect=/profile") + return + } + ctx.SetLoginCookie(sid) + ctx.SetFlash("密码已更新,其它设备已登出") + ctx.Redirect("/profile") +} + +// ProfileAvatarPost 上传头像 +func (d Deps) ProfileAvatarPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderProfile(ctx, "无效请求,请重试") + return + } + file, err := c.FormFile("avatar") + if err != nil { + d.renderProfile(ctx, "请选择头像文件") + return + } + if d.Store == nil { + d.renderProfile(ctx, "上传存储未就绪") + return + } + if _, err := d.User.UploadAvatar(ctx.UserID(), file, d.Store); err != nil { + d.renderProfile(ctx, err.Error()) + return + } + ctx.SetFlash("头像已更新") + ctx.Redirect("/profile") +} diff --git a/routers/web/user.go b/routers/web/user.go new file mode 100644 index 0000000..469ef19 --- /dev/null +++ b/routers/web/user.go @@ -0,0 +1,106 @@ +package web + +import ( + "net/http" + "strconv" + "strings" + + "git.iioio.com/freefire/jiang13-forum/models" + "git.iioio.com/freefire/jiang13-forum/services" + "github.com/gin-gonic/gin" +) + +type userPublicData struct { + PageChrome + UserID uint + Username string + Nickname string + Signature string + Avatar string + Level int + Exp int + Points int + PostCount int64 + CommentCount int64 + FavoriteCount int64 + LikeReceived int64 + IsSelf bool + Posts []PostListItem + Page int + PrevPage int + NextPage int + HasPrev bool + HasMore bool +} + +// UserPublic 公开用户主页(不含邮箱) +func (d Deps) UserPublic(c *gin.Context) { + ctx := d.ctx(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + d.render404(ctx) + return + } + user, err := d.User.GetByID(uint(id)) + if err != nil { + d.render404(ctx) + return + } + st, _ := d.User.ActivityStats(user.ID) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + size := d.Settings.PageSizeDefault() + posts, total, _ := d.Post.List(services.PostListQuery{ + UserID: user.ID, + Page: page, + Size: size, + Sort: "latest", + ViewerID: ctx.UserID(), + ViewerIsAdmin: ctx.IsAdmin(), + }) + items := make([]PostListItem, 0, len(posts)) + for _, p := range posts { + board := "" + if p.Board.ID > 0 { + board = p.Board.Name + } + items = append(items, PostListItem{ + ID: p.ID, Title: p.Title, AuthorName: displayName(user), + BoardName: board, Pinned: p.Pinned || p.BoardPinned, Featured: p.Featured, + CreatedLabel: formatTime(p.CreatedAt), + }) + } + nick := displayName(user) + chrome := d.chrome(ctx, nick+" · "+d.Settings.SiteBranding().Name, strings.TrimSpace(user.Signature), "") + hasMore := int64(page*size) < total + ctx.HTML(http.StatusOK, "user/view", userPublicData{ + PageChrome: chrome, + UserID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + Signature: user.Signature, + Avatar: user.Avatar, + Level: models.LevelFromExp(user.Exp), + Exp: user.Exp, + Points: user.Points, + PostCount: st.PostCount, CommentCount: st.CommentCount, + FavoriteCount: st.FavoriteCount, LikeReceived: st.LikeReceived, + IsSelf: ctx.UserID() == user.ID, + Posts: items, + Page: page, PrevPage: page - 1, NextPage: page + 1, + HasPrev: page > 1, HasMore: hasMore, + }) +} + +func displayName(u *models.User) string { + if u == nil { + return "" + } + n := strings.TrimSpace(u.Nickname) + if n == "" { + return u.Username + } + return n +} diff --git a/templates/base/navbar.tmpl b/templates/base/navbar.tmpl index 71e0d20..bf4e40e 100644 --- a/templates/base/navbar.tmpl +++ b/templates/base/navbar.tmpl @@ -11,6 +11,7 @@