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] ~~首个用户自动成为管理员~~ → 改为仅 `/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] 浏览量 — 详情页计数
|
||||
- [ ] 举报帖子
|
||||
- [ ] 内容审核状态展示(作者可见待审/被拒)
|
||||
|
||||
@@ -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`
|
||||
- 积分钱包面板(流水、签到)、徽章展示:**未迁**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`(品牌/限流/敏感词)。
|
||||
@@ -66,6 +66,9 @@ var parseGlobs = []string{
|
||||
"status/*.tmpl",
|
||||
"auth/*.tmpl",
|
||||
"admin/*.tmpl",
|
||||
"profile/*.tmpl",
|
||||
"user/*.tmpl",
|
||||
"favorites/*.tmpl",
|
||||
}
|
||||
|
||||
// Load 解析全部模板(进程内一次)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
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">
|
||||
{{if .LoggedIn}}
|
||||
<a href="/compose">发帖</a>
|
||||
<a href="/favorites">收藏</a>
|
||||
<a href="/profile">{{.ViewerName}}</a>
|
||||
{{if .IsAdmin}}<a href="/admin/dashboard">后台</a>{{end}}
|
||||
<form class="j13-inline-form" method="post" action="/logout">
|
||||
|
||||
@@ -2,5 +2,5 @@ package templates
|
||||
|
||||
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
|
||||
|
||||
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 .PostTypeLabel}}<span class="j13-tag">{{.PostTypeLabel}}</span>{{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>{{.ViewCount}} 阅读</span>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@
|
||||
<li class="j13-comment" id="floor-{{.Floor}}">
|
||||
<div class="j13-comment__meta">
|
||||
<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>
|
||||
</div>
|
||||
{{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 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