feat: SSR 个人闭环(profile / user / favorites)

登录用户可改资料与头像、浏览收藏;公开主页不含邮箱;改密吊销并重建本端 session。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 05:53:56 +08:00
parent fde5f628ec
commit 126e3bae98
19 changed files with 599 additions and 20 deletions

View File

@@ -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] 浏览量 — 详情页计数
- [ ] 举报帖子 - [ ] 举报帖子
- [ ] 内容审核状态展示(作者可见待审/被拒) - [ ] 内容审核状态展示(作者可见待审/被拒)

View File

@@ -29,9 +29,9 @@
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 | | `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 |
| `/compose` | 发帖normalMarkdown textarea + 图片上传) | 已迁 | | `/compose` | 发帖normalMarkdown 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`
- 公开页:签名、徽章、统计、最近帖(按现实现) - 积分钱包面板(流水、签到)、徽章展示:**未迁**
--- ---

View File

@@ -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`(品牌/限流/敏感词)。

View File

@@ -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 解析全部模板(进程内一次)

View File

@@ -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; }

View File

@@ -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,

View File

@@ -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
View 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,
})
}

View File

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

View File

@@ -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
View 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
View 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
}

View File

@@ -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">

View File

@@ -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

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

View File

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

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

View File

@@ -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; }