feat: SSR 个人闭环(profile / user / favorites)
登录用户可改资料与头像、浏览收藏;公开主页不含邮箱;改密吊销并重建本端 session。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,9 +35,9 @@
|
|||||||
- [ ] 忘记密码:邮箱验证码 + 重置
|
- [ ] 忘记密码:邮箱验证码 + 重置
|
||||||
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
|
- [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP)
|
||||||
- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
|
- [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员
|
||||||
- [ ] 个人中心:改昵称、签名、密码、上传头像(可裁剪)
|
- [x] 个人中心:改昵称、签名、密码、上传头像 — SSR `/profile`(裁剪未做)
|
||||||
- [ ] 个人活动统计:帖数、评数、收藏数、获赞
|
- [x] 个人活动统计:帖数、评数、收藏数、获赞 — `/profile` + `/user/:id`
|
||||||
- [ ] 公开用户主页 `/user/:id`(无邮箱)
|
- [x] 公开用户主页 `/user/:id`(无邮箱)
|
||||||
- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
|
- [x] 禁言用户无法使用需登录写接口(中间件 + compose 门控)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
|
- [x] 编辑帖子(时限、锁帖约束)— SSR `/post/:id/edit`
|
||||||
- [ ] 删除帖子 → 软删进回收站
|
- [ ] 删除帖子 → 软删进回收站
|
||||||
- [ ] 修订历史列表与单条详情(可做 diff)
|
- [ ] 修订历史列表与单条详情(可做 diff)
|
||||||
- [x] 点赞切换;收藏切换;(收藏列表页未迁)
|
- [x] 点赞切换;收藏切换;收藏列表 — SSR `/favorites`
|
||||||
- [x] 浏览量 — 详情页计数
|
- [x] 浏览量 — 详情页计数
|
||||||
- [ ] 举报帖子
|
- [ ] 举报帖子
|
||||||
- [ ] 内容审核状态展示(作者可见待审/被拒)
|
- [ ] 内容审核状态展示(作者可见待审/被拒)
|
||||||
|
|||||||
@@ -29,9 +29,9 @@
|
|||||||
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
|
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
|
||||||
| `/compose` | 发帖(normal;Markdown textarea + 图片上传) | 已迁 |
|
| `/compose` | 发帖(normal;Markdown textarea + 图片上传) | 已迁 |
|
||||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
||||||
| `/profile` | 个人中心 | pending |
|
| `/profile` | 个人中心(资料/密码/头像;无钱包/裁剪) | 已迁 |
|
||||||
| `/user/:id` | 公开用户页 | 未注册 |
|
| `/user/:id` | 公开用户页 | 已迁 |
|
||||||
| `/favorites` | 收藏 | pending |
|
| `/favorites` | 收藏 | 已迁 |
|
||||||
| `/projects` | Gitea 码桶 | 后置 |
|
| `/projects` | Gitea 码桶 | 后置 |
|
||||||
| `/links` | 友链 | pending |
|
| `/links` | 友链 | pending |
|
||||||
| `/messages` | 私信 | pending |
|
| `/messages` | 私信 | pending |
|
||||||
@@ -157,10 +157,10 @@
|
|||||||
|
|
||||||
## 6. 个人中心 / 公开主页
|
## 6. 个人中心 / 公开主页
|
||||||
|
|
||||||
- 资料编辑、头像裁剪、密码
|
- 资料编辑、密码、头像直传(**本迭代无裁剪器**)— SSR `/profile`
|
||||||
- 积分钱包面板(流水、签到状态)
|
- 公开页:签名、等级/积分只读、统计、最近帖 — SSR `/user/:id`(无邮箱)
|
||||||
- 徽章与等级徽记展示
|
- 收藏列表 — SSR `/favorites`
|
||||||
- 公开页:签名、徽章、统计、最近帖(按现实现)
|
- 积分钱包面板(流水、签到)、徽章展示:**未迁**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ modules/
|
|||||||
templates/
|
templates/
|
||||||
install.tmpl
|
install.tmpl
|
||||||
post-install.tmpl
|
post-install.tmpl
|
||||||
base/ home/ post/ shared/ status/ auth/ admin/
|
base/ home/ post/ shared/ status/ auth/ admin/ profile/ user/ favorites/
|
||||||
services/
|
services/
|
||||||
web_src/ → public/assets/
|
web_src/ → public/assets/
|
||||||
```
|
```
|
||||||
@@ -83,4 +83,6 @@ web_src/ → public/assets/
|
|||||||
|
|
||||||
公开写:`/install`、`/login`、`/logout`、`/register`、`/compose`、`/post/:id/edit`、帖详情评论/赞/藏。
|
公开写:`/install`、`/login`、`/logout`、`/register`、`/compose`、`/post/:id/edit`、帖详情评论/赞/藏。
|
||||||
|
|
||||||
|
个人闭环:`/profile`(昵称/签名/密码/头像)、`/user/:id`、`/favorites`。
|
||||||
|
|
||||||
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)。
|
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)。
|
||||||
@@ -66,6 +66,9 @@ var parseGlobs = []string{
|
|||||||
"status/*.tmpl",
|
"status/*.tmpl",
|
||||||
"auth/*.tmpl",
|
"auth/*.tmpl",
|
||||||
"admin/*.tmpl",
|
"admin/*.tmpl",
|
||||||
|
"profile/*.tmpl",
|
||||||
|
"user/*.tmpl",
|
||||||
|
"favorites/*.tmpl",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 解析全部模板(进程内一次)
|
// Load 解析全部模板(进程内一次)
|
||||||
|
|||||||
@@ -344,4 +344,39 @@ body.j13-body {
|
|||||||
.j13-admin-reject input { width: auto; min-width: 12rem; margin: 0; }
|
.j13-admin-reject input { width: auto; min-width: 12rem; margin: 0; }
|
||||||
.j13-admin-reject button { 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; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
|
|
||||||
webpages.Register(r, webpages.Deps{
|
webpages.Register(r, webpages.Deps{
|
||||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||||
Settings: settingsSvc, Auth: authSvc,
|
Settings: settingsSvc, Auth: authSvc, User: userSvc,
|
||||||
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
||||||
Message: messageSvc, Filter: filter,
|
Message: messageSvc, Filter: filter,
|
||||||
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
|
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Deps struct {
|
|||||||
JWTSecret string
|
JWTSecret string
|
||||||
Settings *services.ForumSettingsService
|
Settings *services.ForumSettingsService
|
||||||
Auth *services.AuthService
|
Auth *services.AuthService
|
||||||
|
User *services.UserService
|
||||||
Board *services.BoardService
|
Board *services.BoardService
|
||||||
Post *services.PostService
|
Post *services.PostService
|
||||||
Comment *services.CommentService
|
Comment *services.CommentService
|
||||||
|
|||||||
69
routers/web/favorites.go
Normal file
69
routers/web/favorites.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -52,9 +52,14 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
|
|||||||
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
|
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("/messages", deps.PendingPage)
|
||||||
g.GET("/favorites", deps.PendingPage)
|
|
||||||
g.GET("/projects", deps.PendingPage)
|
g.GET("/projects", deps.PendingPage)
|
||||||
g.GET("/links", deps.PendingPage)
|
g.GET("/links", deps.PendingPage)
|
||||||
g.GET("/boards", deps.PendingPage)
|
g.GET("/boards", deps.PendingPage)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type PostPageData struct {
|
|||||||
PostPath string
|
PostPath string
|
||||||
PostTitle string
|
PostTitle string
|
||||||
AuthorName string
|
AuthorName string
|
||||||
|
AuthorID uint
|
||||||
BoardID uint
|
BoardID uint
|
||||||
BoardName string
|
BoardName string
|
||||||
Pinned bool
|
Pinned bool
|
||||||
@@ -41,6 +42,7 @@ type PostPageData struct {
|
|||||||
type CommentView struct {
|
type CommentView struct {
|
||||||
Floor int
|
Floor int
|
||||||
AuthorName string
|
AuthorName string
|
||||||
|
AuthorID uint
|
||||||
CreatedLabel string
|
CreatedLabel string
|
||||||
Content string
|
Content string
|
||||||
ContentHidden bool
|
ContentHidden bool
|
||||||
@@ -75,7 +77,8 @@ func (d Deps) PostView(c *gin.Context) {
|
|||||||
an = cm.User.Username
|
an = cm.User.Username
|
||||||
}
|
}
|
||||||
cv = append(cv, CommentView{
|
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,
|
Content: cm.Content, ContentHidden: cm.ContentHidden,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -95,7 +98,8 @@ func (d Deps) PostView(c *gin.Context) {
|
|||||||
ctx.HTML(http.StatusOK, "post", PostPageData{
|
ctx.HTML(http.StatusOK, "post", PostPageData{
|
||||||
PageChrome: chrome, PostID: post.ID,
|
PageChrome: chrome, PostID: post.ID,
|
||||||
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", 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,
|
Pinned: post.Pinned || post.BoardPinned, Featured: post.Featured,
|
||||||
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
|
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
|
||||||
ViewCount: post.ViewCount, LikeCount: post.LikeCount,
|
ViewCount: post.ViewCount, LikeCount: post.LikeCount,
|
||||||
|
|||||||
168
routers/web/profile.go
Normal file
168
routers/web/profile.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
106
routers/web/user.go
Normal file
106
routers/web/user.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<nav class="j13-nav">
|
<nav class="j13-nav">
|
||||||
{{if .LoggedIn}}
|
{{if .LoggedIn}}
|
||||||
<a href="/compose">发帖</a>
|
<a href="/compose">发帖</a>
|
||||||
|
<a href="/favorites">收藏</a>
|
||||||
<a href="/profile">{{.ViewerName}}</a>
|
<a href="/profile">{{.ViewerName}}</a>
|
||||||
{{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}}
|
{{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}}
|
||||||
<form class="j13-inline-form" method="post" action="/logout">
|
<form class="j13-inline-form" method="post" action="/logout">
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ package templates
|
|||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl
|
//go:embed *.tmpl base/*.tmpl home/*.tmpl post/*.tmpl shared/*.tmpl status/*.tmpl auth/*.tmpl admin/*.tmpl profile/*.tmpl user/*.tmpl favorites/*.tmpl
|
||||||
var FS embed.FS
|
var FS embed.FS
|
||||||
|
|||||||
30
templates/favorites/list.tmpl
Normal file
30
templates/favorites/list.tmpl
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "favorites/list"}}
|
||||||
|
{{template "base/head" .}}
|
||||||
|
{{template "base/navbar" .}}
|
||||||
|
<main class="j13-main j13-profile">
|
||||||
|
<h1>我的收藏</h1>
|
||||||
|
{{template "base/alert" .}}
|
||||||
|
<p class="j13-muted">共 {{.Total}} 条 · <a href="/profile">个人中心</a></p>
|
||||||
|
{{if .Items}}
|
||||||
|
<ul class="j13-post-list">
|
||||||
|
{{range .Items}}
|
||||||
|
<li class="j13-post-item">
|
||||||
|
<a class="j13-post-item__title" href="/post/{{.PostID}}">{{.Title}}</a>
|
||||||
|
<div class="j13-post-item__meta">
|
||||||
|
{{if .AuthorName}}<span>{{.AuthorName}}</span>{{end}}
|
||||||
|
{{if .BoardName}}<span>{{.BoardName}}</span>{{end}}
|
||||||
|
<span>{{.CreatedLabel}}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<nav class="j13-pager">
|
||||||
|
{{if .HasPrev}}<a href="/favorites?page={{.PrevPage}}">上一页</a>{{end}}
|
||||||
|
{{if .HasMore}}<a href="/favorites?page={{.NextPage}}">下一页</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{else}}
|
||||||
|
<p class="j13-empty">还没有收藏。在帖子详情页可点击收藏。</p>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
{{template "base/footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
{{if .Featured}}<span class="j13-tag j13-tag--feat">精华</span>{{end}}
|
{{if .Featured}}<span class="j13-tag j13-tag--feat">精华</span>{{end}}
|
||||||
{{if .PostTypeLabel}}<span class="j13-tag">{{.PostTypeLabel}}</span>{{end}}
|
{{if .PostTypeLabel}}<span class="j13-tag">{{.PostTypeLabel}}</span>{{end}}
|
||||||
{{if .BoardName}}<a class="j13-tag" href="/board/{{.BoardID}}">{{.BoardName}}</a>{{end}}
|
{{if .BoardName}}<a class="j13-tag" href="/board/{{.BoardID}}">{{.BoardName}}</a>{{end}}
|
||||||
<span>{{.AuthorName}}</span>
|
{{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
|
||||||
<span>{{.CreatedLabel}}</span>
|
<span>{{.CreatedLabel}}</span>
|
||||||
<span>{{.ViewCount}} 阅读</span>
|
<span>{{.ViewCount}} 阅读</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<li class="j13-comment" id="floor-{{.Floor}}">
|
<li class="j13-comment" id="floor-{{.Floor}}">
|
||||||
<div class="j13-comment__meta">
|
<div class="j13-comment__meta">
|
||||||
<span class="j13-comment__floor">#{{.Floor}}</span>
|
<span class="j13-comment__floor">#{{.Floor}}</span>
|
||||||
<span>{{.AuthorName}}</span>
|
{{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
|
||||||
<span>{{.CreatedLabel}}</span>
|
<span>{{.CreatedLabel}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .ContentHidden}}
|
{{if .ContentHidden}}
|
||||||
|
|||||||
72
templates/profile/view.tmpl
Normal file
72
templates/profile/view.tmpl
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{{define "profile/view"}}
|
||||||
|
{{template "base/head" .}}
|
||||||
|
{{template "base/navbar" .}}
|
||||||
|
<main class="j13-main j13-profile">
|
||||||
|
<h1>个人中心</h1>
|
||||||
|
{{template "base/alert" .}}
|
||||||
|
<p class="j13-muted">
|
||||||
|
<a href="{{.PublicURL}}">查看公开主页</a>
|
||||||
|
· <a href="/favorites">我的收藏</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="j13-admin-stats">
|
||||||
|
<li><strong>{{.PostCount}}</strong><span>帖子</span></li>
|
||||||
|
<li><strong>{{.CommentCount}}</strong><span>评论</span></li>
|
||||||
|
<li><strong>{{.FavoriteCount}}</strong><span>收藏</span></li>
|
||||||
|
<li><strong>{{.LikeReceived}}</strong><span>获赞</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<section class="j13-profile__card">
|
||||||
|
<h2>账号</h2>
|
||||||
|
<p>用户名 <strong>{{.Username}}</strong></p>
|
||||||
|
<p class="j13-muted">邮箱 {{.Email}}</p>
|
||||||
|
<p class="j13-muted">等级 Lv.{{.Level}} · 经验 {{.Exp}} · 积分 {{.Points}}</p>
|
||||||
|
{{if .Avatar}}
|
||||||
|
<p><img class="j13-avatar" src="{{.Avatar}}" alt="头像"/></p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>头像</h2>
|
||||||
|
<form method="post" action="/profile/avatar" enctype="multipart/form-data" class="j13-form j13-admin-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<label>图片(最大 {{.AvatarMaxMB}} MB)
|
||||||
|
<input type="file" name="avatar" accept="image/*" required/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">上传头像</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>昵称</h2>
|
||||||
|
<form method="post" action="/profile/nickname" class="j13-form j13-admin-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<label>昵称 <input name="nickname" value="{{.Nickname}}" maxlength="32" required/></label>
|
||||||
|
<button type="submit">保存昵称</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>签名</h2>
|
||||||
|
<form method="post" action="/profile/signature" class="j13-form j13-admin-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<label>签名
|
||||||
|
<textarea name="signature" rows="3" maxlength="{{.SignatureMax}}">{{.Signature}}</textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit">保存签名</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>修改密码</h2>
|
||||||
|
<form method="post" action="/profile/password" class="j13-form j13-admin-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<label>当前密码 <input type="password" name="old_password" required autocomplete="current-password"/></label>
|
||||||
|
<label>新密码 <input type="password" name="new_password" required minlength="6" autocomplete="new-password"/></label>
|
||||||
|
<label>确认新密码 <input type="password" name="new_password2" required minlength="6" autocomplete="new-password"/></label>
|
||||||
|
<button type="submit">更新密码</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
{{template "base/footer" .}}
|
||||||
|
{{end}}
|
||||||
48
templates/user/view.tmpl
Normal file
48
templates/user/view.tmpl
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{{define "user/view"}}
|
||||||
|
{{template "base/head" .}}
|
||||||
|
{{template "base/navbar" .}}
|
||||||
|
<main class="j13-main j13-profile">
|
||||||
|
<header class="j13-user-hero">
|
||||||
|
{{if .Avatar}}
|
||||||
|
<img class="j13-avatar j13-avatar--lg" src="{{.Avatar}}" alt=""/>
|
||||||
|
{{else}}
|
||||||
|
<span class="j13-avatar j13-avatar--lg j13-avatar--placeholder" aria-hidden="true">·</span>
|
||||||
|
{{end}}
|
||||||
|
<div>
|
||||||
|
<h1>{{if .Nickname}}{{.Nickname}}{{else}}{{.Username}}{{end}}</h1>
|
||||||
|
<p class="j13-muted">@{{.Username}} · Lv.{{.Level}} · 积分 {{.Points}}</p>
|
||||||
|
{{if .Signature}}<p class="j13-user-sig">{{.Signature}}</p>{{end}}
|
||||||
|
{{if .IsSelf}}<p><a href="/profile">编辑资料</a></p>{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{{template "base/alert" .}}
|
||||||
|
|
||||||
|
<ul class="j13-admin-stats">
|
||||||
|
<li><strong>{{.PostCount}}</strong><span>帖子</span></li>
|
||||||
|
<li><strong>{{.CommentCount}}</strong><span>评论</span></li>
|
||||||
|
<li><strong>{{.LikeReceived}}</strong><span>获赞</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>最近帖子</h2>
|
||||||
|
{{if .Posts}}
|
||||||
|
<ul class="j13-post-list">
|
||||||
|
{{range .Posts}}
|
||||||
|
<li class="j13-post-item">
|
||||||
|
<a class="j13-post-item__title" href="/post/{{.ID}}">{{.Title}}</a>
|
||||||
|
<div class="j13-post-item__meta">
|
||||||
|
{{if .BoardName}}<span>{{.BoardName}}</span>{{end}}
|
||||||
|
<span>{{.CreatedLabel}}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<nav class="j13-pager">
|
||||||
|
{{if .HasPrev}}<a href="/user/{{.UserID}}?page={{.PrevPage}}">上一页</a>{{end}}
|
||||||
|
{{if .HasMore}}<a href="/user/{{.UserID}}?page={{.NextPage}}">下一页</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{else}}
|
||||||
|
<p class="j13-empty">暂无公开帖子。</p>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
{{template "base/footer" .}}
|
||||||
|
{{end}}
|
||||||
@@ -344,4 +344,39 @@ body.j13-body {
|
|||||||
.j13-admin-reject input { width: auto; min-width: 12rem; margin: 0; }
|
.j13-admin-reject input { width: auto; min-width: 12rem; margin: 0; }
|
||||||
.j13-admin-reject button { 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; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user