feat: SSR 右栏签到/抽奖条与安全回跳
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,7 +20,7 @@
|
|||||||
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
|
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
|
||||||
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`)
|
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`)
|
||||||
- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin 仅友链侧栏开关
|
- [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin 仅友链侧栏开关
|
||||||
- [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区)
|
- [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`)
|
||||||
- [ ] 下拉刷新(移动端)
|
- [ ] 下拉刷新(移动端)
|
||||||
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)
|
- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)
|
||||||
- [x] 404 页
|
- [x] 404 页
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
### 2.3 右栏 RightPanel
|
### 2.3 右栏 RightPanel
|
||||||
|
|
||||||
- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **未迁**(入口仍在 `/profile`)
|
- 登录用户:`AsideCheckInStrip`(签到 + 抽奖 + 积分入口)— **已迁** SSR 右栏;流水/详情仍在 `/profile#wallet`
|
||||||
- 热门帖 — **已迁** SSR(固定块)
|
- 热门帖 — **已迁** SSR(固定块)
|
||||||
- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin 完整排序编辑器未迁,友链侧栏开关在 `/admin/friend-links`
|
- 可配置 widgets:`tag_cloud` / `recent_comments` / `recent_users` / `friend_links`(顺序与开关来自 `aside_widgets`)— **已迁** SSR;Admin 完整排序编辑器未迁,友链侧栏开关在 `/admin/friend-links`
|
||||||
- 桌面三列;≤900px 隐藏右栏
|
- 桌面三列;≤900px 隐藏右栏
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ web_src/ → public/assets/
|
|||||||
|
|
||||||
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。
|
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。
|
||||||
|
|
||||||
右栏:首页与帖详情三栏;固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链);Admin 友链页可开关侧栏友链。
|
右栏:首页与帖详情三栏;登录用户签到/抽奖条(PRG 可回跳当前 Feed/帖页);固定热门帖 + `aside_widgets`(标签云 / 最新评论 / 最新用户 / 友链);Admin 友链页可开关侧栏友链。
|
||||||
|
|
||||||
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、nav/footer/aside 入口开关)。
|
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、nav/footer/aside 入口开关)。
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,38 @@ body.j13-body {
|
|||||||
}
|
}
|
||||||
.j13-widget__more a { color: var(--j13-accent); }
|
.j13-widget__more a { color: var(--j13-accent); }
|
||||||
|
|
||||||
|
.j13-checkin {
|
||||||
|
padding: 0.75rem 0.8rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
background: var(--j13-surface);
|
||||||
|
}
|
||||||
|
.j13-checkin__points {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.j13-checkin__points a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.j13-checkin__points a:hover { color: var(--j13-accent); }
|
||||||
|
.j13-checkin__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.j13-checkin__done {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--j13-muted);
|
||||||
|
}
|
||||||
|
.j13-checkin__more {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.j13-checkin__more a { color: var(--j13-accent); }
|
||||||
|
|
||||||
.j13-main {
|
.j13-main {
|
||||||
background: var(--j13-surface);
|
background: var(--j13-surface);
|
||||||
border: 1px solid var(--j13-border);
|
border: 1px solid var(--j13-border);
|
||||||
@@ -544,6 +576,10 @@ body.j13-body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.j13-btn--sm {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
.j13-btn:disabled {
|
.j13-btn:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -76,10 +76,17 @@ type RightAsideWidget struct {
|
|||||||
FriendLinks []RightAsideFriendLink
|
FriendLinks []RightAsideFriendLink
|
||||||
}
|
}
|
||||||
|
|
||||||
// RightAsideData 右栏:固定热门 + 可配置 widgets
|
// RightAsideData 右栏:签到条 + 固定热门 + 可配置 widgets
|
||||||
type RightAsideData struct {
|
type RightAsideData struct {
|
||||||
HotPosts []RightAsideHotPost
|
ShowCheckIn bool
|
||||||
Widgets []RightAsideWidget
|
CheckedIn bool
|
||||||
|
CheckInStreak int
|
||||||
|
CheckInPoints int // 已签实得或预计可得
|
||||||
|
LotteryDrawn bool
|
||||||
|
LotteryPoints int
|
||||||
|
LotteryCost int
|
||||||
|
HotPosts []RightAsideHotPost
|
||||||
|
Widgets []RightAsideWidget
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageChrome 布局公共字段
|
// PageChrome 布局公共字段
|
||||||
@@ -160,7 +167,7 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
|
|||||||
ViewerPoints: viewerPoints,
|
ViewerPoints: viewerPoints,
|
||||||
ShowFriendLinksNav: d.Settings.NavShowFriendLinks(),
|
ShowFriendLinksNav: d.Settings.NavShowFriendLinks(),
|
||||||
ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(),
|
ShowFriendLinksFooter: d.Settings.FooterShowFriendLinks(),
|
||||||
RightAside: d.loadRightAside(brand),
|
RightAside: d.loadRightAside(ctx, brand),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,11 +178,24 @@ const (
|
|||||||
rightAsideUserLimit = 8
|
rightAsideUserLimit = 8
|
||||||
)
|
)
|
||||||
|
|
||||||
func (d Deps) loadRightAside(brand services.SiteBranding) RightAsideData {
|
func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) RightAsideData {
|
||||||
out := RightAsideData{
|
out := RightAsideData{
|
||||||
HotPosts: []RightAsideHotPost{},
|
HotPosts: []RightAsideHotPost{},
|
||||||
Widgets: []RightAsideWidget{},
|
Widgets: []RightAsideWidget{},
|
||||||
}
|
}
|
||||||
|
if ctx.IsSigned() && d.Points != nil {
|
||||||
|
out.ShowCheckIn = true
|
||||||
|
if st, err := d.Points.GetCheckInStatus(ctx.UserID()); err == nil {
|
||||||
|
out.CheckedIn = st.CheckedIn
|
||||||
|
out.CheckInStreak = st.Streak
|
||||||
|
out.CheckInPoints = st.TodayPoints
|
||||||
|
}
|
||||||
|
if st, err := d.Points.GetLotteryStatus(ctx.UserID()); err == nil {
|
||||||
|
out.LotteryDrawn = st.Drawn
|
||||||
|
out.LotteryPoints = st.Points
|
||||||
|
out.LotteryCost = st.Cost
|
||||||
|
}
|
||||||
|
}
|
||||||
if d.Post != nil {
|
if d.Post != nil {
|
||||||
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
|
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
|
|||||||
@@ -226,11 +226,22 @@ func (d Deps) ProfileAvatarPost(c *gin.Context) {
|
|||||||
// ProfileCheckInPost 每日签到(PRG)
|
// ProfileCheckInPost 每日签到(PRG)
|
||||||
func (d Deps) ProfileCheckInPost(c *gin.Context) {
|
func (d Deps) ProfileCheckInPost(c *gin.Context) {
|
||||||
ctx := d.ctx(c)
|
ctx := d.ctx(c)
|
||||||
|
redir := safePointsRedirect(c.PostForm("redirect"))
|
||||||
if !ctx.CheckCSRF() {
|
if !ctx.CheckCSRF() {
|
||||||
|
if redir != "/profile" {
|
||||||
|
ctx.SetFlash("无效请求,请重试")
|
||||||
|
ctx.Redirect(redir)
|
||||||
|
return
|
||||||
|
}
|
||||||
d.renderProfile(ctx, "无效请求,请重试")
|
d.renderProfile(ctx, "无效请求,请重试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if d.Points == nil {
|
if d.Points == nil {
|
||||||
|
if redir != "/profile" {
|
||||||
|
ctx.SetFlash("积分服务未就绪")
|
||||||
|
ctx.Redirect(redir)
|
||||||
|
return
|
||||||
|
}
|
||||||
d.renderProfile(ctx, "积分服务未就绪")
|
d.renderProfile(ctx, "积分服务未就绪")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -238,24 +249,40 @@ func (d Deps) ProfileCheckInPost(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, services.ErrAlreadyCheckedIn) {
|
if errors.Is(err, services.ErrAlreadyCheckedIn) {
|
||||||
ctx.SetFlash("今日已签到")
|
ctx.SetFlash("今日已签到")
|
||||||
ctx.Redirect("/profile")
|
ctx.Redirect(redir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if redir != "/profile" {
|
||||||
|
ctx.SetFlash(err.Error())
|
||||||
|
ctx.Redirect(redir)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.renderProfile(ctx, err.Error())
|
d.renderProfile(ctx, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.SetFlash(fmt.Sprintf("签到成功:连续 %d 天,获得 %d 积分", st.Streak, st.TodayPoints))
|
ctx.SetFlash(fmt.Sprintf("签到成功:连续 %d 天,获得 %d 积分", st.Streak, st.TodayPoints))
|
||||||
ctx.Redirect("/profile")
|
ctx.Redirect(redir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfileLotteryPost 每日抽奖(PRG)
|
// ProfileLotteryPost 每日抽奖(PRG)
|
||||||
func (d Deps) ProfileLotteryPost(c *gin.Context) {
|
func (d Deps) ProfileLotteryPost(c *gin.Context) {
|
||||||
ctx := d.ctx(c)
|
ctx := d.ctx(c)
|
||||||
|
redir := safePointsRedirect(c.PostForm("redirect"))
|
||||||
if !ctx.CheckCSRF() {
|
if !ctx.CheckCSRF() {
|
||||||
|
if redir != "/profile" {
|
||||||
|
ctx.SetFlash("无效请求,请重试")
|
||||||
|
ctx.Redirect(redir)
|
||||||
|
return
|
||||||
|
}
|
||||||
d.renderProfile(ctx, "无效请求,请重试")
|
d.renderProfile(ctx, "无效请求,请重试")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if d.Points == nil {
|
if d.Points == nil {
|
||||||
|
if redir != "/profile" {
|
||||||
|
ctx.SetFlash("积分服务未就绪")
|
||||||
|
ctx.Redirect(redir)
|
||||||
|
return
|
||||||
|
}
|
||||||
d.renderProfile(ctx, "积分服务未就绪")
|
d.renderProfile(ctx, "积分服务未就绪")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -263,7 +290,12 @@ func (d Deps) ProfileLotteryPost(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, services.ErrAlreadyLottery) {
|
if errors.Is(err, services.ErrAlreadyLottery) {
|
||||||
ctx.SetFlash("今日已抽奖")
|
ctx.SetFlash("今日已抽奖")
|
||||||
ctx.Redirect("/profile")
|
ctx.Redirect(redir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if redir != "/profile" {
|
||||||
|
ctx.SetFlash(err.Error())
|
||||||
|
ctx.Redirect(redir)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.renderProfile(ctx, err.Error())
|
d.renderProfile(ctx, err.Error())
|
||||||
@@ -274,5 +306,23 @@ func (d Deps) ProfileLotteryPost(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
ctx.SetFlash("抽奖结果:未中奖,明天再来")
|
ctx.SetFlash("抽奖结果:未中奖,明天再来")
|
||||||
}
|
}
|
||||||
ctx.Redirect("/profile")
|
ctx.Redirect(redir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safePointsRedirect 签到/抽奖 PRG 回跳:仅允许 /、/board/…、/post/…;空或非法则 /profile
|
||||||
|
func safePointsRedirect(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || strings.Contains(raw, "://") || strings.HasPrefix(raw, "//") || strings.Contains(raw, "..") {
|
||||||
|
return "/profile"
|
||||||
|
}
|
||||||
|
if raw == "/" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "/board/") || strings.HasPrefix(raw, "/post/") {
|
||||||
|
if strings.ContainsAny(raw, "?#") {
|
||||||
|
return "/profile"
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return "/profile"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
{{define "shared/right_aside"}}
|
{{define "shared/right_aside"}}
|
||||||
<aside class="j13-aside j13-aside--right" aria-label="侧栏">
|
<aside class="j13-aside j13-aside--right" aria-label="侧栏">
|
||||||
|
{{if and .LoggedIn .RightAside.ShowCheckIn}}
|
||||||
|
<section class="j13-checkin" aria-label="签到与抽奖">
|
||||||
|
<p class="j13-checkin__points">
|
||||||
|
<a href="/profile#wallet">{{.ViewerPoints}} 积分</a>
|
||||||
|
</p>
|
||||||
|
<div class="j13-checkin__actions">
|
||||||
|
{{if .RightAside.CheckedIn}}
|
||||||
|
<span class="j13-checkin__done">已签 · {{.RightAside.CheckInStreak}} 天</span>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/profile/checkin" class="j13-inline-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<input type="hidden" name="redirect" value="{{.Path}}"/>
|
||||||
|
<button type="submit" class="j13-btn j13-btn--sm">签到 +{{.RightAside.CheckInPoints}}</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{if .RightAside.LotteryDrawn}}
|
||||||
|
<span class="j13-checkin__done">已抽 · {{.RightAside.LotteryPoints}}</span>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/profile/lottery" class="j13-inline-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<input type="hidden" name="redirect" value="{{.Path}}"/>
|
||||||
|
<button type="submit" class="j13-btn j13-btn--sm j13-btn-secondary">抽奖</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p class="j13-checkin__more"><a href="/profile#wallet">钱包详情</a></p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
<section class="j13-widget">
|
<section class="j13-widget">
|
||||||
<h2 class="j13-widget__title">热门讨论</h2>
|
<h2 class="j13-widget__title">热门讨论</h2>
|
||||||
{{if .RightAside.HotPosts}}
|
{{if .RightAside.HotPosts}}
|
||||||
|
|||||||
@@ -167,6 +167,38 @@ body.j13-body {
|
|||||||
}
|
}
|
||||||
.j13-widget__more a { color: var(--j13-accent); }
|
.j13-widget__more a { color: var(--j13-accent); }
|
||||||
|
|
||||||
|
.j13-checkin {
|
||||||
|
padding: 0.75rem 0.8rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
background: var(--j13-surface);
|
||||||
|
}
|
||||||
|
.j13-checkin__points {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.j13-checkin__points a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.j13-checkin__points a:hover { color: var(--j13-accent); }
|
||||||
|
.j13-checkin__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.j13-checkin__done {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--j13-muted);
|
||||||
|
}
|
||||||
|
.j13-checkin__more {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.j13-checkin__more a { color: var(--j13-accent); }
|
||||||
|
|
||||||
.j13-main {
|
.j13-main {
|
||||||
background: var(--j13-surface);
|
background: var(--j13-surface);
|
||||||
border: 1px solid var(--j13-border);
|
border: 1px solid var(--j13-border);
|
||||||
@@ -544,6 +576,10 @@ body.j13-body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.j13-btn--sm {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
.j13-btn:disabled {
|
.j13-btn:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
Reference in New Issue
Block a user