feat: SSR Feed 搜索面板(关键词/标签/作者/仅标题)
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,7 +18,7 @@
|
|||||||
- [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
|
- [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门)
|
||||||
- [x] 板块筛选:全部 + 单板块
|
- [x] 板块筛选:全部 + 单板块
|
||||||
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
|
- [ ] 列表样式可配:`title` | `excerpt` | `thumbnail`
|
||||||
- [ ] 搜索:关键词、标签、作者、仅标题(`title_only`)
|
- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`)
|
||||||
- [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)
|
- [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)
|
||||||
- [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区)
|
- [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区)
|
||||||
- [ ] 下拉刷新(移动端)
|
- [ ] 下拉刷新(移动端)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
|
|
||||||
### 2.2 中栏
|
### 2.2 中栏
|
||||||
|
|
||||||
**Feed**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题)+ 虚拟列表项(标题、作者、板块徽章、标签、回复数、最后回复、置顶/精华标记)。列表样式随 `feed_list_style`。
|
**Feed**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题;已迁 SSR GET)+ 列表项(标题、作者、板块徽章、回复数、置顶/精华)。虚拟滚动 / `feed_list_style` 未迁。
|
||||||
|
|
||||||
**帖子详情**:标题区操作(赞、藏、举报、编辑、管理操作)→ 特殊组件(投票卡 / 悬赏条 / 抽奖卡)→ 正文 `PostContent`(门控块 UI)→ 文章目录 → 作者卡片 → 修订入口 → 评论线程 + 评论框。
|
**帖子详情**:标题区操作(赞、藏、举报、编辑、管理操作)→ 特殊组件(投票卡 / 悬赏条 / 抽奖卡)→ 正文 `PostContent`(门控块 UI)→ 文章目录 → 作者卡片 → 修订入口 → 评论线程 + 评论框。
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ web_src/ → public/assets/
|
|||||||
|
|
||||||
公开浏览:`/boards` 板块索引(链到 `/board/:id`)。
|
公开浏览:`/boards` 板块索引(链到 `/board/:id`)。
|
||||||
|
|
||||||
|
Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。
|
||||||
|
|
||||||
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、入口开关)。
|
友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、入口开关)。
|
||||||
|
|
||||||
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`。
|
Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`。
|
||||||
@@ -112,6 +112,53 @@ body.j13-body {
|
|||||||
.j13-sort a.is-active,
|
.j13-sort a.is-active,
|
||||||
.j13-sort a:hover { color: var(--j13-accent); font-weight: 600; }
|
.j13-sort a:hover { color: var(--j13-accent); font-weight: 600; }
|
||||||
|
|
||||||
|
.j13-search {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
background: var(--j13-bg);
|
||||||
|
scroll-margin-top: 4rem;
|
||||||
|
}
|
||||||
|
.j13-search__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.j13-search__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 8rem;
|
||||||
|
flex: 1 1 8rem;
|
||||||
|
}
|
||||||
|
.j13-search__label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--j13-muted);
|
||||||
|
}
|
||||||
|
.j13-search__field input {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
background: var(--j13-bg);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.j13-search__check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.j13-search__err {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.j13-post-list { list-style: none; margin: 0; padding: 0; }
|
.j13-post-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.j13-post-item {
|
.j13-post-item {
|
||||||
padding: 0.85rem 0;
|
padding: 0.85rem 0;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -91,6 +94,12 @@ type HomePageData struct {
|
|||||||
PageChrome
|
PageChrome
|
||||||
BoardName string
|
BoardName string
|
||||||
Sort string
|
Sort string
|
||||||
|
Keyword string
|
||||||
|
Tag string
|
||||||
|
Author string
|
||||||
|
TitleOnly bool
|
||||||
|
HasSearch bool
|
||||||
|
SearchError string
|
||||||
Posts []PostListItem
|
Posts []PostListItem
|
||||||
Page int
|
Page int
|
||||||
PrevPage int
|
PrevPage int
|
||||||
@@ -111,6 +120,54 @@ type PostListItem struct {
|
|||||||
CreatedLabel string
|
CreatedLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormAction 搜索表单提交路径(当前 Feed)
|
||||||
|
func (d HomePageData) FormAction() string {
|
||||||
|
if d.ActiveBoard > 0 {
|
||||||
|
return fmt.Sprintf("/board/%d", d.ActiveBoard)
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortHref 排序链接,保留搜索参数
|
||||||
|
func (d HomePageData) SortHref(sort string) string {
|
||||||
|
return buildFeedURL(d.ActiveBoard, sort, 0, d.Keyword, d.Tag, d.Author, d.TitleOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageHref 分页链接,保留搜索与排序
|
||||||
|
func (d HomePageData) PageHref(page int) string {
|
||||||
|
return buildFeedURL(d.ActiveBoard, d.Sort, page, d.Keyword, d.Tag, d.Author, d.TitleOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFeedURL(boardID uint, sort string, page int, keyword, tag, author string, titleOnly bool) string {
|
||||||
|
q := url.Values{}
|
||||||
|
if sort != "" && sort != "latest" {
|
||||||
|
q.Set("sort", sort)
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
q.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if kw := strings.TrimSpace(keyword); kw != "" {
|
||||||
|
q.Set("keyword", kw)
|
||||||
|
}
|
||||||
|
if t := strings.TrimSpace(tag); t != "" {
|
||||||
|
q.Set("tag", t)
|
||||||
|
}
|
||||||
|
if a := strings.TrimSpace(author); a != "" {
|
||||||
|
q.Set("author", a)
|
||||||
|
}
|
||||||
|
if titleOnly {
|
||||||
|
q.Set("title_only", "1")
|
||||||
|
}
|
||||||
|
path := "/"
|
||||||
|
if boardID > 0 {
|
||||||
|
path = fmt.Sprintf("/board/%d", boardID)
|
||||||
|
}
|
||||||
|
if enc := q.Encode(); enc != "" {
|
||||||
|
return path + "?" + enc
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
// Home 首页 / 板块
|
// Home 首页 / 板块
|
||||||
func (d Deps) Home(c *gin.Context) {
|
func (d Deps) Home(c *gin.Context) {
|
||||||
ctx := d.ctx(c)
|
ctx := d.ctx(c)
|
||||||
@@ -120,6 +177,11 @@ func (d Deps) Home(c *gin.Context) {
|
|||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
size := d.Settings.PageSizeDefault()
|
size := d.Settings.PageSizeDefault()
|
||||||
|
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||||
|
tag := strings.TrimSpace(c.Query("tag"))
|
||||||
|
author := strings.TrimSpace(c.Query("author"))
|
||||||
|
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
|
||||||
|
hasSearch := keyword != "" || tag != "" || author != ""
|
||||||
|
|
||||||
var boardID uint
|
var boardID uint
|
||||||
var boardName string
|
var boardName string
|
||||||
@@ -142,47 +204,77 @@ func (d Deps) Home(c *gin.Context) {
|
|||||||
chrome.Title = boardName + " · " + chrome.SiteName
|
chrome.Title = boardName + " · " + chrome.SiteName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data := HomePageData{
|
||||||
|
PageChrome: chrome,
|
||||||
|
BoardName: boardName,
|
||||||
|
Sort: sort,
|
||||||
|
Keyword: keyword,
|
||||||
|
Tag: tag,
|
||||||
|
Author: author,
|
||||||
|
TitleOnly: titleOnly,
|
||||||
|
HasSearch: hasSearch,
|
||||||
|
Page: page,
|
||||||
|
PrevPage: page - 1,
|
||||||
|
NextPage: page + 1,
|
||||||
|
HasPrev: page > 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
listKW := keyword
|
||||||
|
if keyword != "" {
|
||||||
|
kw, err := d.Settings.NormalizeSearchKeyword(keyword)
|
||||||
|
if err != nil {
|
||||||
|
data.SearchError = err.Error()
|
||||||
|
data.Posts = []PostListItem{}
|
||||||
|
ctx.HTML(http.StatusOK, "home", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listKW = kw
|
||||||
|
data.Keyword = kw
|
||||||
|
}
|
||||||
|
|
||||||
items, total, err := d.Post.ListItems(services.PostListQuery{
|
items, total, err := d.Post.ListItems(services.PostListQuery{
|
||||||
BoardID: boardID,
|
BoardID: boardID,
|
||||||
Page: page,
|
Page: page,
|
||||||
Size: size,
|
Size: size,
|
||||||
Sort: sort,
|
Sort: sort,
|
||||||
|
Keyword: listKW,
|
||||||
|
Tag: tag,
|
||||||
|
Author: author,
|
||||||
|
TitleOnly: titleOnly,
|
||||||
ViewerID: ctx.UserID(),
|
ViewerID: ctx.UserID(),
|
||||||
ViewerIsAdmin: ctx.IsAdmin(),
|
ViewerIsAdmin: ctx.IsAdmin(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, services.ErrSearchKeywordTooShort) || errors.Is(err, services.ErrSearchKeywordTooLong) {
|
||||||
|
data.SearchError = err.Error()
|
||||||
|
data.Posts = []PostListItem{}
|
||||||
|
ctx.HTML(http.StatusOK, "home", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.String(http.StatusInternalServerError, "加载帖子失败")
|
c.String(http.StatusInternalServerError, "加载帖子失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
posts := make([]PostListItem, 0, len(items))
|
posts := make([]PostListItem, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
author := strings.TrimSpace(it.User.Nickname)
|
authorName := strings.TrimSpace(it.User.Nickname)
|
||||||
if author == "" {
|
if authorName == "" {
|
||||||
author = it.User.Username
|
authorName = it.User.Username
|
||||||
}
|
}
|
||||||
bname := ""
|
bname := ""
|
||||||
if it.Board.ID > 0 {
|
if it.Board.ID > 0 {
|
||||||
bname = it.Board.Name
|
bname = it.Board.Name
|
||||||
}
|
}
|
||||||
posts = append(posts, PostListItem{
|
posts = append(posts, PostListItem{
|
||||||
ID: it.ID, Title: it.Title, AuthorName: author, BoardName: bname,
|
ID: it.ID, Title: it.Title, AuthorName: authorName, BoardName: bname,
|
||||||
Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount,
|
Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount,
|
||||||
CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"),
|
CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, "home", HomePageData{
|
data.Posts = posts
|
||||||
PageChrome: chrome,
|
data.HasMore = int64(page*size) < total
|
||||||
BoardName: boardName,
|
ctx.HTML(http.StatusOK, "home", data)
|
||||||
Sort: sort,
|
|
||||||
Posts: posts,
|
|
||||||
Page: page,
|
|
||||||
PrevPage: page - 1,
|
|
||||||
NextPage: page + 1,
|
|
||||||
HasPrev: page > 1,
|
|
||||||
HasMore: int64(page*size) < total,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSort(s string) string {
|
func normalizeSort(s string) string {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="j13-nav">
|
<nav class="j13-nav">
|
||||||
|
<a href="/#search">搜索</a>
|
||||||
<a href="/boards">板块</a>
|
<a href="/boards">板块</a>
|
||||||
{{if .ShowFriendLinksNav}}<a href="/links">友链</a>{{end}}
|
{{if .ShowFriendLinksNav}}<a href="/links">友链</a>{{end}}
|
||||||
{{if .LoggedIn}}
|
{{if .LoggedIn}}
|
||||||
|
|||||||
@@ -3,13 +3,36 @@
|
|||||||
<header class="j13-feed__header">
|
<header class="j13-feed__header">
|
||||||
<h1 class="j13-feed__title">{{if .BoardName}}{{.BoardName}}{{else}}全部帖子{{end}}</h1>
|
<h1 class="j13-feed__title">{{if .BoardName}}{{.BoardName}}{{else}}全部帖子{{end}}</h1>
|
||||||
<div class="j13-sort" role="navigation" aria-label="排序">
|
<div class="j13-sort" role="navigation" aria-label="排序">
|
||||||
<a class="{{if eq .Sort "latest"}}is-active{{end}}" href="{{sortURL .ActiveBoard "latest"}}">最新发帖</a>
|
<a class="{{if eq .Sort "latest"}}is-active{{end}}" href="{{.SortHref "latest"}}">最新发帖</a>
|
||||||
<a class="{{if eq .Sort "reply"}}is-active{{end}}" href="{{sortURL .ActiveBoard "reply"}}">最新回复</a>
|
<a class="{{if eq .Sort "reply"}}is-active{{end}}" href="{{.SortHref "reply"}}">最新回复</a>
|
||||||
<a class="{{if eq .Sort "hot"}}is-active{{end}}" href="{{sortURL .ActiveBoard "hot"}}">热门讨论</a>
|
<a class="{{if eq .Sort "hot"}}is-active{{end}}" href="{{.SortHref "hot"}}">热门讨论</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<form id="search" class="j13-search" method="get" action="{{.FormAction}}" role="search">
|
||||||
|
{{if and .Sort (ne .Sort "latest")}}<input type="hidden" name="sort" value="{{.Sort}}"/>{{end}}
|
||||||
|
<div class="j13-search__row">
|
||||||
|
<label class="j13-search__field">
|
||||||
|
<span class="j13-search__label">关键词</span>
|
||||||
|
<input type="search" name="keyword" value="{{.Keyword}}" placeholder="标题或正文" autocomplete="off"/>
|
||||||
|
</label>
|
||||||
|
<label class="j13-search__field">
|
||||||
|
<span class="j13-search__label">标签</span>
|
||||||
|
<input type="text" name="tag" value="{{.Tag}}" placeholder="整枚标签" autocomplete="off"/>
|
||||||
|
</label>
|
||||||
|
<label class="j13-search__field">
|
||||||
|
<span class="j13-search__label">作者</span>
|
||||||
|
<input type="text" name="author" value="{{.Author}}" placeholder="用户名或昵称" autocomplete="off"/>
|
||||||
|
</label>
|
||||||
|
<label class="j13-search__check">
|
||||||
|
<input type="checkbox" name="title_only" value="1"{{if .TitleOnly}} checked{{end}}/>
|
||||||
|
仅标题
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="j13-btn">搜索</button>
|
||||||
|
</div>
|
||||||
|
{{if .SearchError}}<p class="j13-search__err" role="alert">{{.SearchError}}</p>{{end}}
|
||||||
|
</form>
|
||||||
{{if not .Posts}}
|
{{if not .Posts}}
|
||||||
<p class="j13-empty">暂无帖子。</p>
|
<p class="j13-empty">{{if .SearchError}}请调整搜索条件后重试。{{else if .HasSearch}}未找到匹配帖子。{{else}}暂无帖子。{{end}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="j13-post-list">
|
<ul class="j13-post-list">
|
||||||
{{range .Posts}}
|
{{range .Posts}}
|
||||||
@@ -29,9 +52,9 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if or .HasPrev .HasMore}}
|
{{if or .HasPrev .HasMore}}
|
||||||
<nav class="j13-pager">
|
<nav class="j13-pager">
|
||||||
{{if .HasPrev}}<a href="{{pageURL .ActiveBoard .Sort .PrevPage}}">上一页</a>{{end}}
|
{{if .HasPrev}}<a href="{{.PageHref .PrevPage}}">上一页</a>{{end}}
|
||||||
<span>第 {{.Page}} 页</span>
|
<span>第 {{.Page}} 页</span>
|
||||||
{{if .HasMore}}<a href="{{pageURL .ActiveBoard .Sort .NextPage}}">下一页</a>{{end}}
|
{{if .HasMore}}<a href="{{.PageHref .NextPage}}">下一页</a>{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -112,6 +112,53 @@ body.j13-body {
|
|||||||
.j13-sort a.is-active,
|
.j13-sort a.is-active,
|
||||||
.j13-sort a:hover { color: var(--j13-accent); font-weight: 600; }
|
.j13-sort a:hover { color: var(--j13-accent); font-weight: 600; }
|
||||||
|
|
||||||
|
.j13-search {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
background: var(--j13-bg);
|
||||||
|
scroll-margin-top: 4rem;
|
||||||
|
}
|
||||||
|
.j13-search__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.j13-search__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 8rem;
|
||||||
|
flex: 1 1 8rem;
|
||||||
|
}
|
||||||
|
.j13-search__label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--j13-muted);
|
||||||
|
}
|
||||||
|
.j13-search__field input {
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
background: var(--j13-bg);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.j13-search__check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.j13-search__err {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.j13-post-list { list-style: none; margin: 0; padding: 0; }
|
.j13-post-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.j13-post-item {
|
.j13-post-item {
|
||||||
padding: 0.85rem 0;
|
padding: 0.85rem 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user