diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 35c6f42..561ca60 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -18,7 +18,7 @@ - [x] Feed 排序:`latest`(最新发帖)/ `reply`(最新回复)/ `hot`(热门) - [x] 板块筛选:全部 + 单板块 - [ ] 列表样式可配:`title` | `excerpt` | `thumbnail` -- [ ] 搜索:关键词、标签、作者、仅标题(`title_only`) +- [x] 搜索:关键词、标签、作者、仅标题(`title_only`)— SSR Feed 面板(`/` / `/board/:id`) - [ ] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序) - [ ] 登录用户右栏/侧边:签到与抽奖入口(入口暂在 `/profile` 钱包区) - [ ] 下拉刷新(移动端) diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 78f8cdb..439eaa4 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -80,7 +80,7 @@ ### 2.2 中栏 -**Feed**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题)+ 虚拟列表项(标题、作者、板块徽章、标签、回复数、最后回复、置顶/精华标记)。列表样式随 `feed_list_style`。 +**Feed**:排序条(最新发帖 / 最新回复 / 热门)+ 搜索面板(关键词、标签、作者、仅标题;已迁 SSR GET)+ 列表项(标题、作者、板块徽章、回复数、置顶/精华)。虚拟滚动 / `feed_list_style` 未迁。 **帖子详情**:标题区操作(赞、藏、举报、编辑、管理操作)→ 特殊组件(投票卡 / 悬赏条 / 抽奖卡)→ 正文 `PostContent`(门控块 UI)→ 文章目录 → 作者卡片 → 修订入口 → 评论线程 + 评论框。 diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index 2b41825..c4ab303 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -90,6 +90,8 @@ web_src/ → public/assets/ 公开浏览:`/boards` 板块索引(链到 `/board/:id`)。 +Feed 搜索:`/` / `/board/:id` 面板(`keyword` / `tag` / `author` / `title_only`);排序与分页 URL 保参;顶栏链到 `/#search`。 + 友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、入口开关)。 Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`。 \ No newline at end of file diff --git a/public/assets/site.css b/public/assets/site.css index d65ecae..e84b921 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -112,6 +112,53 @@ body.j13-body { .j13-sort a.is-active, .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-item { padding: 0.85rem 0; diff --git a/routers/web/home.go b/routers/web/home.go index 7883272..1f1503a 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -1,7 +1,10 @@ package web import ( + "errors" + "fmt" "net/http" + "net/url" "strconv" "strings" "time" @@ -89,14 +92,20 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { // HomePageData Feed type HomePageData struct { PageChrome - BoardName string - Sort string - Posts []PostListItem - Page int - PrevPage int - NextPage int - HasPrev bool - HasMore bool + BoardName string + Sort string + Keyword string + Tag string + Author string + TitleOnly bool + HasSearch bool + SearchError string + Posts []PostListItem + Page int + PrevPage int + NextPage int + HasPrev bool + HasMore bool } // PostListItem 列表项 @@ -111,6 +120,54 @@ type PostListItem struct { 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 首页 / 板块 func (d Deps) Home(c *gin.Context) { ctx := d.ctx(c) @@ -120,6 +177,11 @@ func (d Deps) Home(c *gin.Context) { page = 1 } 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 boardName string @@ -142,47 +204,77 @@ func (d Deps) Home(c *gin.Context) { 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{ BoardID: boardID, Page: page, Size: size, Sort: sort, + Keyword: listKW, + Tag: tag, + Author: author, + TitleOnly: titleOnly, ViewerID: ctx.UserID(), ViewerIsAdmin: ctx.IsAdmin(), }) 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, "加载帖子失败") return } posts := make([]PostListItem, 0, len(items)) for _, it := range items { - author := strings.TrimSpace(it.User.Nickname) - if author == "" { - author = it.User.Username + authorName := strings.TrimSpace(it.User.Nickname) + if authorName == "" { + authorName = it.User.Username } bname := "" if it.Board.ID > 0 { bname = it.Board.Name } 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, CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"), }) } - ctx.HTML(http.StatusOK, "home", HomePageData{ - PageChrome: chrome, - BoardName: boardName, - Sort: sort, - Posts: posts, - Page: page, - PrevPage: page - 1, - NextPage: page + 1, - HasPrev: page > 1, - HasMore: int64(page*size) < total, - }) + data.Posts = posts + data.HasMore = int64(page*size) < total + ctx.HTML(http.StatusOK, "home", data) } func normalizeSort(s string) string { diff --git a/templates/base/navbar.tmpl b/templates/base/navbar.tmpl index d1913fc..a89fcf4 100644 --- a/templates/base/navbar.tmpl +++ b/templates/base/navbar.tmpl @@ -9,6 +9,7 @@