16 Commits

Author SHA1 Message Date
94823c0821 chore: 发布 Docker 镜像版本 1.1.5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:16:29 +08:00
b608ec85ba fix: 桌面隐藏导航汉堡,恢复评论数与共 N 条右对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:11:29 +08:00
6048d71759 fix: 帖行评论数勿贴右缘,手机 SSR 顶栏/页脚与 React 同构
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 08:06:57 +08:00
37d14a22d5 fix: 发版后软刷新检测入口壳并在 chunk 404 时硬刷新
避免 Logo/下拉刷新仍跑旧 index.js,以及站内消息预取失败弹出英文 toast。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:53:39 +08:00
a02d103cfe fix: 排序栏 count 紧跟标签,FeedSortBar 与 SSR 统一为 a 标签
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:48:18 +08:00
21c6233fc0 feat: 排序标签强制刷新、推荐帖仅 featured,软刷新同步 limits/品牌文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:32:21 +08:00
4ca5c17e2e fix: 拆除爬虫专用 HTML,并补齐 /favicon.ico 与 head 图标注入
人机统一走 SPA/首页 SSR;约定路径与 link rel=icon 便于爬虫识别站点图标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:15:06 +08:00
cf7a37eccc chore: 忽略 lucide path 抽取临时文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:08:47 +08:00
44eed97fe0 feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动
补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 07:07:38 +08:00
9e134916a4 fix: 软刷新齐套前保留旧画面,避免一点击就卸光
Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
2026-09-01 04:56:10 +08:00
ba60a9b1c4 feat: 调整 Feed 排序为新评论/新帖子/推荐帖
默认按新评论;推荐排序优先 featured,并将用户可见「精华」文案改为「推荐」。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 01:59:01 +08:00
132ca5c82c fix: 用 Zustand 缓存 Feed 列表,返回时恢复滚动且不重复请求
点击 Logo 仍强制刷新;后退忽略 history 上的 refreshFeed,并支持过期静默刷新与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 01:50:33 +08:00
185649db7c fix: 增加手机端排序栏顶部间距
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 23:56:40 +08:00
9d926750b2 chore: 发布 Docker 镜像 1.1.4
含城市中文映射补全与监控概览双通道口径对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 09:07:50 +08:00
a91d917fa9 fix: 对齐监控概览双通道口径文案与诊断抽样
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:58:03 +08:00
34f3d088ad fix: 补全 IP2Location 中国城市中文映射并按省消歧
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:40:12 +08:00
86 changed files with 6861 additions and 1152 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ tmp-cookie.txt
*.swp
Thumbs.db
.DS_Store
# Lucide path <20><>ȡ<EFBFBD><C8A1>ʱ<EFBFBD><CAB1><EFBFBD><EFBFBD>
frontend/tmp-lucide-paths.json

View File

@@ -5,7 +5,7 @@ APP_NAME := jiang13
MAIN_PKG := ./cmd/jiang13
BUILD_DIR := dist
DEV_DATA_DIR := dist/data
VERSION := 1.1.3
VERSION := 1.1.5
LDFLAGS := -s -w -X main.version=$(VERSION)
REGISTRY_IMAGE := hangzhang714128/jiang13-forum

View File

@@ -206,15 +206,15 @@ docker run -d --name jiang13 \
docker login
.\build.bat -Target docker # Windows
# make docker # Linux/macOS
docker push hangzhang714128/jiang13-forum:1.1.3
docker push hangzhang714128/jiang13-forum:1.1.5
docker push hangzhang714128/jiang13-forum:latest
```
或直接构建:
```bash
docker build --build-arg VERSION=1.1.3 -t hangzhang714128/jiang13-forum:1.1.3 -t hangzhang714128/jiang13-forum:latest .
docker push hangzhang714128/jiang13-forum:1.1.3
docker build --build-arg VERSION=1.1.5 -t hangzhang714128/jiang13-forum:1.1.5 -t hangzhang714128/jiang13-forum:latest .
docker push hangzhang714128/jiang13-forum:1.1.5
docker push hangzhang714128/jiang13-forum:latest
```

View File

@@ -12,7 +12,7 @@ $AppName = 'jiang13'
$MainPkg = './cmd/jiang13'
$BuildDir = 'dist'
$DevDataDir = 'dist/data'
$Version = '1.1.3'
$Version = '1.1.5'
$RegistryImage = 'hangzhang714128/jiang13-forum'
$Ldlags = "-s -w -X main.version=$Version"

View File

@@ -171,6 +171,10 @@ func main() {
}
fmt.Println()
fmt.Println("=== 对账抽样(近 30 日非 bot)===")
printGeoReconcileSamples(db, have, since)
fmt.Println()
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
cols := []string{"id", "created_at", "path", "is_bot"}
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
@@ -217,10 +221,103 @@ func main() {
}
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。")
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
fmt.Fprintln(os.Stderr, "注意: 请求日志有地理 ≠ 概览地图/排行有数据(后者只读 page_views)。")
os.Exit(2)
}
}
func printGeoReconcileSamples(db *gorm.DB, have map[string]bool, since time.Time) {
type sample struct {
ID uint
CreatedAt time.Time
IP string
Country string
Region string
RegionISO string `gorm:"column:region_iso"`
City string
ASN uint
ASOrg string `gorm:"column:as_org"`
Path string
}
selCols := []string{"id", "created_at", "path"}
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
if have[c] {
selCols = append(selCols, c)
}
}
sel := strings.Join(selCols, ", ")
printBlock := func(title string, n int64, rows []sample) {
fmt.Printf("%s: %d\n", title, n)
if len(rows) == 0 {
return
}
for _, s := range rows {
fmt.Printf(" #%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q path=%s\n",
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.Path)
}
}
if have["city"] {
var n int64
_ = db.Table("page_views").
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
Count(&n).Error
var rows []sample
_ = db.Table("page_views").
Select(sel).
Where("created_at >= ? AND is_bot = ? AND city <> '' AND city GLOB '[A-Za-z]*'", since, false).
Order("id DESC").
Limit(5).
Scan(&rows).Error
printBlock("英文城市名(可能未中文化)", n, rows)
} else {
fmt.Println("英文城市名: (city 列缺失)")
}
if have["region"] || have["region_iso"] {
cond := "created_at >= ? AND is_bot = ? AND country <> ''"
emptyParts := []string{}
if have["region"] {
emptyParts = append(emptyParts, "(region = '' OR region IS NULL OR region = '-')")
}
if have["region_iso"] {
emptyParts = append(emptyParts, "(region_iso = '' OR region_iso IS NULL)")
}
where := cond + " AND (" + strings.Join(emptyParts, " AND ") + ")"
var n int64
_ = db.Table("page_views").Where(where, since, false).Count(&n).Error
var rows []sample
_ = db.Table("page_views").
Select(sel).
Where(where, since, false).
Order("id DESC").
Limit(5).
Scan(&rows).Error
printBlock("有国家但省为空", n, rows)
} else {
fmt.Println("有国家但省为空: (省列缺失)")
}
if have["asn"] {
var n int64
_ = db.Table("page_views").
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
Count(&n).Error
var rows []sample
_ = db.Table("page_views").
Select(sel).
Where("created_at >= ? AND is_bot = ? AND (asn = 0 OR asn IS NULL)", since, false).
Order("id DESC").
Limit(5).
Scan(&rows).Error
printBlock("asn=0(无运营商)", n, rows)
} else {
fmt.Println("asn=0: (asn 列缺失)")
}
}
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
fmt.Println("=== Geo 数据文件 ===")
v4, v6, asn, country := suite.Paths()

View File

@@ -35,7 +35,9 @@
CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat/Lon/Zip/TimeZone。管理端展示**完整客户端 IP**。
写入时做本地中文映射(省/常见城市/运营商);`country` 存 ISO2,展示用中文名。
写入时做本地中文映射(省 / 中国城市按 IP2Location 英文名 / 运营商);同音城市(如苏州/宿州)按省份歧义。`country` 存 ISO2,展示用中文名。历史 `page_views` / 请求日志不会回写,仅影响新写入。
**口径提示:** 访客地图与城市/运营商排行只聚合 `page_views`(前台浏览);请求日志里的地理来自 access JSONL。仅有请求日志、没有前台浏览时,地图与排行可为空——属预期。
## 写入路径(性能)
@@ -70,6 +72,7 @@ CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
```
会输出 Geo 文件状态、近 30 日省级 Top,以及对账抽样(英文城市名、有国家但省为空、`asn=0`)。
## 明确不做
- 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库

View File

@@ -14,9 +14,10 @@ import (
var staticFS embed.FS
var (
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
spaBrandTitleFn func() string
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
spaBrandTitleFn func() string
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
spaBrandFaviconFn func() string // 站点 Favicon URL,注入 <link rel="icon">
)
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
@@ -29,13 +30,19 @@ func SetSPABrandingJSON(fn func() []byte) {
spaBrandJSONFn = fn
}
// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 <link rel="icon">
func SetSPAFaviconURL(fn func() string) {
spaBrandFaviconFn = fn
}
// SetupEmbed 配置内嵌资源:React SPA 静态资源
func SetupEmbed(r *gin.Engine) error {
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
r.GET("/assets/*filepath", func(c *gin.Context) {
// hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404
c.Header("Cache-Control", "public, max-age=31536000, immutable")
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
c.Writer = w
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
@@ -51,6 +58,30 @@ func SetupEmbed(r *gin.Engine) error {
return nil
}
// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control
type cacheOnOKWriter struct {
gin.ResponseWriter
cacheControl string
wroteHeader bool
}
func (w *cacheOnOKWriter) WriteHeader(code int) {
if !w.wroteHeader {
w.wroteHeader = true
if code == http.StatusOK {
w.Header().Set("Cache-Control", w.cacheControl)
}
}
w.ResponseWriter.WriteHeader(code)
}
func (w *cacheOnOKWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
func ServeSPA(c *gin.Context) {
ServeSPAWithMeta(c, nil)

View File

@@ -5,12 +5,16 @@ import (
"encoding/json"
"html"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
)
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据(仅 <head>,不写 #root,避免刷新闪屏)
var spaRootEmptyRe = regexp.MustCompile(`(?s)<div id="root">\s*</div>`)
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。
// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。
type SPAPageMeta struct {
Title string // 完整 <title>
Description string
@@ -23,6 +27,8 @@ type SPAPageMeta struct {
Robots string // 如 noindex,nofollow
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
Status int // HTTP 状态码,0 视为 200
RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR)
BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON
}
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
@@ -37,8 +43,9 @@ func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
return
}
data = applySPAPageMeta(data, meta)
// 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希
c.Header("Cache-Control", "no-cache")
// 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存)
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Data(status, "text/html; charset=utf-8", data)
}
@@ -56,15 +63,20 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
}
var head strings.Builder
writeMeta(&head, "description", meta.Description)
writeMeta(&head, "keywords", meta.Keywords)
// —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 </title>,不经 JS ——
var seo strings.Builder
writeMeta(&seo, "description", meta.Description)
writeMeta(&seo, "keywords", meta.Keywords)
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
head.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
seo.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
}
if favicon := spaFaviconHref(); favicon != "" {
seo.WriteString(`<link rel="icon" href="` + html.EscapeString(favicon) + `"/>`)
seo.WriteString(`<link rel="shortcut icon" href="` + html.EscapeString(favicon) + `"/>`)
}
robots := strings.TrimSpace(meta.Robots)
if robots != "" {
writeMeta(&head, "robots", robots)
writeMeta(&seo, "robots", robots)
}
ogType := strings.TrimSpace(meta.OGType)
@@ -75,36 +87,63 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
if locale == "" {
locale = "zh_CN"
}
writeProp(&head, "og:type", ogType)
writeProp(&head, "og:site_name", meta.SiteName)
writeProp(&head, "og:locale", locale)
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title))
writeProp(&head, "og:description", meta.Description)
writeProp(&head, "og:url", meta.Canonical)
writeProp(&head, "og:image", meta.OGImage)
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage))
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title))
writeMetaName(&head, "twitter:description", meta.Description)
writeMetaName(&head, "twitter:image", meta.OGImage)
writeProp(&seo, "og:type", ogType)
writeProp(&seo, "og:site_name", meta.SiteName)
writeProp(&seo, "og:locale", locale)
writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
writeProp(&seo, "og:description", meta.Description)
writeProp(&seo, "og:url", meta.Canonical)
writeProp(&seo, "og:image", meta.OGImage)
writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
writeMetaName(&seo, "twitter:description", meta.Description)
writeMetaName(&seo, "twitter:image", meta.OGImage)
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
head.WriteString(`<script type="application/ld+json">`)
head.WriteString(jsonld)
head.WriteString(`</script>`)
// 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
seo.WriteString(`<script type="application/ld+json">`)
seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
seo.WriteString(`</script>`)
}
// 同步注入品牌配置,避免 React 首屏用默认名闪一下
if boot := spaBrandingBootScript(); boot != "" {
head.WriteString(boot)
if seo.Len() > 0 {
data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
}
if head.Len() > 0 {
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1)
// —— 可执行 boot 脚本仍放在 </head> 前 ——
var boot strings.Builder
if s := spaBrandingBootScript(); s != "" {
boot.WriteString(s)
}
if s := spaHomeBootScript(meta.BootJSON); s != "" {
boot.WriteString(s)
}
if boot.Len() > 0 {
data = bytes.Replace(data, []byte("</head>"), []byte(boot.String()+"</head>"), 1)
}
if root := strings.TrimSpace(meta.RootHTML); root != "" {
data = injectSPARootHTML(data, root)
}
return data
}
// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO)
func spaHomeBootScript(raw []byte) string {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || !json.Valid(raw) {
return ""
}
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
return "<script>window.__J13_HOME_BOOT__=" + string(safe) + ";</script>"
}
// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白)
func injectSPARootHTML(data []byte, rootHTML string) []byte {
return spaRootEmptyRe.ReplaceAll(data, []byte(`<div id="root">`+rootHTML+`</div>`))
}
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
func spaBrandingBootScript() string {
if spaBrandJSONFn == nil {
@@ -114,7 +153,6 @@ func spaBrandingBootScript() string {
if len(raw) == 0 || !json.Valid(raw) {
return ""
}
// 防止 JSON 字符串中的 </script> 提前闭合标签
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
}
@@ -154,3 +192,11 @@ func firstNonEmpty(vals ...string) string {
}
return ""
}
// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL)
func spaFaviconHref() string {
if spaBrandFaviconFn == nil {
return ""
}
return strings.TrimSpace(spaBrandFaviconFn())
}

View File

@@ -5,23 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="拾三一隅,自在交流" />
<title>姜十三论坛 - 拾三一隅,自在交流</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
html { scrollbar-gutter: stable; }
html, body, #root { height: 100%; margin: 0; touch-action: pan-x pan-y; }
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.app-header { height: 56px; flex-shrink: 0; }
.site-footer { flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; touch-action: pan-x pan-y; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; touch-action: pan-x pan-y; }
.sidebar { width: 210px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.aside-panel { width: 280px; flex-shrink: 0; }
@media (max-width: 1100px) { .aside-panel { display: none; } }
@media (max-width: 768px) { .sidebar { display: none; } }
</style>
<!-- 样式由 Vite 构建注入外链 CSS,不在此内联 style -->
<script>
(function () {
var theme = localStorage.getItem('j13-theme') || 'light';

View File

@@ -51,7 +51,8 @@
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.4",
"zod": "^4.4.3"
"zod": "^4.4.3",
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/node": "^25.9.3",
@@ -4480,6 +4481,34 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@@ -52,7 +52,8 @@
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.4",
"zod": "^4.4.3"
"zod": "^4.4.3",
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/node": "^25.9.3",

View File

@@ -0,0 +1,60 @@
import fs from 'fs';
import path from 'path';
const iconsDir = path.join('node_modules/lucide-react/dist/esm/icons');
const names = [
'search', 'house', 'star', 'folder-git-2', 'link-2', 'earth', 'file-text',
'message-circle', 'tags', 'moon', 'clock', 'badge-check', 'sliders-horizontal',
'plus', 'sun', 'mail', 'calendar-check', 'check', 'gift', 'panel-right',
'user-plus', 'layout-dashboard',
'code-2', 'coffee', 'help-circle', 'message-square', 'lightbulb', 'book-open',
'gamepad-2', 'palette', 'music', 'camera', 'heart', 'zap', 'globe', 'users',
'briefcase', 'graduation-cap', 'shopping-bag', 'map-pin', 'megaphone', 'flame',
'folder', 'wrench', 'cpu',
];
function esc(v) {
return String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function nodeToInner(nodes) {
return nodes.map(([tag, attrs]) => {
let s = `<${tag}`;
for (const [k, v] of Object.entries(attrs)) {
if (k === 'key') continue;
s += ` ${k}="${esc(v)}"`;
}
return `${s}/>`;
}).join('');
}
function resolveIconFile(name) {
let f = path.join(iconsDir, `${name}.mjs`);
let src = fs.readFileSync(f, 'utf8');
const re = src.match(/export \{ default \} from '\.\/([^']+)';/);
if (re) {
f = path.join(iconsDir, re[1]);
src = fs.readFileSync(f, 'utf8');
}
return { f, src };
}
const out = {};
for (const n of names) {
try {
const { src } = resolveIconFile(n);
const m = src.match(/const __iconNode = (\[[\s\S]*?\]);/);
if (!m) {
console.error('no node', n);
continue;
}
// eslint-disable-next-line no-eval
const nodes = eval(m[1]);
out[n] = nodeToInner(nodes);
} catch (e) {
console.error('fail', n, e.message);
}
}
fs.writeFileSync('tmp-lucide-paths.json', JSON.stringify(out, null, 2));
console.log('wrote', Object.keys(out).length, 'icons');

View File

@@ -0,0 +1,168 @@
import fs from 'fs';
import path from 'path';
const paths = JSON.parse(fs.readFileSync('tmp-lucide-paths.json', 'utf8'));
function splitInner(inner) {
const tags = [];
const re = /<([a-z]+)([^>]*)\/>/g;
let m;
while ((m = re.exec(inner))) {
tags.push(`<${m[1]}${m[2]}/>`);
}
return tags;
}
function goFunc(name, size, key, className = '') {
const tags = splitInner(paths[key]);
const lines = tags.map((t) => `\t\t\`${t}\`,`).join('\n');
if (className) {
return `func ${name}() string {\n\treturn ssrSVGWithClass(${size}, "${className}",\n${lines}\n\t)\n}\n\n`;
}
return `func ${name}() string {\n\treturn ssrSVG(${size},\n${lines}\n\t)\n}\n\n`;
}
const boardKeys = [
'code-2', 'coffee', 'help-circle', 'message-square', 'lightbulb', 'book-open',
'gamepad-2', 'palette', 'music', 'camera', 'heart', 'zap', 'globe', 'users',
'briefcase', 'graduation-cap', 'shopping-bag', 'map-pin', 'megaphone', 'flame',
'star', 'folder', 'wrench', 'cpu',
];
const defaults = [
'code-2', 'coffee', 'help-circle', 'message-square',
'lightbulb', 'book-open', 'gamepad-2', 'palette',
];
let out = `package handler
import (
\t"strconv"
\t"strings"
\t"time"
)
// 内联 SVG:path 对齐 lucide-react@1.18(由 scripts/extract-lucide-paths.mjs 抽取)
func ssrSVG(size int, paths ...string) string {
\treturn ssrSVGWithClass(size, "", paths...)
}
func ssrSVGWithClass(size int, className string, paths ...string) string {
\tvar b strings.Builder
\tb.WriteString(\`<svg xmlns="http://www.w3.org/2000/svg" width="\`)
\tb.WriteString(strconv.Itoa(size))
\tb.WriteString(\`" height="\`)
\tb.WriteString(strconv.Itoa(size))
\tb.WriteString(\`" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"\`)
\tif className != "" {
\t\tb.WriteString(\` class="\`)
\t\tb.WriteString(className)
\t\tb.WriteString(\`"\`)
\t}
\tb.WriteString(\` aria-hidden="true">\`)
\tfor _, p := range paths {
\t\tb.WriteString(p)
\t}
\tb.WriteString(\`</svg>\`)
\treturn b.String()
}
`;
out += goFunc('ssrIconSearch', 16, 'search', 'header-search-icon');
out += goFunc('ssrIconSliders', 15, 'sliders-horizontal');
out += goFunc('ssrIconPlus', 16, 'plus');
out += goFunc('ssrIconMoon', 18, 'moon');
out += goFunc('ssrIconSun', 18, 'sun');
out += goFunc('ssrIconMail', 18, 'mail');
out += goFunc('ssrIconHome', 18, 'house');
out += goFunc('ssrIconStar', 18, 'star');
out += goFunc('ssrIconFolderGit', 18, 'folder-git-2');
out += goFunc('ssrIconLink2', 18, 'link-2');
out += goFunc('ssrIconEarth', 18, 'earth');
out += goFunc('ssrIconFileText', 18, 'file-text');
out += goFunc('ssrIconLayoutDashboard', 18, 'layout-dashboard');
out += goFunc('ssrIconMessageCircle', 16, 'message-circle');
out += goFunc('ssrIconClock', 16, 'clock');
out += goFunc('ssrIconBadgeCheck', 16, 'badge-check');
out += goFunc('ssrIconTags', 16, 'tags');
out += goFunc('ssrIconUserPlus', 16, 'user-plus');
out += goFunc('ssrIconCalendarCheck', 18, 'calendar-check');
out += goFunc('ssrIconCheck', 18, 'check');
out += goFunc('ssrIconGift', 15, 'gift');
out += goFunc('ssrIconPanelRight', 18, 'panel-right');
out += '// 板块图标 path(key 对齐 BOARD_ICON_OPTIONS / AllowedBoardIcons)\nvar ssrBoardIconInner = map[string]string{\n';
for (const k of boardKeys) {
out += `\t"${k}": \`${paths[k]}\`,\n`;
}
out += '}\n\n';
out += '// 与前端 DEFAULT_ICONS 顺序一致(按 themeIndex 回退)\nvar ssrBoardDefaultIcons = []string{\n';
for (const k of defaults) {
out += `\t"${k}",\n`;
}
out += '}\n\n';
out += `// ssrBoardIconSVG 输出板块 Lucide 图标;class 打在 svg 上(与 React BoardIconDisplay 一致)
func ssrBoardIconSVG(icon string, themeIndex int, className string) string {
\tkey := strings.TrimSpace(strings.ToLower(icon))
\tinner, ok := ssrBoardIconInner[key]
\tif !ok || inner == "" {
\t\tif themeIndex < 0 {
\t\t\tthemeIndex = 0
\t\t}
\t\tkey = ssrBoardDefaultIcons[themeIndex%len(ssrBoardDefaultIcons)]
\t\tinner = ssrBoardIconInner[key]
\t}
\treturn ssrSVGWithClass(18, className, inner)
}
// formatSSRRelativeTime 与前端 formatTime 同规则
func formatSSRRelativeTime(t time.Time) string {
\tif t.IsZero() {
\t\treturn ""
\t}
\tnow := time.Now()
\tdiffSec := now.Sub(t).Seconds()
\tif diffSec < 0 {
\t\tdiffSec = 0
\t}
\tif diffSec < 60 {
\t\treturn "刚刚"
\t}
\tif diffSec < 3600 {
\t\treturn strconv.Itoa(int(diffSec/60)) + "分钟前"
\t}
\tif diffSec < 86400 {
\t\treturn strconv.Itoa(int(diffSec/3600)) + "小时前"
\t}
\tdiffDay := int(diffSec / 86400)
\tif diffDay < 30 {
\t\treturn strconv.Itoa(diffDay) + "天前"
\t}
\tif t.Year() == now.Year() {
\t\treturn strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
\t}
\treturn strconv.Itoa(t.Year()) + "年" + strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
}
// formatSSRShortDateTime 与前端 formatShortDateTime 同规则(MM-DD HH:mm)
func formatSSRShortDateTime(t time.Time) string {
\tif t.IsZero() {
\t\treturn ""
\t}
\tlocal := t.Local()
\tpad := func(n int) string {
\t\tif n < 10 {
\t\t\treturn "0" + strconv.Itoa(n)
\t\t}
\t\treturn strconv.Itoa(n)
\t}
\treturn pad(int(local.Month())) + "-" + pad(local.Day()) + " " + pad(local.Hour()) + ":" + pad(local.Minute())
}
`;
const outPath = path.join('..', 'handler', 'ssr_icons.go');
fs.writeFileSync(outPath, out);
console.log('wrote', outPath, Object.keys(paths).length, 'icons');

View File

@@ -17,6 +17,7 @@ import PageLoader from './components/PageLoader';
import AuthPageFallback from './components/AuthPageFallback';
import { Toaster } from './components/ui/sonner';
import PullToRefresh from './components/PullToRefresh';
import TopProgressBar from './components/TopProgressBar';
import { lazyWithRetry } from './utils/lazyWithRetry';
const HomePage = lazyWithRetry(() => import('./pages/HomePage'));
@@ -87,10 +88,10 @@ const router = createBrowserRouter(
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/links" element={<LinksPage />} />
<Route path="/showcase" element={<Suspense fallback={<PageLoader />}><ShowcasePage /></Suspense>} />
<Route path="/showcase" element={<Suspense fallback={null}><ShowcasePage /></Suspense>} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
<Route path="/page/:slug" element={<Suspense fallback={null}><SitePageView /></Suspense>} />
<Route path="*" element={<Suspense fallback={null}><NotFoundPage /></Suspense>} />
</Route>
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
</Route>,
@@ -103,6 +104,7 @@ export default function App() {
<AuthProvider>
<ErrorBoundary>
<RouterProvider router={router} />
<TopProgressBar />
<PullToRefresh />
<Toaster />
</ErrorBoundary>

View File

@@ -215,6 +215,20 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [
{ id: 'showcase', enabled: false },
];
export type FeedSortId = 'reply' | 'latest' | 'hot';
export interface FeedSortTab {
id: FeedSortId;
label: string;
enabled: boolean;
}
export const DEFAULT_FEED_SORT_TABS: FeedSortTab[] = [
{ id: 'reply', label: '新评论', enabled: true },
{ id: 'latest', label: '新帖子', enabled: true },
{ id: 'hot', label: '推荐帖', enabled: true },
];
export interface ForumLimits {
post_edit_window_hours: number;
comment_edit_window_minutes: number;
@@ -255,6 +269,8 @@ export interface ForumLimits {
footer_show_showcase: boolean;
/** 首页列表样式:title 仅标题 / thumbnail 缩略图 */
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
/** 首页排序标签:名称、顺序、启停 */
feed_sort_tabs: FeedSortTab[];
/** 伪静态(固定链接)开关 */
permalink_enabled: boolean;
/** 伪静态后缀,不含点,如 html / htm */
@@ -285,6 +301,7 @@ export interface ForumLimitsPublic {
nav_show_showcase: boolean;
footer_show_showcase: boolean;
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
feed_sort_tabs: FeedSortTab[];
permalink_enabled: boolean;
permalink_ext: string;
/** 是否上报前台路由 pageview(与后台监控采集开关同步) */

View File

@@ -1,6 +1,5 @@
import { CalendarCheck, Check, Gift, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { useCheckIn } from '../hooks/useCheckIn';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
@@ -8,8 +7,14 @@ import { loginPath } from '../utils/authRedirect';
/** 右侧栏首块底部:每日签到 */
export default function AsideCheckInStrip() {
const nav = useNavigate();
const { user } = useAuth();
const { status, loading, busy, doCheckIn } = useCheckIn();
const { user, loading: authLoading } = useAuth();
const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading);
// 鉴权未完成且无种子:空白,避免访客签到卡闪一下再消失
// 有 SSR boot 时 loading 一开始就是 false
if (authLoading) {
return null;
}
if (!user) {
return (
@@ -37,17 +42,14 @@ export default function AsideCheckInStrip() {
);
}
if (loading && !status) {
return (
<div className="widget-checkin" aria-busy="true" aria-label="签到加载中">
<Skeleton className="widget-checkin-skeleton" />
</div>
);
// 登录用户:status 未到齐前不渲染最终态
if (loading || !status) {
return null;
}
const checkedIn = !!status?.checked_in;
const streak = status?.streak ?? 0;
const todayPoints = status?.today_points ?? 5;
const checkedIn = !!status.checked_in;
const streak = status.streak ?? 0;
const todayPoints = status.today_points ?? 5;
const meta = checkedIn
? (streak > 0 ? `连续 ${streak} 天 · 今日已获得 ${todayPoints} 积分` : `今日已获得 ${todayPoints} 积分`)

View File

@@ -1,4 +1,4 @@
import { Sparkles } from 'lucide-react';
import { BadgeCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
@@ -6,13 +6,13 @@ interface Props {
size?: number;
}
/** 精华帖标识 */
/** 推荐帖标识 */
export default function FeaturedIcon({ className, size = 16 }: Props) {
return (
<Sparkles
<BadgeCheck
className={cn('post-featured-icon', className)}
size={size}
aria-label="精华"
aria-label="推荐"
role="img"
/>
);

View File

@@ -1,29 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton';
import PostListSkeleton from './PostListSkeleton';
import { useForumLimits } from '../hooks/useForumLimits';
/** 首页 Feed 初始骨架(排序栏 + 列表) */
export default function FeedPageSkeleton() {
const { limits } = useForumLimits();
return (
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
<div className="feed-panel">
<div className="feed-top">
<div className="feed-top__bar">
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<span className="feed-toolbar__spacer" />
<Skeleton className="skeleton--count" />
</div>
</div>
</div>
<div className="post-list-scroll">
<PostListSkeleton listStyle={limits.feed_list_style ?? 'title'} />
</div>
</div>
</div>
);
}

View File

@@ -1,39 +1,56 @@
import { useRef } from 'react';
import { boardPath, type PermalinkOpts } from '../utils/permalink';
import { getCachedForumLimits } from '../hooks/useForumLimits';
import { Clock, MessageCircle, Flame } from 'lucide-react';
import { useMemo, useRef } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { boardPath, parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits';
import { Clock, MessageCircle, BadgeCheck, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { moveTabIndex } from '../hooks/useOverlayA11y';
import {
enabledFeedSortTabs,
getDefaultFeedSort,
normalizeFeedSortTabs,
} from '../utils/feedSortTabs';
import type { FeedSortTab } from '../api/types';
export type FeedSort = 'latest' | 'reply' | 'hot';
const SORT_OPTIONS: {
key: FeedSort;
label: string;
hint: string;
icon: typeof Clock;
}[] = [
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
];
const SORT_META: Record<FeedSort, { hint: string; icon: typeof Clock }> = {
reply: { hint: '最近有人评论', icon: MessageCircle },
latest: { hint: '按发帖时间', icon: Clock },
hot: { hint: '仅展示推荐帖', icon: BadgeCheck },
};
interface Props {
value: FeedSort;
onChange: (sort: FeedSort) => void;
postTotal?: number;
/** 正在加载、尚未提交到画面的目标排序 */
pendingValue?: FeedSort | null;
}
export function parseFeedSort(raw: string | null): FeedSort {
if (raw === 'reply' || raw === 'hot') return raw;
return 'latest';
/** 当前配置下的默认排序(读缓存,供非 hook 路径) */
export function getDefaultFeedSortFromCache(): FeedSort {
return getDefaultFeedSort(getCachedForumLimits().feed_sort_tabs);
}
/** 解析 URL sort;未启用或不合法时回落默认 */
export function parseFeedSort(raw: string | null, tabs?: FeedSortTab[] | null): FeedSort {
const list = tabs ?? getCachedForumLimits().feed_sort_tabs;
const def = getDefaultFeedSort(list);
if (raw === 'latest' || raw === 'hot' || raw === 'reply') {
const enabled = enabledFeedSortTabs(list).some(t => t.id === raw);
if (enabled) return raw;
}
return def;
}
export function buildHomeUrl(
boardId: number,
sort: FeedSort = 'latest',
sort?: FeedSort,
opts?: { keyword?: string; tag?: string; author?: string; titleOnly?: boolean; permalink?: PermalinkOpts },
) {
const def = getDefaultFeedSortFromCache();
const effective = sort ?? def;
const p = new URLSearchParams();
const tag = opts?.tag?.trim();
const keyword = opts?.keyword?.trim();
@@ -47,7 +64,8 @@ export function buildHomeUrl(
} else if (author) {
p.set('author', author);
}
if (sort !== 'latest') p.set('sort', sort);
// 默认排序不写进 URL
if (effective !== def) p.set('sort', effective);
const qs = p.toString();
if (boardId) {
@@ -57,25 +75,65 @@ export function buildHomeUrl(
return qs ? `/?${qs}` : '/';
}
export function feedSortLabel(sort: FeedSort): string {
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
export function feedSortLabel(sort: FeedSort, tabs?: FeedSortTab[] | null): string {
const list = normalizeFeedSortTabs(tabs ?? getCachedForumLimits().feed_sort_tabs);
return list.find(t => t.id === sort)?.label ?? '帖子列表';
}
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
export default function FeedSortBar({
value,
onChange,
postTotal,
pendingValue,
}: Props) {
const { limits } = useForumLimits();
const { id: boardRouteId } = useParams();
const [params] = useSearchParams();
// 与 SSR href / HomePage 一致:从路由与查询串解析板块与筛选
const boardId = boardRouteId
? (() => {
const id = parsePermalinkID(boardRouteId);
return Number.isFinite(id) && id > 0 ? id : 0;
})()
: (Number(params.get('board')) || 0);
const keyword = params.get('keyword') || '';
const tag = params.get('tag') || '';
const author = params.get('author') || '';
const titleOnly = params.get('title_only') === '1';
const options = useMemo(
() => enabledFeedSortTabs(limits.feed_sort_tabs).map(t => ({
key: t.id as FeedSort,
label: t.label,
hint: SORT_META[t.id]?.hint ?? '',
icon: SORT_META[t.id]?.icon ?? Clock,
href: buildHomeUrl(boardId, t.id as FeedSort, {
keyword,
tag,
author,
titleOnly,
permalink: limits,
}),
})),
[limits, boardId, keyword, tag, author, titleOnly],
);
const listRef = useRef<HTMLDivElement>(null);
const activeIndex = Math.max(0, SORT_OPTIONS.findIndex(o => o.key === value));
const activeIndex = Math.max(0, options.findIndex(o => o.key === value));
const onKeyDown = (e: React.KeyboardEvent) => {
const next = moveTabIndex(e.key, activeIndex, SORT_OPTIONS.length);
const next = moveTabIndex(e.key, activeIndex, options.length);
if (next == null) return;
e.preventDefault();
onChange(SORT_OPTIONS[next].key);
onChange(options[next].key);
requestAnimationFrame(() => {
const tabs = listRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[next]?.focus();
});
};
if (options.length === 0) return null;
return (
<div className="feed-toolbar">
<div
@@ -85,21 +143,30 @@ export default function FeedSortBar({ value, onChange, postTotal }: Props) {
aria-label="帖子排序"
onKeyDown={onKeyDown}
>
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }, i) => (
<button
key={key}
type="button"
role="tab"
tabIndex={activeIndex === i ? 0 : -1}
aria-selected={value === key}
title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active')}
onClick={() => onChange(key)}
>
<Icon aria-hidden />
<span className="feed-sort-tab__label">{label}</span>
</button>
))}
{options.map(({ key, label, hint, icon: Icon, href }, i) => {
const pending = pendingValue === key;
return (
<a
key={key}
href={href}
role="tab"
tabIndex={activeIndex === i ? 0 : -1}
aria-selected={value === key}
aria-busy={pending || undefined}
title={`${label} · ${hint}`}
className={cn('feed-sort-tab', value === key && 'active', pending && 'is-pending')}
onClick={(e) => {
e.preventDefault();
onChange(key);
}}
>
{pending
? <Loader2 className="feed-sort-tab__spin" aria-hidden />
: <Icon aria-hidden />}
<span className="feed-sort-tab__label">{label}</span>
</a>
);
})}
</div>
{postTotal != null && (
<span className="feed-toolbar__count">共 {postTotal} 条</span>

View File

@@ -6,7 +6,7 @@ type PageLoaderProps = {
fullScreen?: boolean;
};
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */
/** 通用路由懒加载占位(后台 / 全屏独立页);前台 MainLayout 用顶栏进度条 + 空白 */
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
return (
<div

View File

@@ -61,6 +61,14 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
openPost();
};
const hasTypeBadge = post.post_type === 'question'
|| post.post_type === 'poll'
|| post.post_type === 'lottery'
|| (post.post_type === 'bounty' && (
(post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0)
|| post.bounty_status === 'awarded'
));
const titleRow = (
<div className="post-title-row">
{post.pinned && (
@@ -70,9 +78,9 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
)}
{post.featured && (
<span className="post-feature-badge" title="精华">
<span className="post-feature-badge" title="推荐">
<FeaturedIcon size={12} />
精华
推荐
</span>
)}
{post.status === 'pending' && (
@@ -81,31 +89,36 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
{post.status === 'rejected' && (
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
)}
{post.post_type === 'question' && (
<span
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
title={post.question_resolved ? '已解决' : '未解决'}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
)}
{post.post_type === 'poll' && (
<span className="post-type-badge post-type-badge--poll" title="投票">投票</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && (
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏">悬赏 {post.bounty_points}</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' && (
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>
)}
{post.post_type === 'lottery' && (
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
)}
<a href={href} className="post-title" onClick={onTitleClick}>
{post.title}
</a>
{/* 类型徽章放标题后:flex 自动扣宽,长标题省略号紧挨徽章左侧 */}
{hasTypeBadge && (
<span className="post-title-type-badges">
{post.post_type === 'question' && (
<span
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
title={post.question_resolved ? '已解决' : '未解决'}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
)}
{post.post_type === 'poll' && (
<span className="post-type-badge post-type-badge--poll" title="投票">投票</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && (
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏">悬赏 {post.bounty_points}</span>
)}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' && (
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>
)}
{post.post_type === 'lottery' && (
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
)}
</span>
)}
</div>
);

View File

@@ -1,4 +1,3 @@
import { Skeleton } from '@/components/ui/skeleton';
import type { ForumLimitsPublic } from '../api/types';
export type FeedListStyle = ForumLimitsPublic['feed_list_style'];
@@ -11,69 +10,3 @@ export function feedListRowEstimate(style: FeedListStyle): number {
default: return 64;
}
}
interface Props {
count?: number;
listStyle?: FeedListStyle;
}
/** 帖子列表加载骨架屏(对齐 v2 紧凑列表) */
export default function PostListSkeleton({ count = 8, listStyle = 'title' }: Props) {
const showExcerpt = listStyle === 'excerpt' || listStyle === 'thumbnail';
const showThumb = listStyle === 'thumbnail';
const titleOnly = listStyle === 'title';
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
{Array.from({ length: count }, (_, i) => {
const hasThumb = showThumb && i % 3 === 0;
return (
<div
key={i}
className={`post-row post-row--v2 post-row--skeleton${titleOnly ? ' post-row--title-only' : ''}${hasThumb ? ' post-row--has-thumb' : ''}`}
>
<Skeleton className="skeleton--avatar skeleton--avatar-v2" />
{hasThumb ? (
<div className="post-main post-main--with-thumb">
<div className="post-content">
<Skeleton className="skeleton--title skeleton--title-v2" style={{ width: `${58 + (i % 4) * 9}%` }} />
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
<div className="post-meta-left post-meta-left--skeleton">
<Skeleton className="skeleton--board-badge" />
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
</div>
<div className="post-aside">
<Skeleton className="skeleton--thumb skeleton--thumb-tall" />
<div className="post-stats">
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
) : (
<div className="post-main">
<div className="post-text">
<Skeleton className="skeleton--title skeleton--title-v2" style={{ width: `${58 + (i % 4) * 9}%` }} />
{showExcerpt && (
<Skeleton className="skeleton--excerpt skeleton--excerpt-v2" style={{ width: `${72 + (i % 3) * 8}%` }} />
)}
</div>
<div className="post-meta">
<div className="post-meta-left post-meta-left--skeleton">
<Skeleton className="skeleton--board-badge" />
<Skeleton className="skeleton--meta-line" style={{ width: `${45 + (i % 3) * 10}%` }} />
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -147,7 +147,7 @@ export default function PostManageMenu({
<DropdownMenuLabel>展示</DropdownMenuLabel>
<DropdownMenuItem onSelect={onFeature}>
<Sparkles size={14} aria-hidden />
{post.featured ? '取消精华' : '设为精华'}
{post.featured ? '取消推荐' : '设为推荐'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onPin}>
<Pin size={14} aria-hidden />

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, ArrowDown } from 'lucide-react';
import { softRefreshCurrentPage } from '../utils/softRefresh';
/** 触发刷新的下拉距离(px) */
const REFRESH_THRESHOLD = 68;
@@ -30,6 +31,9 @@ function pickScrollEl(): HTMLElement | null {
const page = document.querySelector<HTMLElement>('.page-wrap:not(.page-wrap--feed)');
if (page) return page;
const showcase = document.querySelector<HTMLElement>('.showcase-page');
if (showcase) return showcase.closest<HTMLElement>('.main-content') ?? showcase;
const admin = document.querySelector<HTMLElement>('.admin-main');
if (admin) return admin;
@@ -76,7 +80,7 @@ function isNestedScrollBlocking(target: EventTarget | null, bound: HTMLElement):
}
/**
* 手机端下拉刷新:在内部滚动容器顶部下拉后整页重载。
* 手机端下拉刷新:在内部滚动容器顶部下拉后强制刷新当前页。
* (浏览器原生 PTR 依赖 document 滚动,与本站 app-shell 布局不兼容。)
*
* 挂在 Router 外,故用 MutationObserver 在路由切换后重绑滚动容器。
@@ -167,7 +171,22 @@ export default function PullToRefresh() {
setSettling(true);
setPull(REFRESH_THRESHOLD * 0.7);
window.setTimeout(() => {
window.location.reload();
// 后台 / 登录页:整页 reload;前台:静默软刷新(无进度条,齐套后一次覆盖)
const isAdminOrAuth = !!(
document.querySelector('.admin-main')
|| document.querySelector('.auth-page')
);
if (isAdminOrAuth) {
window.location.reload();
return;
}
void softRefreshCurrentPage()
.catch(() => undefined)
.finally(() => {
setRefreshing(false);
setSettling(true);
setPull(0);
});
}, 180);
return;
}

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
@@ -31,7 +29,7 @@ interface Props {
tagsLoading?: boolean;
stats?: ForumStats | null;
onPostClick: (id: number, opts?: { floor?: number }) => void;
/** 首次拉取中,显示骨架避免空态闪烁 */
/** 首次拉取中:不渲染该块内容(由冷启动门闩保证首屏已齐或空白) */
loading?: boolean;
/** 右侧栏可选组件顺序与开关 */
asideWidgets: AsideWidget[];
@@ -39,35 +37,6 @@ interface Props {
postDetail?: PostDetailAside | null;
}
function CommentSkeleton() {
return (
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
<Skeleton className="skeleton--widget-avatar" />
<div className="widget-item-comment-main">
<Skeleton className="skeleton--widget-title" style={{ width: `${72 + (i % 3) * 8}%` }} />
<Skeleton className="skeleton--widget-meta" />
</div>
</div>
))}
</div>
);
}
function UserSkeleton() {
return (
<div className="widget-recent-users-grid" aria-busy="true" aria-label="用户加载中">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="widget-recent-user-cell widget-recent-user-cell--skeleton">
<Skeleton className="skeleton--recent-user-avatar" />
<Skeleton className="skeleton--recent-user-name" style={{ width: `${48 + (i % 3) * 10}%` }} />
</div>
))}
</div>
);
}
export default function RightPanel({
recentComments,
recentUsers,
@@ -109,10 +78,6 @@ export default function RightPanel({
[asideWidgets],
);
const handleApplyClick = () => {
nav('/links?apply=1');
};
const renderWidget = (widget: AsideWidget) => {
switch (widget.id) {
case 'showcase':
@@ -127,15 +92,9 @@ export default function RightPanel({
友情链接
</button>
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="widget-friend-links-apply"
onClick={handleApplyClick}
>
<a href="/links?apply=1" className="widget-friend-links-apply">
申请
</Button>
</a>
</div>
<div className="widget-card-body widget-card-body--friend-links">
{friendLinks.length === 0 ? (
@@ -181,9 +140,7 @@ export default function RightPanel({
最新评论
</div>
<div className="widget-card-body">
{loading && commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
{loading && commentList.length === 0 ? null : commentList.length === 0 ? (
<div className="widget-empty">暂无评论</div>
) : commentList.map(item => (
<div
@@ -241,9 +198,7 @@ export default function RightPanel({
最新注册
</div>
<div className="widget-card-body widget-card-body--users">
{loading && userList.length === 0 ? (
<UserSkeleton />
) : userList.length === 0 ? (
{loading && userList.length === 0 ? null : userList.length === 0 ? (
<div className="widget-empty">暂无用户</div>
) : (
<div className="widget-recent-users-grid">

View File

@@ -3,21 +3,40 @@ import { Globe2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types';
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
const SHOWCASE_KEY = 'showcase';
/** 右侧栏:开源展柜精简列表 */
export default function ShowcaseAsideWidget() {
const nav = useNavigate();
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
const [loading, setLoading] = useState(true);
const cached = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
const [items, setItems] = useState<CommunityShowcaseItem[]>(() => cached ?? []);
const [loading, setLoading] = useState(() => cached === undefined);
useEffect(() => {
let cancelled = false;
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
if (hit !== undefined) {
setItems(hit);
setLoading(false);
return;
}
setLoading(true);
api.communityShowcase()
.then((r) => {
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
if (cancelled) return;
const next = Array.isArray(r.items) ? r.items : [];
setSessionSnapshot(SHOWCASE_KEY, next);
setItems(next);
})
.catch(() => {
if (!cancelled) setItems([]);
if (!cancelled) {
setSessionSnapshot(SHOWCASE_KEY, []);
setItems([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -25,6 +44,43 @@ export default function ShowcaseAsideWidget() {
return () => { cancelled = true; };
}, []);
useEffect(() => {
const fetchShowcase = (opts: { showLoading: boolean }) => {
if (opts.showLoading) setLoading(true);
api.communityShowcase()
.then((r) => {
const next = Array.isArray(r.items) ? r.items : [];
setSessionSnapshot(SHOWCASE_KEY, next);
setItems(next);
})
.catch(() => {
if (opts.showLoading) {
setSessionSnapshot(SHOWCASE_KEY, []);
setItems([]);
}
// 软刷新失败:保持旧 UI
})
.finally(() => setLoading(false));
};
const applyHitOrReload = (showLoading: boolean) => {
const hit = getSessionSnapshot<CommunityShowcaseItem[]>(SHOWCASE_KEY);
if (hit !== undefined) {
setItems(hit);
setLoading(false);
return;
}
fetchShowcase({ showLoading });
};
const onForce = () => applyHitOrReload(true);
const onCommit = () => applyHitOrReload(false);
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, []);
return (
<div className="widget-card widget-card--showcase">
<div className="widget-card-head widget-card-head--split">

View File

@@ -6,9 +6,9 @@ import type { Board } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useAuth } from '../hooks/useAuth';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { transitionTo } from '../utils/spaTransition';
import BoardIconDisplay from './BoardIconDisplay';
import { getBoardThemeIndex } from '../utils/boardTheme';
import ArticleOutline from './ArticleOutline';
@@ -62,11 +62,11 @@ export default function Sidebar({
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const sort = parseFeedSort(params.get('sort'));
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { navPages } = useSitePages();
const { limits } = useForumLimits();
const sort = parseFeedSort(params.get('sort'), limits.feed_sort_tabs);
const showFriendLinksNav = limits.nav_show_friend_links !== false;
const showShowcaseNav = !!limits.nav_show_showcase;
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
@@ -137,22 +137,12 @@ export default function Sidebar({
<div className="sidebar-section">浏览</div>
<nav className="sidebar-nav">
{feedNavLink('all', buildHomeUrl(0, sort, permalinkOpts), '全部帖子', <Home aria-hidden />, 0)}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => void transitionTo(nav, '/favorites'))}
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => void transitionTo(nav, '/projects'))}
</nav>
{(boardsLoading && boards.length === 0) ? (
<>
<div className="sidebar-section sidebar-section--boards">板块</div>
<nav className="sidebar-nav sidebar-nav--skeleton" aria-busy="true" aria-label="板块加载中">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="sidebar-nav-item sidebar-nav-item--skeleton">
<Skeleton className="skeleton--sidebar-icon" />
<Skeleton className="skeleton--sidebar-label" style={{ width: `${58 + (i % 3) * 12}%` }} />
</div>
))}
</nav>
</>
<div className="sidebar-section sidebar-section--boards">板块</div>
) : boards.length > 0 ? (
<>
<div className="sidebar-section sidebar-section--boards">板块</div>
@@ -198,10 +188,10 @@ export default function Sidebar({
<>
<div className="sidebar-section sidebar-section--spaced">站点</div>
<nav className="sidebar-nav">
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => nav('/showcase'))}
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => void transitionTo(nav, '/links'))}
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => void transitionTo(nav, '/showcase'))}
{navPages.map(p => (
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => void transitionTo(nav, pagePath(p.slug, limits)))
))}
</nav>
</>

View File

@@ -9,7 +9,7 @@ function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>;
}
/** 站点页脚:版权、友链/展柜入口、单页、备案号 */
/** 站点页脚:版权、友链/展柜入口、单页、备案号(结构与 Go writeSSRFooter 对齐) */
export default function SiteFooter() {
const { branding } = useSiteBranding();
const { footerPages } = useSitePages();
@@ -19,8 +19,42 @@ export default function SiteFooter() {
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
const showFriendLinks = limits.footer_show_friend_links !== false;
const showShowcase = !!limits.footer_show_showcase;
const hasNavBeforePages = showFriendLinks || showShowcase;
const hasNavBeforeIcp = hasNavBeforePages || footerPages.length > 0;
const navItems: React.ReactNode[] = [];
if (showFriendLinks) {
navItems.push(
<span key="links" className="site-footer__friend">
<Link to="/links">友情链接</Link>
</span>,
);
}
if (showShowcase) {
navItems.push(
<span key="showcase" className="site-footer__friend">
<Link to="/showcase">开源展柜</Link>
</span>,
);
}
for (const p of footerPages) {
navItems.push(
<span key={p.slug} className="site-footer__friend">
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>,
);
}
if (icp) {
navItems.push(
<a
key="icp"
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>,
);
}
return (
<footer className="site-footer">
@@ -38,35 +72,8 @@ export default function SiteFooter() {
</div>
<nav className="site-footer__nav" aria-label="站点链接">
{showFriendLinks && (
<span className="site-footer__friend">
<Link to="/links">友情链接</Link>
</span>
)}
{showShowcase && (
<span className="site-footer__friend">
{showFriendLinks && <FooterSep />}
<Link to="/showcase">开源展柜</Link>
</span>
)}
{footerPages.map((p, i) => (
<span key={p.slug} className="site-footer__friend">
{(hasNavBeforePages || i > 0) && <FooterSep />}
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>
))}
{icp && (
<>
{hasNavBeforeIcp && <FooterSep />}
<a
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>
</>
{navItems.flatMap((node, i) =>
i === 0 ? [node] : [<FooterSep key={`sep-${i}`} />, node],
)}
</nav>
</div>

View File

@@ -0,0 +1,227 @@
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { postPath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits';
import { formatTime } from '../utils/content';
import { MessageCircle } from 'lucide-react';
/** 与 Go formatSSRRelativeTime / 前端 formatTime 对齐的列表时间 */
function stripHtmlPlain(html: string): string {
if (!html) return '';
const t = html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
return t;
}
function truncateRunes(s: string, max: number): string {
const chars = Array.from(s);
if (chars.length <= max) return s;
return chars.slice(0, max).join('');
}
function firstImageSrc(html: string): string {
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i);
const src = m?.[1]?.trim() || '';
if (!src || src.startsWith('data:')) return '';
return src;
}
function displayName(post: PostItem): string {
const n = post.user?.nickname?.trim() || post.user?.username?.trim();
return n || '用户';
}
type Props = {
posts: PostItem[];
sort: FeedSort;
boardId: number;
};
/** 与 Go writeSSRPostRow 同构的静态列表(hydrate 首帧专用) */
export default function StaticFeedList({ posts, sort, boardId }: Props) {
const { limits } = useForumLimits();
const style = limits.feed_list_style ?? 'title';
const titleOnly = style === 'title';
const needExcerpt = style === 'excerpt' || style === 'thumbnail';
const needThumb = style === 'thumbnail';
return (
<div className="post-list-scroll post-list-scroll--ssr">
<div className="content-surface content-surface--ssr">
{posts.length === 0 ? (
<div className="feed-empty">暂无帖子</div>
) : (
posts.map((post) => {
const href = postPath(post.id, limits);
const author = displayName(post);
const initial = Array.from(author)[0] || '?';
let excerpt = '';
let thumb = '';
if (needExcerpt || needThumb) {
excerpt = truncateRunes(stripHtmlPlain(post.content || ''), 120);
}
if (needThumb) thumb = firstImageSrc(post.content || '');
const rowClass = [
'post-row',
'post-row--v2',
titleOnly ? 'post-row--title-only' : '',
thumb ? 'post-row--has-thumb' : '',
].filter(Boolean).join(' ');
let timeLabel = formatTime(post.created_at);
if (sort === 'reply' && !post.last_reply_at) {
timeLabel = '暂无回复';
}
const lastReplyName = post.last_reply_user?.nickname?.trim()
|| post.last_reply_user?.username?.trim()
|| post.last_reply_guest_nick?.trim()
|| '';
const showLastReply = !!post.last_reply_at && (!!post.last_reply_user || !!lastReplyName);
const commentCount = post.comment_count ?? 0;
const hasTypeBadge = post.post_type === 'question'
|| post.post_type === 'poll'
|| post.post_type === 'lottery'
|| (post.post_type === 'bounty' && (
(post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0)
|| post.bounty_status === 'awarded'
));
const titleRow = (
<div className="post-title-row">
{post.pinned ? (
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
) : null}
{post.board_pinned ? (
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
) : null}
{post.featured ? <span className="post-feature-badge" title="推荐">推荐</span> : null}
{post.status === 'pending' ? (
<span className="post-status-badge post-status-badge--pending" title="审核中">审核中</span>
) : null}
{post.status === 'rejected' ? (
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
) : null}
<span className="post-title">{post.title}</span>
{hasTypeBadge ? (
<span className="post-title-type-badges">
{post.post_type === 'question' ? (
<span
className={`post-qa-badge${post.question_resolved ? ' post-qa-badge--resolved' : ' post-qa-badge--open'}`}
title={post.question_resolved ? '已解决' : '未解决'}
>
{post.question_resolved ? '已解决' : '未解决'}
</span>
) : null}
{post.post_type === 'poll' ? (
<span className="post-type-badge post-type-badge--poll" title="投票">投票</span>
) : null}
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 ? (
<span className="post-bounty-badge post-bounty-badge--open" title="悬赏">
悬赏 {post.bounty_points}
</span>
) : null}
{post.post_type === 'bounty' && post.bounty_status === 'awarded' ? (
<span className="post-bounty-badge post-bounty-badge--awarded" title="已采纳">已采纳</span>
) : null}
{post.post_type === 'lottery' ? (
<span className="post-type-badge post-type-badge--lottery" title="抽奖">
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
</span>
) : null}
</span>
) : null}
</div>
);
const metaLeft = (
<div className="post-meta-left">
{boardId === 0 && post.board?.id ? (
<span className="post-list-board-btn">
<span
className={`post-list-board-badge board-badge board-badge--${getBoardThemeIndex(post.board)}`}
>
{post.board.name}
</span>
</span>
) : null}
<span className="post-meta-author">{author}</span>
<span className="post-meta-sep post-meta-sep--before-time" aria-hidden>·</span>
<span className="post-meta-time post-meta-time--created">{timeLabel}</span>
{showLastReply ? (
<span className="post-meta-last-reply">
<span className="post-meta-last-reply-arrow" aria-hidden>←</span>
{/* 外层是 <a class="post-row">,禁止嵌套 a */}
<span className="post-meta-last-reply-user">{lastReplyName}</span>
<span className="post-meta-last-reply-time">{formatTime(post.last_reply_at!)}</span>
</span>
) : null}
</div>
);
const stats = (
<div className="post-stats">
<span
className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}
title="评论"
>
<MessageCircle aria-hidden />
{commentCount}
</span>
</div>
);
return (
<a key={post.id} className={rowClass} href={href}>
{post.user?.avatar ? (
<span className="post-avatar user-link--avatar-only">
<img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
</span>
) : (
<span className="post-avatar user-link--avatar-only">{initial}</span>
)}
{thumb ? (
<div className="post-main post-main--with-thumb">
<div className="post-content">
{titleRow}
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
<div className="post-meta post-meta--inline">
{metaLeft}
{stats}
</div>
</div>
<div className="post-aside">
<div className="post-thumb" aria-hidden>
<img src={thumb} alt="" loading="lazy" decoding="async" />
</div>
</div>
</div>
) : (
<div className="post-main">
<div className="post-text">
{titleRow}
{excerpt ? <p className="post-excerpt">{excerpt}</p> : null}
</div>
<div className="post-meta">
{metaLeft}
{stats}
</div>
</div>
)}
</a>
);
})
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import type { TagCount } from '../api/types';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface Props {
@@ -63,20 +62,7 @@ export default function TagCloud({ tags, loading = false, activeTag = '' }: Prop
}, [tags]);
if (loading && tags.length === 0) {
return (
<div className="tag-cloud tag-cloud--skeleton" aria-busy="true" aria-label="标签加载中">
{Array.from({ length: 10 }, (_, i) => (
<Skeleton
key={i}
className="skeleton--tag-cloud"
style={{
width: `${42 + (i % 5) * 16}px`,
height: `${20 + (i % 3) * 4}px`,
}}
/>
))}
</div>
);
return null;
}
if (items.length === 0) {

View File

@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react';
import { subscribeSpaTransition } from '../utils/spaTransition';
/**
* 视口顶部 2px 细进度条(类 Next.js / YouTube)。
* 由 spaTransition start/done 驱动,假进度爬升至约 80%,结束冲到 100%。
*/
export default function TopProgressBar() {
const [visible, setVisible] = useState(false);
const [width, setWidth] = useState(0);
const [fading, setFading] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fadeRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeRef = useRef(false);
useEffect(() => {
const clearTick = () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
const clearFade = () => {
if (fadeRef.current) {
clearTimeout(fadeRef.current);
fadeRef.current = null;
}
};
return subscribeSpaTransition((active) => {
if (active) {
activeRef.current = true;
clearFade();
setFading(false);
setVisible(true);
setWidth(12);
clearTick();
timerRef.current = setInterval(() => {
setWidth((w) => {
if (w >= 80) return w;
// 越接近 80 越慢
const step = Math.max(0.4, (80 - w) * 0.045);
return Math.min(80, w + step);
});
}, 120);
return;
}
if (!activeRef.current) return;
activeRef.current = false;
clearTick();
setWidth(100);
setFading(true);
clearFade();
fadeRef.current = setTimeout(() => {
setVisible(false);
setFading(false);
setWidth(0);
}, 220);
});
}, []);
if (!visible && width <= 0) return null;
return (
<div
className={[
'top-progress',
fading ? 'top-progress--done' : '',
].filter(Boolean).join(' ')}
style={{ width: `${width}%` }}
aria-hidden
/>
);
}

View File

@@ -4,7 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox, SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton, { feedListRowEstimate } from './PostListSkeleton';
import { feedListRowEstimate } from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth';
@@ -14,6 +14,7 @@ import { loginPath } from '../utils/authRedirect';
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { getDefaultFeedSortFromCache } from './FeedSortBar';
interface Props {
posts: PostItem[];
@@ -56,7 +57,7 @@ function getMobileFeedScrollEl(): HTMLElement | null {
export default function VirtualPostList({
posts,
sort = 'latest',
sort = getDefaultFeedSortFromCache(),
loading,
hasMore,
showPagination,
@@ -161,6 +162,12 @@ export default function VirtualPostList({
onScrollTopChangeRef.current?.(0);
}, [resetScrollKey, virtualizer, getScrollElement]);
useLayoutEffect(() => {
// 收到新的恢复目标时允许再次 restore;null 表示已消费,勿动标记
if (restoreScrollTop == null) return;
restoredRef.current = false;
}, [restoreScrollTop]);
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
const el = getScrollElement();
@@ -173,10 +180,6 @@ export default function VirtualPostList({
onScrollRestoredRef.current?.();
}, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
useEffect(() => {
restoredRef.current = false;
}, [restoreScrollTop]);
useEffect(() => {
const el = getScrollElement();
if (!el) return;
@@ -240,9 +243,7 @@ export default function VirtualPostList({
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton listStyle={feedStyle} />
) : isEmpty ? (
{isInitialLoad ? null : isEmpty ? (
<div className="empty-feed" role="status">
{isSearchEmpty
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />

View File

@@ -0,0 +1,89 @@
import { Pencil } from 'lucide-react';
import { Input } from '@/components/ui/input';
import AdminSortableList, { SortableDragHandle } from './AdminSortableList';
import type { FeedSortId, FeedSortTab } from '../../api/types';
import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
const TAB_META: Record<FeedSortId, { hint: string; placeholder: string }> = {
reply: { hint: '按最后评论时间', placeholder: '新评论' },
latest: { hint: '按发帖时间', placeholder: '新帖子' },
hot: { hint: '仅展示推荐帖', placeholder: '推荐帖' },
};
type Props = {
tabs: FeedSortTab[];
onChange: (next: FeedSortTab[]) => void;
};
export default function FeedSortTabList({ tabs, onChange }: Props) {
const items = normalizeFeedSortTabs(tabs);
const handleToggle = (id: FeedSortId, enabled: boolean) => {
const next = items.map(t => (t.id === id ? { ...t, enabled } : t));
// 不允许全部关闭:关掉最后一项时忽略
if (!enabled && !next.some(t => t.enabled)) return;
onChange(normalizeFeedSortTabs(next));
};
const handleLabel = (id: FeedSortId, label: string) => {
onChange(normalizeFeedSortTabs(items.map(t => (t.id === id ? { ...t, label } : t))));
};
return (
<AdminSortableList
items={items}
getId={tab => tab.id}
onReorder={next => onChange(normalizeFeedSortTabs(next))}
showMoveButtons={false}
className="admin-sortable-list admin-sortable-list--boxed"
ariaLabel="首页排序标签"
renderItem={(tab, _index, controls) => {
const meta = TAB_META[tab.id];
return (
<div
ref={controls.setNodeRef}
style={controls.style}
className={`admin-sortable-row admin-sortable-row--widget${controls.isDragging ? ' is-dragging' : ''}`}
>
<SortableDragHandle
label={`拖拽调整「${tab.label || meta.placeholder}」顺序`}
{...controls.dragHandleProps}
/>
<div className="admin-sortable-row__main">
<label className="admin-sortable-row__field" htmlFor={`feed-sort-label-${tab.id}`}>
<span className="admin-sortable-row__field-name">显示名称</span>
<span className="admin-sortable-row__name-wrap">
<Input
id={`feed-sort-label-${tab.id}`}
type="text"
className="admin-sortable-row__name-input"
value={tab.label}
maxLength={16}
placeholder={meta.placeholder}
onChange={e => handleLabel(tab.id, e.target.value)}
aria-describedby={`feed-sort-hint-${tab.id}`}
/>
<Pencil className="admin-sortable-row__name-icon" size={14} aria-hidden />
</span>
</label>
<span className="admin-sortable-row__hint" id={`feed-sort-hint-${tab.id}`}>
{meta.hint}
</span>
</div>
<button
type="button"
id={`feed-sort-${tab.id}`}
role="switch"
aria-checked={tab.enabled}
aria-label={`启用「${tab.label || meta.placeholder}」`}
className={`admin-settings-switch${tab.enabled ? ' is-on' : ''}`}
onClick={() => handleToggle(tab.id, !tab.enabled)}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
);
}}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import china from '@svg-maps/china';
import { cn } from '@/lib/utils';
import { buildChinaRegionCountMap, heatFill, emptyFill } from './monitorMapUtils';
import { buildChinaRegionCountMap, heatFill } from './monitorMapUtils';
type Loc = { id: string; name: string; path: string };
@@ -15,10 +15,19 @@ type RegionStat = {
type Props = {
regions: RegionStat[];
className?: string;
/** 近 30 日是否已有国家级浏览量 */
hasCountryHits?: boolean;
/** 近 30 日是否已有省级浏览量 */
hasRegionHits?: boolean;
};
/** 中国省区地图:按 BIN 解析出的省/区填色 */
export default function MonitorChinaMap({ regions, className }: Props) {
export default function MonitorChinaMap({
regions,
className,
hasCountryHits = false,
hasRegionHits = false,
}: Props) {
const [hover, setHover] = useState<{ name: string; count: number } | null>(null);
const counts = useMemo(() => buildChinaRegionCountMap(regions), [regions]);
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
@@ -26,6 +35,16 @@ export default function MonitorChinaMap({ regions, className }: Props) {
const locations = (china as { locations: Loc[]; viewBox: string }).locations;
const viewBox = (china as { viewBox: string }).viewBox;
let emptyTitle = '暂无省级访问数据';
let emptyHint = '放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色';
if (hasCountryHits && !hasRegionHits) {
emptyTitle = '有国家浏览量,尚无省级数据';
emptyHint = '需前台路由产生带省字段的浏览量;仅请求日志不会点亮中国地图。请确认已放置 IP2Location DB3 BIN。';
} else if (!hasCountryHits) {
emptyTitle = '暂无浏览量地理数据';
emptyHint = '需前台路由产生浏览量后才会按省填色;仅请求日志不会点亮此处。';
}
return (
<div className={cn('admin-monitor-svg-wrap', className)}>
<svg
@@ -63,8 +82,8 @@ export default function MonitorChinaMap({ regions, className }: Props) {
)}
{!hasData && (
<div className="admin-monitor-map-empty-overlay">
<p>暂无省级访问数据</p>
<p>放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色</p>
<p>{emptyTitle}</p>
<p>{emptyHint}</p>
</div>
)}
</div>

View File

@@ -1,6 +1,9 @@
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react';
import { api } from '../api/client';
import type { User } from '../api/types';
import { clearAllFeedCache } from '../utils/feedCache';
import { clearSessionSnapshots } from '../utils/sessionPageCache';
import { peekAuthSeed } from '../utils/authBoot';
interface AuthCtx {
user: User | null;
@@ -15,8 +18,12 @@ const AuthContext = createContext<AuthCtx>({
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// peek 不清种子,StrictMode 重挂 / initializer 双调仍与 SSR 同构
const [user, setUser] = useState<User | null>(() => {
const s = peekAuthSeed();
return s.hasSeed ? s.user : null;
});
const [loading, setLoading] = useState(() => !peekAuthSeed().hasSeed);
const refresh = useCallback(async () => {
try {
@@ -29,8 +36,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
// 初始化只拉一次用户信息
useEffect(() => { refresh(); }, [refresh]);
// 有 SSR 种子时仍后台校验;无种子则首屏拉取
// 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动
useEffect(() => { void refresh(); }, [refresh]);
const prevUserId = useRef<number | null | 'init'>('init');
useEffect(() => {
if (loading) return;
const id = user?.id ?? null;
if (prevUserId.current === 'init') {
prevUserId.current = id;
return;
}
if (prevUserId.current !== id) {
prevUserId.current = id;
clearSessionSnapshots();
clearAllFeedCache();
}
}, [user, loading]);
const logout = async () => {
await api.logout();

View File

@@ -2,38 +2,115 @@ import { useCallback, useEffect, useState } from 'react';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { CheckInStatus } from '../api/types';
import { getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { useAuth } from './useAuth';
/** 签到会话快照键(与 prefetchRoute 共用) */
export function checkInCacheKey(userId: number) {
return `checkin:${userId}`;
}
/** 每日签到状态与操作(侧栏、积分页等复用) */
export function useCheckIn(enabled = true) {
const { user, refresh } = useAuth();
const [status, setStatus] = useState<CheckInStatus | null>(null);
const [loading, setLoading] = useState(false);
const userId = user?.id;
const [status, setStatus] = useState<CheckInStatus | null>(() => {
if (!userId || !enabled) return null;
return getSessionSnapshot<CheckInStatus>(checkInCacheKey(userId)) ?? null;
});
const [loading, setLoading] = useState(() => {
if (!userId || !enabled) return false;
return getSessionSnapshot<CheckInStatus>(checkInCacheKey(userId)) === undefined;
});
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
if (!user || !enabled) {
const load = useCallback((opts?: { force?: boolean }) => {
if (!userId || !enabled) {
setStatus(null);
setLoading(false);
return;
}
const key = checkInCacheKey(userId);
if (!opts?.force) {
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
}
setLoading(true);
api.checkInStatus()
.then(d => setStatus(d.check_in))
.catch(e => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
.then((d) => {
setSessionSnapshot(key, d.check_in);
setStatus(d.check_in);
})
.catch((e) => notify.error(e instanceof Error ? e.message : '加载签到状态失败'))
.finally(() => setLoading(false));
}, [user, enabled]);
}, [userId, enabled]);
useEffect(() => {
load();
}, [load]);
if (!userId || !enabled) {
setStatus(null);
setLoading(false);
return;
}
const key = checkInCacheKey(userId);
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
setStatus(null);
setLoading(true);
load({ force: true });
}, [userId, enabled, load]);
// 软刷新 commit:仅快照命中才盖;未命中保持旧 UI,后台静默再拉
useEffect(() => {
const applyHitOrSilent = (forceLoading: boolean) => {
if (!userId || !enabled) return;
const key = checkInCacheKey(userId);
const hit = getSessionSnapshot<CheckInStatus>(key);
if (hit !== undefined) {
setStatus(hit);
setLoading(false);
return;
}
if (forceLoading) {
load({ force: true });
return;
}
// 软刷新未命中:不 setLoading,静默覆盖
api.checkInStatus()
.then((d) => {
setSessionSnapshot(key, d.check_in);
setStatus(d.check_in);
setLoading(false);
})
.catch(() => { /* 保持旧 UI */ });
};
const onForce = () => applyHitOrSilent(true);
const onCommit = () => applyHitOrSilent(false);
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, [userId, enabled, load]);
const doCheckIn = useCallback(async () => {
if (!user) return;
if (!userId) return;
setBusy(true);
try {
const r = await api.checkIn();
notify.success(`签到成功,+${r.check_in.today_points} 积分`);
setSessionSnapshot(checkInCacheKey(userId), r.check_in);
setStatus(r.check_in);
await refresh();
} catch (e: unknown) {
@@ -41,13 +118,13 @@ export function useCheckIn(enabled = true) {
} finally {
setBusy(false);
}
}, [user, refresh]);
}, [userId, refresh]);
return {
status,
loading,
busy,
doCheckIn,
reload: load,
reload: () => load({ force: true }),
};
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { ForumLimitsPublic } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../api/types';
const DEFAULT_LIMITS: ForumLimitsPublic = {
post_title_max: 128,
@@ -27,6 +27,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title',
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
permalink_enabled: false,
permalink_ext: 'html',
monitor_pageview: false,
@@ -79,6 +80,27 @@ export function useForumLimits() {
return { limits, loading };
}
/** 冷启动 / 预热:确保 limits 已写入模块缓存;force 时重拉并在成功后通知 hook */
export async function ensureForumLimitsLoaded(opts?: { force?: boolean }): Promise<ForumLimitsPublic> {
if (opts?.force) {
cached = null;
inflight = null;
}
const limits = await fetchLimits();
if (opts?.force) {
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
return limits;
}
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
cached = limits;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
/** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateForumLimitsCache() {
cached = null;

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { buildHomeUrl } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { transitionTo } from '../utils/spaTransition';
import { notify } from '@/lib/notify';
import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
@@ -190,7 +191,7 @@ export function usePostSearch(
}
saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId });
nav(target);
void transitionTo(nav, target);
return true;
}, [nav, params, loc.pathname, limits, buildUrl]);

View File

@@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import {
getSessionSnapshot,
setSessionSnapshot,
deleteSessionSnapshot,
} from '../utils/sessionPageCache';
type Fetcher<T> = () => Promise<T>;
/**
* 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。
* 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。
*/
export function useSessionResource<T>(
key: string | null,
fetcher: Fetcher<T>,
opts?: {
enabled?: boolean;
onError?: (e: unknown) => void;
},
) {
const enabled = opts?.enabled !== false;
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const onErrorRef = useRef(opts?.onError);
onErrorRef.current = opts?.onError;
const [data, setData] = useState<T | undefined>(() =>
key && enabled ? getSessionSnapshot<T>(key) : undefined,
);
const [loading, setLoading] = useState(() => {
if (!key || !enabled) return false;
return getSessionSnapshot<T>(key) === undefined;
});
const [pending, setPending] = useState(false);
const seqRef = useRef(0);
const dataRef = useRef(data);
dataRef.current = data;
useEffect(() => {
if (!key || !enabled) {
if (!enabled) {
setLoading(false);
setPending(false);
}
return;
}
const hit = getSessionSnapshot<T>(key);
if (hit !== undefined) {
setData(hit);
setLoading(false);
setPending(false);
return;
}
const seq = ++seqRef.current;
const keep = dataRef.current !== undefined;
if (keep) setPending(true);
else setLoading(true);
fetcherRef.current()
.then((next) => {
if (seq !== seqRef.current) return;
setSessionSnapshot(key, next);
setData(next);
})
.catch((e: unknown) => {
if (seq !== seqRef.current) return;
onErrorRef.current?.(e);
if (!keep) setData(undefined);
})
.finally(() => {
if (seq !== seqRef.current) return;
setLoading(false);
setPending(false);
});
return () => {
seqRef.current += 1;
};
}, [key, enabled]);
const replace = useCallback((next: T | ((prev: T | undefined) => T)) => {
setData((prev) => {
const value = typeof next === 'function' ? (next as (p: T | undefined) => T)(prev) : next;
if (key) setSessionSnapshot(key, value);
return value;
});
}, [key]);
const invalidate = useCallback(() => {
if (key) deleteSessionSnapshot(key);
}, [key]);
useEffect(() => {
const reload = (opts: { allowLoading: boolean }) => {
if (!key || !enabled) return;
const warm = getSessionSnapshot<T>(key);
if (warm !== undefined) {
setData(warm);
setLoading(false);
setPending(false);
return;
}
const seq = ++seqRef.current;
const keep = dataRef.current !== undefined;
if (opts.allowLoading) {
deleteSessionSnapshot(key);
if (keep) setPending(true);
else setLoading(true);
}
fetcherRef.current()
.then((next) => {
if (seq !== seqRef.current) return;
setSessionSnapshot(key, next);
setData(next);
})
.catch((e: unknown) => {
if (seq !== seqRef.current) return;
onErrorRef.current?.(e);
if (opts.allowLoading && !keep) setData(undefined);
})
.finally(() => {
if (seq !== seqRef.current) return;
setLoading(false);
setPending(false);
});
};
const onForce = () => reload({ allowLoading: true });
// 软刷新 commit:未命中不卸 UI,后台静默再拉
const onCommit = () => reload({ allowLoading: false });
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, [key, enabled]);
return { data, loading, pending, replace, invalidate };
}

View File

@@ -46,9 +46,15 @@ function readBootBranding(): SiteBranding | null {
let cached: SiteBranding | null = readBootBranding();
let inflight: Promise<SiteBranding> | null = null;
let cacheEpoch = 0;
/** force 预热刚写入后,hook 因 epoch 重跑时复用缓存,避免连打两次 API */
let preferCacheOnce = false;
const listeners = new Set<() => void>();
function fetchBranding(): Promise<SiteBranding> {
if (preferCacheOnce && cached) {
preferCacheOnce = false;
return Promise.resolve(cached);
}
if (inflight) return inflight;
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
inflight = api.siteBranding()
@@ -123,6 +129,30 @@ export function useSiteBranding() {
return { branding, loading };
}
/** 保留当前画面,后台重拉品牌配置(下拉刷新等) */
export function refetchSiteBranding() {
inflight = null;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
/** 冷启动 / 预热:确保品牌已写入模块缓存;force 时重拉并在成功后通知 hook */
export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise<SiteBranding> {
if (!opts?.force && cached) return cached;
if (opts?.force) {
inflight = null;
preferCacheOnce = false;
}
const next = await fetchBranding();
applyDocumentBrand(next);
if (opts?.force) {
preferCacheOnce = true;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
return next;
}
/** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateSiteBrandingCache() {
cached = null;

View File

@@ -1,32 +1,60 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { SitePageSummary } from '../api/types';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
let cache: SitePageSummary[] | null = null;
let pending: Promise<SitePageSummary[]> | null = null;
/** 拉取或复用站点页摘要缓存(供冷启动门闩与 hook 共用) */
export function ensureSitePagesLoaded(opts?: { force?: boolean }): Promise<SitePageSummary[]> {
if (opts?.force) {
cache = null;
pending = null;
}
if (cache) return Promise.resolve(cache);
if (!pending) {
pending = api.pages()
.then(d => {
cache = d.pages ?? [];
return cache;
})
.catch(() => {
cache = [];
return cache;
})
.finally(() => { pending = null; });
}
return pending;
}
/** 已发布单页摘要(页脚/侧栏导航) */
export function useSitePages() {
const [pages, setPages] = useState<SitePageSummary[]>(cache ?? []);
useEffect(() => {
if (cache) {
setPages(cache);
return;
}
if (!pending) {
pending = api.pages()
.then(d => {
cache = d.pages ?? [];
return cache;
})
.catch(() => {
cache = [];
return cache;
})
.finally(() => { pending = null; });
}
pending.then(setPages);
const load = () => {
void ensureSitePagesLoaded().then(setPages);
};
if (cache) setPages(cache);
else load();
const onForce = () => {
cache = null;
pending = null;
load();
};
const onCommit = () => {
// 软刷新:cache 已由 prefetch 写好,同步进 state
void ensureSitePagesLoaded().then(setPages);
};
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, []);
return {
@@ -39,3 +67,9 @@ export function useSitePages() {
export function invalidateSitePagesCache() {
cache = null;
}
/** 文档 SSR:同步写入站点页摘要缓存 */
export function seedSitePagesCache(pages: SitePageSummary[]) {
cache = Array.isArray(pages) ? pages : [];
pending = null;
}

View File

@@ -1,6 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
import PageLoader from '../components/PageLoader';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { useState, useEffect, useCallback, useRef, useMemo, Suspense, startTransition } from 'react';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import {
@@ -23,7 +21,12 @@ import BackToTop from '../components/BackToTop';
import { useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { prefetchRoute, wasColdBootEnsured } from '../utils/prefetchRoute';
import {
transitionTo,
} from '../utils/spaTransition';
import PostSearchPanel from '../components/search/PostSearchPanel';
import {
POST_SEARCH_OPEN_EVENT,
@@ -33,30 +36,42 @@ import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { refetchSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
import { useMonitorPageview } from '../hooks/useMonitorPageview';
import SiteBrandMark from '../components/SiteBrandMark';
import SiteFooter from '../components/SiteFooter';
import { userPath } from '../utils/userPath';
import { parsePermalinkID } from '../utils/permalink';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
import { getBootUnread } from '../utils/authBoot';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme();
const { branding } = useSiteBranding();
useMonitorPageview();
const isMobile = useMediaQuery('(max-width: 768px)');
const mqMobile = useMediaQuery('(max-width: 768px)');
const hideAside = useMediaQuery('(max-width: 1100px)');
/** hydrate 首帧强制桌面布局(SSR 为桌面三栏),随后再跟 matchMedia */
const [forceDesktop, setForceDesktop] = useState(() => isHomeHydrating());
const isMobile = forceDesktop ? false : mqMobile;
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
useEffect(() => {
if (!forceDesktop) return;
endHomeHydrate();
startTransition(() => setForceDesktop(false));
}, [forceDesktop]);
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
const [recentUsers, setRecentUsers] = useState<RecentUser[]>(() => getCachedRecentUsers());
const [unreadMessages, setUnreadMessages] = useState(0);
const [unreadMessages, setUnreadMessages] = useState(() => getBootUnread());
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
const [postOutline, setPostOutline] = useState<{
@@ -73,15 +88,20 @@ export default function MainLayout() {
const searchInputRef = useRef<HTMLInputElement>(null);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
const asideEverLoaded = useRef(false);
/** 冷启动门闩:main.tsx 已预热则可同步放行;否则等齐再呈现 */
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured());
const coldBootDone = useRef(isCompose || wasColdBootEnsured());
const bootGen = useRef(0);
const [boardId, setBoardId] = useState(() => {
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
if (m) return parsePermalinkID(m[1]) || 0;
return Number(params.get('board')) || 0;
});
const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
const feedSort = parseFeedSort(params.get('sort'), forumLimits.feed_sort_tabs);
const postSearch = usePostSearch(forumLimits);
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
@@ -195,6 +215,94 @@ export default function MainLayout() {
return () => window.removeEventListener('boards-refresh', onRefresh);
}, [refreshBoards]);
// 冷启动兜底:main 未预热时静默等齐(不打进度条);站内已预热则保持打开
useEffect(() => {
if (isCompose) {
coldBootDone.current = true;
setShellReady(true);
return;
}
if (coldBootDone.current || wasColdBootEnsured()) {
coldBootDone.current = true;
setShellReady(true);
setBoardsLoading(false);
setAsideLoading(false);
setTagsLoading(false);
asideEverLoaded.current = true;
return;
}
const gen = ++bootGen.current;
let cancelled = false;
(async () => {
try {
const path = `${loc.pathname}${loc.search}`;
const tasks: Promise<unknown>[] = [
prefetchRoute(path, { force: false }),
refreshBoards(),
ensureSitePagesLoaded(),
];
if (!hideAside) {
if (showRecentComments) {
tasks.push(
api.recentComments().then((d) => {
const next = Array.isArray(d.comments) ? d.comments : [];
setRecentComments(next);
setCachedRecentComments(next);
}).catch(() => undefined),
);
}
if (showRecentUsers) {
tasks.push(
api.recentUsers().then((d) => {
const next = Array.isArray(d.users) ? d.users : [];
setRecentUsers(next);
setCachedRecentUsers(next);
}).catch(() => undefined),
);
}
if (showTagCloud) {
tasks.push(
api.tags(40).then((d) => {
const next = Array.isArray(d.tags) ? d.tags : [];
setTags(next);
setCachedTags(next);
}).catch(() => undefined),
);
}
}
await Promise.all(tasks);
} catch {
// 失败也放行
} finally {
if (!cancelled && bootGen.current === gen) {
asideEverLoaded.current = true;
setAsideLoading(false);
setTagsLoading(false);
setBoardsLoading(false);
coldBootDone.current = true;
setShellReady(true);
}
}
})();
return () => {
cancelled = true;
};
}, [
isCompose,
loc.pathname,
loc.search,
hideAside,
showRecentComments,
showRecentUsers,
showTagCloud,
refreshBoards,
]);
const refreshUnreadMessages = useCallback(() => {
if (!user) {
setUnreadMessages(0);
@@ -216,10 +324,49 @@ export default function MainLayout() {
};
}, [refreshUnreadMessages]);
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/精华等不改标签)
useEffect(() => {
const syncFromCache = () => {
// 仅当 cache 有内容时覆盖,避免空数组盖住已渲染的非空 UI
const nextBoards = getCachedBoards();
if (nextBoards.length > 0) setBoards(nextBoards);
setBoardsLoading(false);
const nextStats = getCachedStats();
if (nextStats) setStats(nextStats);
const nextComments = getCachedRecentComments();
if (nextComments.length > 0 || hasCachedAside()) setRecentComments(nextComments);
const nextUsers = getCachedRecentUsers();
if (nextUsers.length > 0 || hasCachedAside()) setRecentUsers(nextUsers);
const nextTags = getCachedTags();
if (nextTags.length > 0) setTags(nextTags);
setTagsLoading(false);
setAsideLoading(false);
asideEverLoaded.current = true;
refreshUnreadMessages();
};
const onForce = () => {
// 旧路径:仍可能被别处派发;尽量静默刷新壳层
refreshBoards();
refreshUnreadMessages();
refetchSiteBranding();
setLayoutRefreshTick(n => n + 1);
};
const onCommit = () => {
// 软刷新:预热已写入 cache,同一拍同步进 state,不触发分批 loading
syncFromCache();
};
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, [refreshBoards, refreshUnreadMessages]);
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
useEffect(() => {
if (isCompose || !showTagCloud) return;
let cancelled = false;
// 有 session 缓存则静默刷新(软刷新不卸空白)
if (getCachedTags().length === 0) setTagsLoading(true);
api.tags(40).then(d => {
if (cancelled) return;
@@ -232,7 +379,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
}, [isCompose, showTagCloud]);
}, [isCompose, showTagCloud, layoutRefreshTick]);
const needAsideData = !isCompose && (!hideAside || asideOpen);
const needRecentComments = needAsideData && showRecentComments;
@@ -259,7 +406,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
}, [needRecentComments]);
}, [needRecentComments, layoutRefreshTick]);
const needRecentUsers = needAsideData && showRecentUsers;
useEffect(() => {
@@ -284,7 +431,7 @@ export default function MainLayout() {
return () => {
cancelled = true;
};
}, [needRecentUsers]);
}, [needRecentUsers, layoutRefreshTick]);
const doQuickSearch = () => {
const { author, titleOnly, scopeBoardId } = postSearch.filters;
@@ -369,14 +516,14 @@ export default function MainLayout() {
};
return (
<div className="app-shell">
<div className={cn('app-shell', forceDesktop && 'ssr-home')}>
<div className="app-frame">
<header className="app-header">
<div className="header-inner">
{isMobile && !isCompose && (
{!isCompose && (
<button
type="button"
className="header-icon-btn"
className="header-icon-btn header-menu-btn"
onClick={openSidebar}
aria-label="打开导航菜单"
aria-expanded={sidebarOpen}
@@ -386,9 +533,10 @@ export default function MainLayout() {
<Menu size={18} aria-hidden />
</button>
)}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
{/* 任意页点 Logo:回首页并强制刷新,不展示会话缓存 */}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/', { refresh: true })}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
<span className="header-logo-text">{branding.name}</span>
</button>
{!isCompose && isMobile && (
@@ -469,11 +617,11 @@ export default function MainLayout() {
<button
type="button"
className="header-compose-btn"
onClick={() => user ? nav('/compose') : nav(loginPath('/compose'))}
onClick={() => user ? void transitionTo(nav, '/compose') : nav(loginPath('/compose'))}
aria-label="发帖"
>
<Plus size={16} aria-hidden />
{!isMobile && <span>发帖</span>}
<span className="header-compose-btn__label">发帖</span>
</button>
)}
@@ -493,17 +641,15 @@ export default function MainLayout() {
</button>
)}
{!isMobile && (
<button
<button
type="button"
className="header-icon-btn"
className="header-icon-btn header-theme-btn"
onClick={toggle}
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
</button>
)}
{authLoading ? (
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
@@ -514,7 +660,7 @@ export default function MainLayout() {
className="header-icon-btn header-msg-btn"
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
onClick={() => nav('/messages')}
onClick={() => void transitionTo(nav, '/messages')}
>
<Mail size={18} aria-hidden />
{unreadMessages > 0 && (
@@ -534,14 +680,14 @@ export default function MainLayout() {
className="w-40"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onClick={() => nav(userPath(user.id))}>个人主页</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/profile')}>
<DropdownMenuItem onClick={() => void transitionTo(nav, userPath(user.id))}>个人主页</DropdownMenuItem>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/profile')}>
账号设置{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/messages')}>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/messages')}>
站内消息{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}>我的收藏</DropdownMenuItem>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/favorites')}>我的收藏</DropdownMenuItem>
{isMobile && (
<DropdownMenuItem onClick={toggle}>
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
@@ -587,12 +733,13 @@ export default function MainLayout() {
)}
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
{!isCompose && (
{!isCompose && shellReady && (
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
boardsLoading={boardsLoading}
/>
)}
@@ -601,13 +748,16 @@ export default function MainLayout() {
isCompose && 'content-workspace--compose',
hideAside && !isCompose && 'content-workspace--aside-hidden',
)}>
<main className={cn(
'main-content',
isCompose && 'main-content--compose',
// 手机 Feed:整栏滚动,板块条 / 排序栏可滚出视口,多露出帖子列表
isMobile && !isCompose && isFeedHome && 'main-content--feed-mobile-scroll',
)}>
{isMobile && !isCompose && isFeedHome && (
<main
className={cn(
'main-content',
isCompose && 'main-content--compose',
// 手机 Feed:整栏滚动,板块条 / 排序栏可滚出视口,多露出帖子列表
isMobile && !isCompose && isFeedHome && 'main-content--feed-mobile-scroll',
)}
aria-busy={!shellReady}
>
{shellReady && isMobile && !isCompose && isFeedHome && (
<div
ref={boardBarRef}
className="mobile-board-bar"
@@ -645,13 +795,16 @@ export default function MainLayout() {
})}
</div>
)}
<Suspense fallback={isFeedHome ? <FeedPageSkeleton /> : <PageLoader />}>
<Outlet context={layoutCtx} />
</Suspense>
{shellReady ? (
<Suspense fallback={null}>
<Outlet context={layoutCtx} />
</Suspense>
) : null}
</main>
{!isCompose && (
<aside className="aside-panel">
<aside className="aside-panel" aria-busy={!shellReady}>
{shellReady && (
<RightPanel
recentComments={recentComments}
recentUsers={recentUsers}
@@ -661,6 +814,7 @@ export default function MainLayout() {
loading={asideLoading}
asideWidgets={asideWidgets}
onPostClick={openPost}
postDetail={isPostDetail ? {
author: postOutline?.author ?? null,
publishedAt: postOutline?.publishedAt,
@@ -670,6 +824,7 @@ export default function MainLayout() {
outlineTitle: postOutline?.title,
} : null}
/>
)}
</aside>
)}
</div>
@@ -709,12 +864,14 @@ export default function MainLayout() {
</button>
</div>
<div className="aside-drawer-body sidebar-drawer-body">
{shellReady && (
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
boardsLoading={boardsLoading}
/>
)}
<div className="sidebar-drawer-extras">
<button
type="button"

View File

@@ -1,12 +1,96 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { hydrateRoot, createRoot } from 'react-dom/client';
import { applyTheme, getStoredTheme } from './utils/theme';
import App from './App';
import { consumeHomeBoot } from './utils/homeBoot';
import { beginHomeHydrate } from './utils/homeHydrate';
import { reloadForStaleChunk } from './utils/chunkLoad';
import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
applyTheme(getStoredTheme());
consumeHomeBoot();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404)
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault();
reloadForStaleChunk();
});
function hasSSRHome(): boolean {
return !!document.querySelector('#root .ssr-home');
}
function waitForStylesheets(): Promise<void> {
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
if (links.length === 0) return Promise.resolve();
return Promise.all(
links.map(
(node) =>
new Promise<void>((resolve) => {
const link = node as HTMLLinkElement;
try {
if (link.sheet) {
resolve();
return;
}
} catch {
/* cross-origin */
}
const finish = () => resolve();
link.addEventListener('load', finish, { once: true });
link.addEventListener('error', finish, { once: true });
}),
),
).then(() => undefined);
}
function renderApp() {
return (
<React.StrictMode>
<App />
</React.StrictMode>
);
}
function mountCreateRoot(el: HTMLElement) {
createRoot(el).render(renderApp());
}
/** 有 SSR 正文:hydrate 接管同一 DOM;失败则回退 createRoot */
function mountHydrateHome(el: HTMLElement) {
beginHomeHydrate();
try {
hydrateRoot(el, renderApp());
} catch (err) {
console.warn('[j13] hydrateRoot 失败,回退 createRoot', err);
el.innerHTML = '';
mountCreateRoot(el);
}
}
async function boot() {
const path = `${window.location.pathname}${window.location.search}`;
const ssr = hasSSRHome();
await waitForStylesheets();
try {
if (document.fonts?.ready) await document.fonts.ready;
} catch {
/* ignore */
}
if (isMainLayoutPath(window.location.pathname)) {
await ensureColdBootReady(path);
}
const root = document.getElementById('root');
if (!root) return;
if (ssr) {
mountHydrateHome(root);
} else {
mountCreateRoot(root);
}
}
void boot();

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Star } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -7,6 +7,7 @@ import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { useSessionResource } from '../hooks/useSessionResource';
import PostListItem from '../components/PostListItem';
import { loginPath } from '../utils/authRedirect';
import { useForumLimits } from '../hooks/useForumLimits';
@@ -26,16 +27,19 @@ export default function FavoritesPage() {
const { user, loading: authLoading } = useAuth();
const { limits } = useForumLimits();
useNoIndexSEO('我的收藏');
const [list, setList] = useState<FavItem[]>([]);
const [loading, setLoading] = useState(true);
const { data: list = [], loading } = useSessionResource<FavItem[]>(
user ? 'favorites' : null,
() => api.favorites().then(d => (Array.isArray(d.favorites) ? d.favorites as FavItem[] : [])),
{
enabled: !!user && !authLoading,
onError: (e) => notify.error(e instanceof Error ? e.message : '加载失败'),
},
);
useEffect(() => {
if (authLoading) return;
if (!user) { nav(loginPath('/favorites')); return; }
api.favorites()
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
if (!user) nav(loginPath('/favorites'));
}, [user, authLoading, nav]);
if (authLoading || loading) return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;

View File

@@ -1,25 +1,35 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useOutletContext, useSearchParams, useLocation, useParams } from 'react-router-dom';
import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } from 'react';
import {
useNavigate,
useOutletContext,
useSearchParams,
useLocation,
useParams,
useNavigationType,
} from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import StaticFeedList from '../components/StaticFeedList';
import FeedHeader from '../components/FeedHeader';
import FeedSearchFilters from '../components/search/FeedSearchFilters';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
import {
getFeedCache,
setFeedCache,
clearAllFeedCache,
navigateFeed,
FEED_RESET_EVENT,
FEED_PULL_REFRESH_EVENT,
type FeedNavState,
} from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
import { openForumPost } from '../utils/openPost';
import { startTransition, doneTransition } from '../utils/spaTransition';
import { isHomeHydrating } from '../utils/homeHydrate';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
@@ -35,9 +45,41 @@ function boardIdFromLocation(routeId: string | undefined, searchParams: URLSearc
return q > 0 ? q : 0;
}
type FeedHydrate = {
posts: PostItem[];
postTotal: number;
page: number;
scrollTop: number;
loading: boolean;
};
/** 首屏同步读取 Zustand,避免先骨架屏再恢复 */
function readHydrate(
boardId: number,
keyword: string,
sort: FeedSort,
tag: string,
author: string,
titleOnly: boolean,
): FeedHydrate {
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
const cached = getHomeStoreState().getFeed(key);
if (cached && cached.posts.length > 0) {
return {
posts: cached.posts,
postTotal: cached.postTotal,
page: cached.page,
scrollTop: cached.scrollTop,
loading: false,
};
}
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
}
export default function HomePage() {
const nav = useNavigate();
const location = useLocation();
const navType = useNavigationType();
const { id: boardRouteId } = useParams();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
@@ -56,7 +98,8 @@ export default function HomePage() {
const tag = params.get('tag') || '';
const author = params.get('author') || '';
const titleOnly = params.get('title_only') === '1';
const sort = parseFeedSort(params.get('sort'));
const rawSort = params.get('sort');
const sort = parseFeedSort(rawSort, limits.feed_sort_tabs);
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
const isSiteHome = !boardId && !keyword && !tag && !author;
const siteIntro = siteMetaDescription(branding);
@@ -96,20 +139,117 @@ export default function HomePage() {
}
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
const [listResetKey, setListResetKey] = useState(0);
// URL 上的 sort 已被后台关闭时,纠正为默认排序
useEffect(() => {
if (limitsLoading) return;
if (rawSort !== 'latest' && rawSort !== 'hot' && rawSort !== 'reply') return;
const stillOn = enabledFeedSortTabs(limits.feed_sort_tabs).some(t => t.id === rawSort);
if (stillOn) return;
const def = getDefaultFeedSort(limits.feed_sort_tabs);
nav(buildHomeUrl(boardId, def, { keyword, tag, author, titleOnly, permalink: limits }), { replace: true });
}, [
limitsLoading,
limits.feed_sort_tabs,
limits,
rawSort,
boardId,
keyword,
tag,
author,
titleOnly,
nav,
]);
const scrollTopRef = useRef(0);
const skipCacheSaveRef = useRef(false);
const loadingRef = useRef(false);
const pageRef = useRef(1);
const cacheKey = useMemo(
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
[boardId, keyword, sort, tag, author, titleOnly],
);
// 筛选键变化时同步水合(含首次挂载),保证第一帧就有列表数据
const initial = useMemo(
() => readHydrate(boardId, keyword, sort, tag, author, titleOnly),
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随筛选键变化重置
[cacheKey],
);
const [posts, setPosts] = useState<PostItem[]>(initial.posts);
const [postTotal, setPostTotal] = useState(initial.postTotal);
const [page, setPage] = useState(initial.page);
const [loading, setLoading] = useState(initial.loading);
/** 画面上已提交的筛选(URL 已变但数据未到时仍画上一份) */
const [view, setView] = useState({
cacheKey,
sort,
boardId,
keyword,
tag,
author,
titleOnly,
});
const [listPending, setListPending] = useState(false);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
initial.posts.length > 0 ? initial.scrollTop : null,
);
const [listResetKey, setListResetKey] = useState(0);
/** hydrate 首帧用静态列表;完成后升级 VirtualPostList */
const [useVirtualList, setUseVirtualList] = useState(() => !isHomeHydrating());
useEffect(() => {
if (useVirtualList) return;
reactStartTransition(() => setUseVirtualList(true));
}, [useVirtualList]);
const scrollTopRef = useRef(initial.scrollTop);
const fetchSeqRef = useRef(0);
const pageRef = useRef(initial.page);
const cacheKeyRef = useRef(cacheKey);
const viewKeyRef = useRef(view.cacheKey);
const postsRef = useRef(posts);
const scrollRafRef = useRef(0);
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
const hydratedKeyRef = useRef<string | null>(null);
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
const refreshFetchKeyRef = useRef<string | null>(null);
pageRef.current = page;
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
const feedSnapRef = useRef({ boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page });
cacheKeyRef.current = cacheKey;
viewKeyRef.current = view.cacheKey;
postsRef.current = posts;
const commitDisplayed = useCallback((
next: FeedHydrate,
meta: { cacheKey: string; sort: FeedSort; boardId: number; keyword: string; tag: string; author: string; titleOnly: boolean },
) => {
setPosts(next.posts);
setPostTotal(next.postTotal);
setPage(next.page);
pageRef.current = next.page;
scrollTopRef.current = next.scrollTop;
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
setLoading(next.loading);
setListPending(false);
setView(meta);
}, []);
// 筛选键切换:有快照则立刻换页;否则保留当前画面等请求结束
useEffect(() => {
if ((location.state as FeedNavState | null)?.refreshFeed && navType !== 'POP') {
hydratedKeyRef.current = null;
return;
}
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
if (next.posts.length > 0) {
hydratedKeyRef.current = cacheKey;
commitDisplayed(next, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
return;
}
hydratedKeyRef.current = null;
if (postsRef.current.length > 0) {
setListPending(true);
setLoading(false);
return;
}
commitDisplayed(next, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
}, [cacheKey, boardId, keyword, sort, tag, author, titleOnly, commitDisplayed, location.state, navType]);
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
@@ -121,16 +261,39 @@ export default function HomePage() {
setListResetKey(k => k + 1);
}, []);
/** 同筛选强制刷新:只复位滚动,保留旧列表直到 loadFirst 覆盖 */
const beginFeedRefresh = useCallback(() => {
skipCacheSaveRef.current = true;
clearAllFeedCache();
resetFeedView();
}, [resetFeedView]);
const fetchPage = useCallback(async (p: number) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
/** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */
const persistFeed = useCallback((
nextPosts: PostItem[],
nextTotal: number,
nextPage: number,
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string },
) => {
const key = opts?.key ?? cacheKeyRef.current;
const prev = getHomeStoreState().getFeed(key);
getHomeStoreState().setFeed(key, {
posts: nextPosts,
postTotal: nextTotal,
page: nextPage,
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
lastFetchTime: opts?.touchFetchTime === false
? (prev?.lastFetchTime ?? Date.now())
: Date.now(),
});
}, []);
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean; resetScroll?: boolean }) => {
const seq = ++fetchSeqRef.current;
const requestKey = cacheKeyRef.current;
const silent = !!opts?.silent;
const keepView = postsRef.current.length > 0;
// 有旧列表时不卸页,只标 pending
if (!silent && !keepView) setLoading(true);
if (!silent && keepView) setListPending(true);
try {
const data = await api.posts({
page: p,
@@ -140,144 +303,229 @@ export default function HomePage() {
tag: tag || '',
author: tag ? '' : author,
title_only: !tag && titleOnly ? '1' : '',
sort: sort === 'latest' ? '' : sort,
sort,
});
if (seq !== fetchSeqRef.current) return;
const batch = Array.isArray(data.posts) ? data.posts : [];
const total = data.total ?? 0;
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
const scrollTop = jumpTop ? 0 : scrollTopRef.current;
if (jumpTop) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1);
}
setPosts(batch);
setPostTotal(total);
setPage(p);
pageRef.current = p;
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
if (seq !== fetchSeqRef.current) return;
if (!silent) {
notify.error(e instanceof Error ? e.message : '加载失败');
if (!keepView) {
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
}
}
} finally {
loadingRef.current = false;
setLoading(false);
if (seq === fetchSeqRef.current) {
setLoading(false);
setListPending(false);
}
}
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize]);
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
const loadFirst = useCallback((opts?: { resetScroll?: boolean }) => (
fetchPage(1, { resetScroll: opts?.resetScroll })
), [fetchPage]);
const goToPage = useCallback((p: number) => {
if (loadingRef.current) return;
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
if (p < 1 || p > maxPage) return;
if (p === pageRef.current) return;
resetFeedView();
fetchPage(p);
}, [fetchPage, postTotal, pageSize, resetFeedView]);
void fetchPage(p, { resetScroll: true });
}, [fetchPage, postTotal, pageSize]);
const handleSelectPost = useCallback((id: number) => {
// 离开前再写一次滚动,确保详情页返回可还原
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
openForumPost(nav, id, limits.open_posts_in_new_tab);
}, [nav, limits.open_posts_in_new_tab]);
// 等限制就绪后再拉列表;筛选变化时重载
// 等限制就绪后再决定:强制刷新 / 用缓存 / 静默刷新 / 首拉
useEffect(() => {
if (limitsLoading || isInvalidBoardRoute || isMissingBoard) return;
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
if (forceRefresh) {
beginFeedRefresh();
loadFirst();
// 浏览器后退/前进(POP)忽略 history 上残留的 refreshFeed,避免误清空缓存
if (forceRefresh && navType !== 'POP') {
fetchSeqRef.current += 1;
hydratedKeyRef.current = cacheKey;
refreshFetchKeyRef.current = cacheKey;
// 排序等强制刷新:丢弃该键旧分页/滚动,置顶重拉第 1 页
getHomeStoreState().clearFeed(cacheKey);
resetFeedView();
setListPending(postsRef.current.length > 0);
if (postsRef.current.length === 0) setLoading(true);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
void loadFirst({ resetScroll: true });
// 消费后清掉 state,防止该 history 条目永远带着刷新标记
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
return;
}
const cached = getFeedCache(boardId, keyword, sort, tag, author, titleOnly);
// POP 带回 refreshFeed 时也清掉,避免下次同条目再误触发
if (forceRefresh && navType === 'POP') {
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}
// 刚强制刷新并清缓存:跳过随后因 state 清空触发的空缓存首拉
if (refreshFetchKeyRef.current === cacheKey) {
refreshFetchKeyRef.current = null;
return;
}
const cached = getHomeStoreState().getFeed(cacheKey);
if (cached && cached.posts.length > 0) {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
pageRef.current = cached.page;
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
setLoading(false);
const needRestore = hydratedKeyRef.current !== cacheKey;
hydratedKeyRef.current = cacheKey;
if (needRestore) {
commitDisplayed({
posts: cached.posts,
postTotal: cached.postTotal,
page: cached.page,
scrollTop: cached.scrollTop,
loading: false,
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
} else {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
pageRef.current = cached.page;
setLoading(false);
setListPending(false);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
}
return;
}
setRestoreScrollTop(null);
scrollTopRef.current = 0;
hydratedKeyRef.current = cacheKey;
if (postsRef.current.length === 0) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
}
loadFirst();
}, [
limitsLoading,
pageSize,
cacheKey,
location.key,
location.state,
location.pathname,
location.search,
location.hash,
navType,
nav,
loadFirst,
commitDisplayed,
resetFeedView,
isInvalidBoardRoute,
isMissingBoard,
sort,
boardId,
keyword,
tag,
author,
titleOnly,
sort,
location.key,
location.state,
loadFirst,
beginFeedRefresh,
isInvalidBoardRoute,
isMissingBoard,
]);
// 筛选未变时同步列表快照;变筛选的那一帧先保留旧快照供 cleanup 写入
if (
feedSnapRef.current.boardId === boardId
&& feedSnapRef.current.keyword === keyword
&& feedSnapRef.current.tag === tag
&& feedSnapRef.current.author === author
&& feedSnapRef.current.titleOnly === titleOnly
&& feedSnapRef.current.sort === sort
) {
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page };
}
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps(否则会用旧列表污染新 keyword)
useEffect(() => {
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts: [], postTotal: 0, page: 1 };
return () => {
if (skipCacheSaveRef.current) return;
const snap = feedSnapRef.current;
if (snap.posts.length === 0) return;
setFeedCache(snap.boardId, snap.keyword, snap.sort, {
posts: snap.posts,
postTotal: snap.postTotal,
page: snap.page,
scrollTop: scrollTopRef.current,
}, snap.tag, snap.author, snap.titleOnly);
// Logo 等同 URL 强制刷新:仅复位滚动,不卸列表(数据由 transitionTo 预热)
const onFeedReset = () => {
fetchSeqRef.current += 1;
resetFeedView();
};
}, [boardId, keyword, tag, author, titleOnly, sort]);
useEffect(() => {
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
}, [loading, posts.length]);
useEffect(() => {
const onFeedReset = () => beginFeedRefresh();
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
}, [beginFeedRefresh]);
}, [resetFeedView]);
useEffect(() => {
const fn = () => {
beginFeedRefresh();
loadFirst();
// 下拉 / posts-refresh / 软刷新 commit:有预热则一次覆盖;禁止先卸列表
const applyWarmOrReload = () => {
fetchSeqRef.current += 1;
const key = cacheKeyRef.current;
const warm = getHomeStoreState().getFeed(key);
if (warm && warm.posts.length > 0) {
// 先写入新数据,再复位滚动(避免先 reset 造成空白闪一下)
commitDisplayed({
posts: warm.posts,
postTotal: warm.postTotal,
page: warm.page,
scrollTop: 0,
loading: false,
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
setListResetKey((k) => k + 1);
return;
}
// 未命中:保留旧 posts,静默重拉
if (postsRef.current.length > 0) {
setListPending(true);
setLoading(false);
void loadFirst();
return;
}
setLoading(true);
void loadFirst();
};
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
}, [beginFeedRefresh, loadFirst]);
window.addEventListener('posts-refresh', applyWarmOrReload);
window.addEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload);
return () => {
window.removeEventListener('posts-refresh', applyWarmOrReload);
window.removeEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload);
};
}, [
commitDisplayed, loadFirst,
sort, boardId, keyword, tag, author, titleOnly,
]);
// 卸载时取消未执行的 scroll rAF
useEffect(() => () => {
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
}, []);
const handleScrollTopChange = useCallback((top: number) => {
scrollTopRef.current = top;
// rAF 节流写入 store,避免每个 scroll 事件都触发订阅者
if (scrollRafRef.current) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = 0;
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
});
}, []);
const handleSortChange = (next: FeedSort) => {
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
// 排序标签:一律强制刷新到第 1 页顶部,不恢复浏览进度
if (next === sort) {
const tid = startTransition();
getHomeStoreState().clearFeed(cacheKeyRef.current);
beginFeedRefresh();
loadFirst();
void Promise.resolve(loadFirst({ resetScroll: true })).finally(() => doneTransition(tid));
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits }));
navigateFeed(nav, url, { refresh: true });
};
const showSortBar = !keyword && !tag && !author;
const showSortBar = !view.keyword && !view.tag && !view.author;
const searchFilters = parseSearchFromUrl(location.pathname, params);
const isSearchActive = !!(keyword || author);
const isSearchActive = !!(view.keyword || view.author);
if (isInvalidBoardRoute || isMissingBoard) {
return (
@@ -288,9 +536,9 @@ export default function HomePage() {
);
}
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
// 冷启动由门闩 / ensureColdBootReady 挡住;有旧列表时软刷新绝不卸空
if ((loading || limitsLoading || (isBoardRoute && boardsLoading)) && posts.length === 0) {
return <FeedPageSkeleton />;
return null;
}
return (
@@ -298,15 +546,32 @@ export default function HomePage() {
<div className="feed-panel">
<div className="feed-top">
<div className="feed-top__bar">
<FeedHeader
keyword={keyword}
tag={tag}
author={author}
postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'}
/>
{!useVirtualList ? (
(view.keyword || view.tag || view.author) ? (
<h1 className="feed-header-title">
{view.tag
? `#${view.tag}`
: view.keyword
? `搜索:${view.keyword}`
: `作者:${view.author}`}
</h1>
) : null
) : (
<FeedHeader
keyword={view.keyword}
tag={view.tag}
author={view.author}
postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'}
/>
)}
{showSortBar && (
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
<FeedSortBar
value={view.sort}
pendingValue={listPending ? sort : null}
onChange={handleSortChange}
postTotal={postTotal}
/>
)}
</div>
{isSearchActive && (
@@ -318,32 +583,36 @@ export default function HomePage() {
/>
)}
</div>
<VirtualPostList
posts={posts}
sort={sort}
loading={loading || limitsLoading}
hasMore={hasMore}
showPagination={showPagination}
page={page}
totalPages={totalPages}
postTotal={postTotal}
onPageChange={goToPage}
onSelect={handleSelectPost}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag || author}
isSearchMode={!!(keyword || author)}
searchKeyword={keyword}
searchAuthor={author}
searchTitleOnly={titleOnly}
searchScopeBoardId={searchFilters.scopeBoardId}
onClearSearch={postSearch.clearSearch}
boardId={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
/>
{!useVirtualList ? (
<StaticFeedList posts={posts} sort={view.sort} boardId={view.boardId} />
) : (
<VirtualPostList
posts={posts}
sort={view.sort}
loading={listPending ? false : (loading || limitsLoading)}
hasMore={hasMore}
showPagination={showPagination}
page={page}
totalPages={totalPages}
postTotal={postTotal}
onPageChange={goToPage}
onSelect={handleSelectPost}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={handleScrollTopChange}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={view.keyword || view.tag || view.author}
isSearchMode={!!(view.keyword || view.author)}
searchKeyword={view.keyword}
searchAuthor={view.author}
searchTitleOnly={view.titleOnly}
searchScopeBoardId={searchFilters.scopeBoardId}
onClearSearch={postSearch.clearSearch}
boardId={view.boardId}
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
/>
)}
</div>
</div>
);

View File

@@ -15,6 +15,7 @@ import { loginPath } from '../utils/authRedirect';
import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../utils/friendLink';
import { InFlowSiteFooter } from '../components/SiteFooter';
import FriendLinkApplyDialog from '../components/FriendLinkApplyDialog';
import { useSessionResource } from '../hooks/useSessionResource';
const APPLY_STATUS_ORDER: Record<FriendLinkApply['status'], number> = {
pending: 0,
@@ -48,8 +49,6 @@ export default function LinksPage() {
const { user } = useAuth();
const [applyOpen, setApplyOpen] = useState(false);
const [editApply, setEditApply] = useState<FriendLinkApply | null>(null);
const [myApplies, setMyApplies] = useState<FriendLinkApply[]>([]);
const [myLoading, setMyLoading] = useState(false);
const [cancelingId, setCancelingId] = useState<number | null>(null);
usePageSEO({
@@ -63,6 +62,15 @@ export default function LinksPage() {
(l: FriendLink) => l.name?.trim() && l.url?.trim(),
);
const { data: myApplies = [], loading: myLoading, replace: setMyApplies } = useSessionResource<FriendLinkApply[]>(
user ? 'links:applies' : null,
() => api.myFriendLinkApplies().then(r => r.applies ?? []),
{
enabled: !!user,
onError: (e) => notify.error(e instanceof Error ? e.message : '加载失败'),
},
);
const sortedApplies = useMemo(
() => [...myApplies].sort((a, b) => {
const byStatus = APPLY_STATUS_ORDER[a.status] - APPLY_STATUS_ORDER[b.status];
@@ -98,16 +106,10 @@ export default function LinksPage() {
setMyApplies([]);
return;
}
setMyLoading(true);
api.myFriendLinkApplies()
.then(r => setMyApplies(r.applies ?? []))
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
.finally(() => setMyLoading(false));
}, [user]);
useEffect(() => {
loadMyApplies();
}, [loadMyApplies]);
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'));
}, [user, setMyApplies]);
useEffect(() => {
if (!user || !myApplies.some(isReciprocalChecking)) return;

View File

@@ -14,8 +14,14 @@ import { postPath } from '../utils/permalink';
import { userPath } from '../utils/userPath';
import { InFlowSiteFooter } from '../components/SiteFooter';
import { cn } from '@/lib/utils';
import { getSessionSnapshot, setSessionSnapshot, deleteSessionSnapshot } from '../utils/sessionPageCache';
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
type MsgTab = 'dm' | 'notify';
type ConvSnap = { conversations: MessageConversation[]; total: number; page: number };
type NotifySnap = { notifications: PrivateMessage[]; total: number; page: number };
type ThreadSnap = { messages: PrivateMessage[]; total: number; peerUser: User | null };
const NOTIFY_KINDS = [
{ key: 'all', label: '全部' },
@@ -136,11 +142,28 @@ export default function MessagesPage() {
}, []);
const loadConversations = useCallback(async (page = 1, append = false) => {
const key = `messages:conv:${page}`;
if (!append) {
const hit = getSessionSnapshot<ConvSnap>(key);
if (hit) {
setConversations(hit.conversations);
setConvTotal(hit.total);
setConvPage(hit.page);
setListLoading(false);
return;
}
}
setListLoading(true);
try {
const r = await api.messageConversations({ page, size: 30 });
const next = r.conversations || [];
setConversations((prev) => (append ? [...prev, ...next] : next));
setConversations((prev) => {
const merged = append ? [...prev, ...next] : next;
if (!append) {
setSessionSnapshot(key, { conversations: merged, total: r.total || 0, page: r.page || page });
}
return merged;
});
setConvTotal(r.total || 0);
setConvPage(r.page || page);
} catch (e: unknown) {
@@ -151,6 +174,17 @@ export default function MessagesPage() {
}, []);
const loadNotifications = useCallback(async (page = 1, append = false, kind = 'all') => {
const key = `messages:notify:${kind}:${page}`;
if (!append) {
const hit = getSessionSnapshot<NotifySnap>(key);
if (hit) {
setNotifications(hit.notifications);
setNotifyTotal(hit.total);
setNotifyPage(hit.page);
setNotifyLoading(false);
return;
}
}
setNotifyLoading(true);
try {
const r = await api.messageNotifications({
@@ -164,7 +198,9 @@ export default function MessagesPage() {
// 打开通知页时标已读(首屏)
if (!append && page === 1) {
await api.markNotificationsRead().catch(() => undefined);
setNotifications(next.map((m) => ({ ...m, is_read: true })));
const marked = next.map((m) => ({ ...m, is_read: true }));
setNotifications(marked);
setSessionSnapshot(key, { notifications: marked, total: r.total || 0, page: r.page || page });
setNotifyUnread(0);
window.dispatchEvent(new Event('messages-unread-refresh'));
} else {
@@ -177,20 +213,47 @@ export default function MessagesPage() {
}
}, []);
const [threadEpoch, setThreadEpoch] = useState(0);
useEffect(() => {
if (authLoading) return;
if (!user) {
nav(loginPath('/messages'));
return;
}
void refreshUnreadSplit();
if (tab === 'dm') {
if (!getSessionSnapshot('messages:conv:1')) void refreshUnreadSplit();
loadConversations(1);
} else {
if (!getSessionSnapshot(`messages:notify:${notifyKind}:1`)) void refreshUnreadSplit();
loadNotifications(1, false, notifyKind);
}
}, [user, authLoading, nav, tab, notifyKind, loadConversations, loadNotifications, refreshUnreadSplit]);
useEffect(() => {
const onForce = () => {
// 下拉预热会话列表后直接重读;线程快照需作废以便重拉
void refreshUnreadSplit();
if (tab === 'dm') {
void loadConversations(1);
if (peerSelected && selectedPeer !== null) {
deleteSessionSnapshot(`messages:thread:${selectedPeer}`);
setThreadEpoch(n => n + 1);
}
} else {
// 通知列表:预热未覆盖 kind,作废后重拉
deleteSessionSnapshot(`messages:notify:${notifyKind}:1`);
void loadNotifications(1, false, notifyKind);
}
};
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce);
};
}, [tab, notifyKind, peerSelected, selectedPeer, loadConversations, loadNotifications, refreshUnreadSplit]);
const scrollToBottom = useCallback((smooth = false) => {
requestAnimationFrame(() => {
threadEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
@@ -204,15 +267,30 @@ export default function MessagesPage() {
setMsgTotal(0);
return;
}
const cached = getSessionSnapshot<ThreadSnap>(`messages:thread:${selectedPeer}`);
if (cached) {
setMessages(cached.messages);
setMsgTotal(cached.total);
setPeerUser(cached.peerUser);
setThreadLoading(false);
return;
}
let cancelled = false;
setThreadLoading(true);
stickToBottomRef.current = true;
api.conversationMessages(selectedPeer, { size: 50 })
.then((r) => {
if (cancelled) return;
setMessages(r.messages || []);
const messages = r.messages || [];
const peer = r.peer_user || null;
setMessages(messages);
setMsgTotal(r.total || 0);
setPeerUser(r.peer_user || null);
setPeerUser(peer);
setSessionSnapshot(`messages:thread:${selectedPeer}`, {
messages,
total: r.total || 0,
peerUser: peer,
});
setConversations((prev) => prev.map((c) => (
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
)));
@@ -226,7 +304,7 @@ export default function MessagesPage() {
if (!cancelled) setThreadLoading(false);
});
return () => { cancelled = true; };
}, [user, peerSelected, selectedPeer, refreshUnreadSplit]);
}, [user, peerSelected, selectedPeer, refreshUnreadSplit, threadEpoch]);
useEffect(() => {
if (!threadLoading && stickToBottomRef.current) {
@@ -321,7 +399,17 @@ export default function MessagesPage() {
try {
const r = await api.sendMessage({ to_user_id: selectedPeer, content });
stickToBottomRef.current = true;
setMessages((prev) => [...prev, r.message]);
setMessages((prev) => {
const next = [...prev, r.message];
if (selectedPeer != null) {
setSessionSnapshot(`messages:thread:${selectedPeer}`, {
messages: next,
total: msgTotal + 1,
peerUser,
});
}
return next;
});
setMsgTotal((n) => n + 1);
setDraft('');
setConversations((prev) => {
@@ -335,7 +423,9 @@ export default function MessagesPage() {
unread_count: 0,
updated_at: r.message.created_at,
};
return [next, ...rest];
const merged = [next, ...rest];
setSessionSnapshot('messages:conv:1', { conversations: merged, total: convTotal, page: 1 });
return merged;
});
scrollToBottom(true);
} catch (e: unknown) {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
import { useParams, useNavigate, useOutletContext, useLocation, useNavigationType } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react';
import FeaturedIcon from '@/components/FeaturedIcon';
import { Button } from '@/components/ui/button';
@@ -51,7 +51,9 @@ import { getCachedSiteBranding } from '../hooks/useSiteBranding';
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
import { collectCommentSubtreeIds } from '../utils/comment';
import { loadMyCommentIds } from '../utils/guest';
import { clearAllFeedCache } from '../utils/feedCache';
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
import { loginPath } from '../utils/authRedirect';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
@@ -62,6 +64,27 @@ import type { PostHeading } from '../utils/postHeadings';
import { InFlowSiteFooter } from '../components/SiteFooter';
import NotFoundPage from './NotFoundPage';
type PostDetailSnapshot = {
post: PostItem;
comments: Comment[];
poll: PollView | null;
lottery: PostLotteryView | null;
liked: boolean;
favorited: boolean;
canEdit: boolean;
isEdited: boolean;
editBlockReason: string;
editWindowHours: number;
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
scrollTop: number;
};
function postDetailCacheKey(id: number) {
return `post:${id}`;
}
/** 格式化剩余可编辑时间 */
function formatEditRemaining(createdAt: string, windowHours: number): string {
if (windowHours <= 0) return '';
@@ -80,26 +103,31 @@ export default function PostDetailPage() {
const postId = parsePermalinkID(id);
const nav = useNavigate();
const location = useLocation();
const navType = useNavigationType();
const { user, refresh } = useAuth();
const { limits } = useForumLimits();
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
const [post, setPost] = useState<PostItem | null>(null);
const [poll, setPoll] = useState<PollView | null>(null);
const [lottery, setLottery] = useState<PostLotteryView | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false);
const initialSnap = (postId && !Number.isNaN(postId))
? getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(postId))
: undefined;
const [post, setPost] = useState<PostItem | null>(initialSnap?.post ?? null);
const [poll, setPoll] = useState<PollView | null>(initialSnap?.poll ?? null);
const [lottery, setLottery] = useState<PostLotteryView | null>(initialSnap?.lottery ?? null);
const [comments, setComments] = useState<Comment[]>(initialSnap?.comments ?? []);
const [liked, setLiked] = useState(initialSnap?.liked ?? false);
const [favorited, setFavorited] = useState(initialSnap?.favorited ?? false);
const [replyTo, setReplyTo] = useState<Comment | null>(null);
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(!initialSnap);
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
const [submitCount, setSubmitCount] = useState(0);
const [canEdit, setCanEdit] = useState(false);
const [isEdited, setIsEdited] = useState(false);
const [editBlockReason, setEditBlockReason] = useState('');
const [editWindowHours, setEditWindowHours] = useState(0);
const [canEdit, setCanEdit] = useState(initialSnap?.canEdit ?? false);
const [isEdited, setIsEdited] = useState(initialSnap?.isEdited ?? false);
const [editBlockReason, setEditBlockReason] = useState(initialSnap?.editBlockReason ?? '');
const [editWindowHours, setEditWindowHours] = useState(initialSnap?.editWindowHours ?? 0);
const [showRevisions, setShowRevisions] = useState(false);
const [deletingPost, setDeletingPost] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -114,14 +142,25 @@ export default function PostDetailPage() {
const [rejecting, setRejecting] = useState(false);
const [bountyAwardTarget, setBountyAwardTarget] = useState<number | null>(null);
const [bountyAwarding, setBountyAwarding] = useState(false);
const [bountyCanRefund, setBountyCanRefund] = useState(true);
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState('');
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(0);
const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true);
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? '');
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0);
/** 仅浏览器后退/前进还原滚动;从列表再次点进帖子从顶部开始 */
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => {
if (!initialSnap) return null;
if (navType === 'POP' && !location.hash) return initialSnap.scrollTop;
return 0;
});
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
const commentBoxRef = useRef<HTMLDivElement>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
const postRef = useRef<PostItem | null>(null);
const scrollTopRef = useRef(
initialSnap && navType === 'POP' && !location.hash ? initialSnap.scrollTop : 0,
);
postRef.current = post;
useGlobalWheelScroll(pageRef, !loading && !!post);
@@ -180,19 +219,64 @@ export default function PostDetailPage() {
const loadSeq = useRef(0);
const detailPath = postPath(postId, limits);
useEffect(() => {
const applySnapshot = useCallback((snap: PostDetailSnapshot, opts?: { restoreScroll?: boolean }) => {
setPost(snap.post);
setPoll(snap.poll);
setLottery(snap.lottery);
setLiked(snap.liked);
setFavorited(snap.favorited);
setCanEdit(snap.canEdit);
setIsEdited(snap.isEdited);
setEditBlockReason(snap.editBlockReason);
setEditWindowHours(snap.editWindowHours);
setBountyCanRefund(snap.bountyCanRefund);
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
setComments(snap.comments);
if (opts?.restoreScroll) {
scrollTopRef.current = snap.scrollTop;
setRestoreScrollTop(snap.scrollTop);
} else {
scrollTopRef.current = 0;
// 同组件换帖时容器可能仍停在上一帖位置;初次进入已是 0 则不动,避免覆盖 #floor-N
const el = pageRef.current;
if (el && el.scrollTop !== 0) setRestoreScrollTop(0);
}
}, []);
const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {});
loadPostRef.current = (mode: 'auto' | 'force') => {
if (!postId || Number.isNaN(postId)) {
setPost(null);
setLoading(false);
return;
}
if (mode === 'force') {
deleteSessionSnapshot(postDetailCacheKey(postId));
}
const cached = mode === 'force'
? undefined
: getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(postId));
if (cached) {
loadSeq.current += 1;
applySnapshot(cached, { restoreScroll: navType === 'POP' && !location.hash });
setReplyTo(null);
setEditingCommentId(null);
setComposerOpen(false);
setHeadings([]);
setLoading(false);
return;
}
const seq = ++loadSeq.current;
const keep = !!postRef.current;
setReplyTo(null);
setEditingCommentId(null);
setComposerOpen(false);
setHeadings([]);
const seq = ++loadSeq.current;
setLoading(true);
setPost(null);
if (!keep) {
setLoading(true);
setPost(null);
}
(async () => {
try {
@@ -202,30 +286,118 @@ export default function PostDetailPage() {
api.comments(postId, myIds),
]);
if (seq !== loadSeq.current) return;
setPost(detail.post);
setPoll(detail.poll ?? null);
setLottery(detail.lottery ?? null);
setLiked(detail.liked);
setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setEditWindowHours(detail.post_edit_window_hours ?? 0);
setBountyCanRefund(detail.bounty_can_refund ?? true);
setBountyRefundBlockReason(detail.bounty_refund_block_reason ?? '');
setBountyEligibleReplyCount(detail.bounty_eligible_reply_count ?? 0);
setComments(Array.isArray(comm.comments) ? comm.comments : []);
const commentsList = Array.isArray(comm.comments) ? comm.comments : [];
const snap: PostDetailSnapshot = {
post: detail.post,
comments: commentsList,
poll: detail.poll ?? null,
lottery: detail.lottery ?? null,
liked: detail.liked,
favorited: detail.favorited,
canEdit: detail.can_edit ?? false,
isEdited: detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
editBlockReason: detail.edit_block_reason ?? '',
editWindowHours: detail.post_edit_window_hours ?? 0,
bountyCanRefund: detail.bounty_can_refund ?? true,
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
scrollTop: 0,
};
setSessionSnapshot(postDetailCacheKey(postId), snap);
applySnapshot(snap, { restoreScroll: false });
if (mode === 'force') {
const el = pageRef.current;
if (el) el.scrollTop = 0;
scrollTopRef.current = 0;
}
void refresh();
} catch {
if (seq !== loadSeq.current) return;
setPost(null);
if (!keep) setPost(null);
} finally {
if (seq === loadSeq.current) setLoading(false);
}
})();
};
useEffect(() => {
loadPostRef.current('auto');
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载
}, [postId]);
useEffect(() => {
// 下拉已预热则应用快照;否则强制重拉
const onForce = () => {
if (!postId || Number.isNaN(postId)) return;
const warm = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(postId));
if (warm) {
loadSeq.current += 1;
applySnapshot(warm, { restoreScroll: false });
setLoading(false);
const el = pageRef.current;
if (el) el.scrollTop = 0;
scrollTopRef.current = 0;
return;
}
loadPostRef.current('force');
};
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onForce);
};
}, [postId, applySnapshot]);
useEffect(() => {
const el = pageRef.current;
if (!el || loading || !post) return;
const onScroll = () => {
scrollTopRef.current = el.scrollTop;
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, [loading, post]);
useLayoutEffect(() => {
if (restoreScrollTop == null || !pageRef.current || loading || !post) return;
pageRef.current.scrollTop = restoreScrollTop;
scrollTopRef.current = restoreScrollTop;
setRestoreScrollTop(null);
}, [restoreScrollTop, loading, post]);
useEffect(() => {
if (!post || post.id !== postId || loading) return;
setSessionSnapshot(postDetailCacheKey(post.id), {
post,
comments,
poll,
lottery,
liked,
favorited,
canEdit,
isEdited,
editBlockReason,
editWindowHours,
bountyCanRefund,
bountyRefundBlockReason,
bountyEligibleReplyCount,
scrollTop: scrollTopRef.current,
});
}, [
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason,
bountyEligibleReplyCount, postId, loading,
]);
useEffect(() => () => {
const p = postRef.current;
if (!p) return;
const prev = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(p.id));
if (!prev) return;
setSessionSnapshot(postDetailCacheKey(p.id), { ...prev, scrollTop: scrollTopRef.current });
}, []);
const reloadComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
@@ -573,6 +745,7 @@ export default function PostDetailPage() {
setDeletingPost(true);
try {
await api.deletePost(postId);
deleteSessionSnapshot(postDetailCacheKey(postId));
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success('帖子已删除');
@@ -592,7 +765,7 @@ export default function PostDetailPage() {
onCancelReply: () => setReplyTo(null),
};
if (loading) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (loading && !post) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (!post) {
return (
<NotFoundPage

View File

@@ -43,6 +43,7 @@ import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
import { InFlowSiteFooter } from '../components/SiteFooter';
import { userPath } from '../utils/userPath';
import { useSessionResource } from '../hooks/useSessionResource';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
@@ -75,7 +76,7 @@ export default function ProfilePage() {
const nav = useNavigate();
const [params, setParams] = useSearchParams();
const tab = parseTab(params.get('tab'));
const { user, loading: authLoading, refresh } = useAuth();
const { user, loading: authLoading, refresh, logout } = useAuth();
useNoIndexSEO('个人中心');
const [nickLoading, setNickLoading] = useState(false);
const [sigLoading, setSigLoading] = useState(false);
@@ -88,12 +89,6 @@ export default function ProfilePage() {
const [cropFileName, setCropFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [idCopied, setIdCopied] = useState(false);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
const [postPage, setPostPage] = useState(1);
const [postTotal, setPostTotal] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -102,7 +97,6 @@ export default function ProfilePage() {
const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema),
@@ -181,37 +175,26 @@ export default function ProfilePage() {
if (copyTimer.current) clearTimeout(copyTimer.current);
}, []);
const loadStats = useCallback(() => {
setStatsLoading(true);
api.profileStats()
.then(d => setStats(d.stats))
.catch(() => setStats(null))
.finally(() => setStatsLoading(false));
}, []);
const { data: stats = null, loading: statsLoading } = useSessionResource<UserActivityStats | null>(
user ? `profile:stats:${user.id}` : null,
() => api.profileStats().then(d => d.stats ?? null),
{ enabled: !!user },
);
useEffect(() => {
if (!user) return;
loadStats();
}, [user, loadStats]);
useEffect(() => {
if (!user || tab !== 'posts') return;
let cancelled = false;
setPostsLoading(true);
api.posts({ user_id: user.id, page: postPage, size: pageSize, sort: 'latest' })
.then(d => {
if (cancelled) return;
setPosts(Array.isArray(d.posts) ? d.posts : []);
setPostTotal(d.total ?? 0);
})
.catch(e => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败');
})
.finally(() => {
if (!cancelled) setPostsLoading(false);
});
return () => { cancelled = true; };
}, [user, tab, postPage, pageSize]);
const [postPage, setPostPage] = useState(1);
const postsKey = user && tab === 'posts' ? `profile:posts:${user.id}:${postPage}:${pageSize}` : null;
const { data: postsSnap, loading: postsLoading } = useSessionResource<{ posts: PostItem[]; total: number }>(
postsKey,
() => api.posts({ user_id: user!.id, page: postPage, size: pageSize, sort: 'latest' })
.then(d => ({ posts: Array.isArray(d.posts) ? d.posts : [], total: d.total ?? 0 })),
{
enabled: !!postsKey,
onError: (e) => notify.error(e instanceof Error ? e.message : '加载帖子失败'),
},
);
const posts = postsSnap?.posts ?? [];
const postTotal = postsSnap?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
const closeCropDialog = useCallback((open: boolean) => {
if (!open) {
@@ -263,7 +246,7 @@ export default function ProfilePage() {
await api.updatePassword(values.old_password, values.new_password);
notify.success('密码已修改,请重新登录');
pwdForm.reset();
await api.logout();
await logout();
nav('/login');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '修改失败');

View File

@@ -10,14 +10,13 @@ import ProjectListItem from '../components/ProjectListItem';
import { InFlowSiteFooter } from '../components/SiteFooter';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
import { useSessionResource } from '../hooks/useSessionResource';
type ProjectsSnap = { list: GiteaProject[]; total: number; totalPages: number };
export default function ProjectsPage() {
const nav = useNavigate();
const [list, setList] = useState<GiteaProject[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState(true);
const [queryInput, setQueryInput] = useState('');
const [query, setQuery] = useState('');
@@ -40,24 +39,20 @@ export default function ProjectsPage() {
return () => window.clearTimeout(t);
}, [queryInput]);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.projects({ page, limit: 30, q: query || undefined })
.then(d => {
if (cancelled) return;
setList(Array.isArray(d.projects) ? d.projects : []);
setTotal(d.total ?? 0);
setTotalPages(d.total_pages ?? 0);
})
.catch(e => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [page, query]);
const { data, loading } = useSessionResource<ProjectsSnap>(
`projects:${page}:${query}`,
() => api.projects({ page, limit: 30, q: query || undefined }).then(d => ({
list: Array.isArray(d.projects) ? d.projects : [],
total: d.total ?? 0,
totalPages: d.total_pages ?? 0,
})),
{
onError: (e) => notify.error(e instanceof Error ? e.message : '加载失败'),
},
);
const list = data?.list ?? [];
const total = data?.total ?? 0;
const totalPages = data?.totalPages ?? 0;
return (
<div className="page-wrap">

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { ExternalLink, Globe2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { api } from '../api/client';
@@ -6,12 +5,15 @@ import type { CommunityShowcaseItem } from '../api/types';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
import { InFlowSiteFooter } from '../components/SiteFooter';
import { useSessionResource } from '../hooks/useSessionResource';
/** 官方精选的公网部署展柜(只读;仅人工精选条目) */
export default function ShowcasePage() {
const { branding } = useSiteBranding();
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
const [loading, setLoading] = useState(true);
const { data: items = [], loading } = useSessionResource<CommunityShowcaseItem[]>(
'showcase',
() => api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
);
usePageSEO({
title: '开源部署展柜',
@@ -20,23 +22,9 @@ export default function ShowcasePage() {
canonicalPath: '/showcase',
});
useEffect(() => {
let cancelled = false;
api.communityShowcase()
.then((r) => {
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
})
.catch(() => {
if (!cancelled) setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
return (
<div className="showcase-page">
<div className="page-wrap">
<div className="showcase-page">
<header className="showcase-head">
<div className="showcase-head-mark" aria-hidden>
<Globe2 size={22} />
@@ -82,6 +70,7 @@ export default function ShowcasePage() {
)}
<InFlowSiteFooter />
</div>
</div>
);
}

View File

@@ -1,14 +1,13 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import NotFoundPage from './NotFoundPage';
import { api } from '../api/client';
import type { SitePage } from '../api/types';
import PostContent from '../components/PostContent';
import PageLoader from '../components/PageLoader';
import { usePageSEO } from '../hooks/usePageSEO';
import { parsePermalinkSlug, pagePath } from '../utils/permalink';
import { useForumLimits } from '../hooks/useForumLimits';
import { useAuth } from '../hooks/useAuth';
import { useSessionResource } from '../hooks/useSessionResource';
/** 自定义单页(关于我们、版规等) */
export default function SitePageView() {
@@ -16,22 +15,12 @@ export default function SitePageView() {
const slug = parsePermalinkSlug(rawSlug);
const { limits } = useForumLimits();
const { user } = useAuth();
const [page, setPage] = useState<SitePage | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
if (!slug) {
setNotFound(true);
setLoading(false);
return;
}
setLoading(true);
api.page(slug)
.then(d => setPage(d.page))
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [slug]);
const { data: page, loading } = useSessionResource<SitePage | null>(
slug ? `sitepage:${slug}` : null,
() => api.page(slug).then(d => d.page),
{ enabled: !!slug },
);
const notFound = !slug || (!loading && !page);
usePageSEO({
title: page?.title,
@@ -41,7 +30,7 @@ export default function SitePageView() {
});
if (!slug) return <NotFoundPage title="页面不存在" />;
if (loading) return <PageLoader />;
if (loading) return null;
if (notFound || !page) return <NotFoundPage title="页面不存在" description="该页面不存在或未发布" />;
return (

View File

@@ -20,6 +20,7 @@ import { api } from '../api/client';
import type { PostItem, UserActivityStats, UserPublic } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { useForumLimits } from '../hooks/useForumLimits';
import { useSessionResource } from '../hooks/useSessionResource';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import ComposeMessageDialog from '../components/ComposeMessageDialog';
@@ -31,6 +32,9 @@ import { canonicalRedirectPath, parsePermalinkID, userPath } from '../utils/perm
import NotFoundPage from './NotFoundPage';
import { InFlowSiteFooter } from '../components/SiteFooter';
type ProfileSnap = { profile: UserPublic; stats: UserActivityStats | null };
type PostsSnap = { posts: PostItem[]; total: number };
export default function UserProfilePage() {
const { id: idParam } = useParams();
const userId = parsePermalinkID(idParam);
@@ -40,15 +44,34 @@ export default function UserProfilePage() {
const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const [profile, setProfile] = useState<UserPublic | null>(null);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [msgOpen, setMsgOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
const [postPage, setPostPage] = useState(1);
const [postTotal, setPostTotal] = useState(0);
const profileKey = userId && !Number.isNaN(userId) ? `user:${userId}` : null;
const { data: profileSnap, loading } = useSessionResource<ProfileSnap>(
profileKey,
() => api.userProfile(userId).then(d => ({ profile: d.user, stats: d.stats ?? null })),
{
enabled: !!profileKey,
onError: () => setNotFound(true),
},
);
const profile = profileSnap?.profile ?? null;
const stats = profileSnap?.stats ?? null;
const postsKey = profileKey && profile ? `${profileKey}:posts:${postPage}:${pageSize}` : null;
const { data: postsSnap, loading: postsLoading } = useSessionResource<PostsSnap>(
postsKey,
() => api.posts({ user_id: userId, page: postPage, size: pageSize, sort: 'latest' })
.then(d => ({ posts: Array.isArray(d.posts) ? d.posts : [], total: d.total ?? 0 })),
{
enabled: !!postsKey,
onError: (e) => notify.error(e instanceof Error ? e.message : '加载帖子失败'),
},
);
const posts = postsSnap?.posts ?? [];
const postTotal = postsSnap?.total ?? 0;
const isSelf = !!me && me.id === userId;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
@@ -56,43 +79,13 @@ export default function UserProfilePage() {
useEffect(() => {
if (!userId || Number.isNaN(userId)) {
setNotFound(true);
setLoading(false);
return;
}
setLoading(true);
setNotFound(false);
setPostPage(1);
api.userProfile(userId)
.then(d => {
setProfile(d.user);
setStats(d.stats);
})
.catch(() => {
setProfile(null);
setStats(null);
setNotFound(true);
})
.finally(() => setLoading(false));
}, [userId]);
useEffect(() => {
if (!userId || Number.isNaN(userId) || !profile) return;
let cancelled = false;
setPostsLoading(true);
api.posts({ user_id: userId, page: postPage, size: pageSize, sort: 'latest' })
.then(d => {
if (cancelled) return;
setPosts(Array.isArray(d.posts) ? d.posts : []);
setPostTotal(d.total ?? 0);
})
.catch(e => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败');
})
.finally(() => {
if (!cancelled) setPostsLoading(false);
});
return () => { cancelled = true; };
}, [userId, profile, postPage, pageSize]);
setNotFound(false);
setPostPage(1);
}, [userId]);
useEffect(() => {
if (!userId || Number.isNaN(userId)) return;

View File

@@ -245,7 +245,7 @@ export default function AdminDashboardPage() {
) : '—'}
</td>
<td className="space-x-1">
{p.featured ? <Badge variant="orange">精华</Badge> : null}
{p.featured ? <Badge variant="orange">推荐</Badge> : null}
{p.pinned ? <Badge variant="green">全局置顶</Badge> : null}
{p.board_pinned ? <Badge variant="green">板块置顶</Badge> : null}
{!p.featured && !p.pinned && !p.board_pinned ? '—' : null}

View File

@@ -22,6 +22,7 @@ import { cn } from '@/lib/utils';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import type { ForumLimits, FriendLink, FriendLinkApply, SiteBranding } from '../../api/types';
import { DEFAULT_FEED_SORT_TABS } from '../../api/types';
import { useSiteBranding, seedSiteBrandingCache, invalidateSiteBrandingCache } from '../../hooks/useSiteBranding';
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
import { formatTime } from '../../utils/content';
@@ -185,6 +186,7 @@ export default function AdminLinksPage() {
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title',
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
permalink_enabled: false,
permalink_ext: 'html',
...s.limits,
@@ -366,6 +368,7 @@ export default function AdminLinksPage() {
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title',
feed_sort_tabs: DEFAULT_FEED_SORT_TABS,
permalink_enabled: false,
permalink_ext: 'html',
post_edit_window_hours: 24,

View File

@@ -79,23 +79,29 @@ function GeoRankTable({
cities,
asns,
mode,
mapMode,
rankMode,
onRankMode,
hasCountryHits,
}: {
cities: MonitorCityItem[];
asns: MonitorASNItem[];
mode: MapMode;
rankMode: RankMode;
onRankMode: (m: RankMode) => void;
/** 近 30 日是否已有国家级浏览量(用于区分「无 PV 地理」与缺库) */
hasCountryHits: boolean;
}) {
const cityRows = mode === 'china'
? cities.filter((i) => CHINA_CODES.has((i.country || '').toUpperCase()))
: cities;
const rows = rankMode === 'city' ? cityRows : asns;
const emptyHint = rankMode === 'city'
? '放置 IP2Location DB3 BIN 后可见城市排行'
: '放置 GeoLite2-ASN.mmdb 后可见运营商排行';
? (hasCountryHits
? '需前台路由产生带城市的浏览量;仅请求日志不会进入此排行。请确认已放置 IP2Location DB3 BIN。'
: '需前台路由产生浏览量后才会有城市排行;仅请求日志不会点亮此处。')
: (hasCountryHits
? '需浏览量写入 ASN;请确认已放置 GeoLite2-ASN.mmdb。仅请求日志不会进入此排行。'
: '需前台路由产生浏览量后才会有运营商排行;仅请求日志不会点亮此处。');
return (
<div className="admin-monitor-rank">
@@ -117,7 +123,7 @@ function GeoRankTable({
</div>
{rows.length === 0 ? (
<div className="admin-monitor-geo-empty">
<p>暂无来源数据</p>
<p>暂无浏览量来源数据</p>
<p className="admin-monitor-muted">{emptyHint}</p>
</div>
) : (
@@ -282,18 +288,24 @@ export default function AdminMonitorPage() {
const metrics = useMemo(() => {
if (!overview) return [];
const pvHint = '前台浏览';
const accessHintShort = '服务端请求';
const accessHintFull = '服务端请求(含 API,重启后重计)';
return [
{ label: '浏览量', value: formatNum(overview.pageviews) },
{ label: '访客数', value: formatNum(overview.visitors) },
{ label: '独立 IP', value: formatNum(overview.unique_ips) },
{ label: '流量', value: formatBytes(overview.traffic) },
{ label: '蜘蛛', value: formatNum(overview.bots) },
{ label: '请求数', value: formatNum(overview.requests) },
{ label: '4xx', value: formatNum(overview.status_4xx) },
{ label: '5xx', value: formatNum(overview.status_5xx) },
{ label: '浏览量', hint: pvHint, title: pvHint, value: formatNum(overview.pageviews) },
{ label: '访客数', hint: pvHint, title: pvHint, value: formatNum(overview.visitors) },
{ label: '独立 IP', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.unique_ips) },
{ label: '流量', hint: accessHintShort, title: accessHintFull, value: formatBytes(overview.traffic) },
{ label: '蜘蛛', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.bots) },
{ label: '请求数', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.requests) },
{ label: '4xx', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.status_4xx) },
{ label: '5xx', hint: accessHintShort, title: accessHintFull, value: formatNum(overview.status_5xx) },
];
}, [overview]);
const hasCountryHits = (geo?.countries?.length || 0) > 0;
const hasRegionHits = (geo?.regions?.length || 0) > 0;
const saveSettings = async () => {
if (!settings) return;
setSaving(true);
@@ -352,7 +364,7 @@ export default function AdminMonitorPage() {
网站监控
</h1>
<p className="admin-page-desc">
浏览量/访客来自前台路由 pageview;请求数与日志来自服务端访问采集(含 /api)
浏览量/访客与访客地图来自前台路由 pageview;请求数、流量与请求日志来自服务端访问采集(含 /api,重启后请求类今日指标重计)
</p>
</div>
</div>
@@ -391,8 +403,11 @@ export default function AdminMonitorPage() {
</div>
<div className="admin-monitor-today-grid">
{metrics.map((m) => (
<div key={m.label} className="admin-monitor-today-item">
<div className="admin-monitor-today-label">{m.label}</div>
<div key={m.label} className="admin-monitor-today-item" title={m.title}>
<div className="admin-monitor-today-label">
{m.label}
<span className="admin-monitor-today-hint">{m.hint}</span>
</div>
<div className="admin-monitor-today-value">{m.value}</div>
</div>
))}
@@ -404,7 +419,13 @@ export default function AdminMonitorPage() {
<div className="admin-card-head admin-monitor-map-head">
<div className="admin-monitor-today-head" style={{ marginBottom: 0 }}>
<span className="admin-monitor-section-bar" aria-hidden />
<h2 className="admin-monitor-section-title">访客地图(30 日)</h2>
<div>
<h2 className="admin-monitor-section-title">访客地图</h2>
<p className="admin-monitor-map-sub">
基于前台浏览 · 近 30 日
{mapMode === 'china' ? ' · 按省/区填色' : ' · 按国家填色'}
</p>
</div>
</div>
<div className="admin-monitor-seg" role="group" aria-label="地图范围">
<button
@@ -428,7 +449,11 @@ export default function AdminMonitorPage() {
{mapMode === 'world' ? (
<MonitorWorldMap items={geoCountries(geo)} />
) : (
<MonitorChinaMap regions={geo?.regions || []} />
<MonitorChinaMap
regions={geo?.regions || []}
hasCountryHits={hasCountryHits}
hasRegionHits={hasRegionHits}
/>
)}
</div>
<div className="admin-monitor-map-side">
@@ -438,6 +463,7 @@ export default function AdminMonitorPage() {
mode={mapMode}
rankMode={rankMode}
onRankMode={setRankMode}
hasCountryHits={hasCountryHits}
/>
</div>
</div>
@@ -554,10 +580,10 @@ export default function AdminMonitorPage() {
</thead>
<tbody>
{logs.map((row) => {
const placeBits = [row.region, row.city, row.as_org].filter(Boolean);
const geoBits = [
row.country ? countryLabel(row.country) : '',
row.city || '',
row.as_org || '',
...placeBits,
].filter(Boolean);
return (
<tr key={row.id}>
@@ -568,9 +594,9 @@ export default function AdminMonitorPage() {
<td className="admin-table-mono">
{row.ip || '—'}
{row.is_bot ? ' · bot' : ''}
{(row.city || row.as_org) ? (
{placeBits.length > 0 ? (
<div className="admin-monitor-muted" style={{ fontSize: 12 }}>
{[row.city, row.as_org].filter(Boolean).join(' · ')}
{placeBits.join(' · ')}
</div>
) : null}
</td>

View File

@@ -218,7 +218,7 @@ export default function AdminPostsPage() {
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
: tab === 'pending'
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
: '精华、全局置顶、板块置顶、锁定编辑/讨论、删除(移入回收站);支持按标题、标签或正文搜索'}
: '推荐、全局置顶、板块置顶、锁定编辑/讨论、删除(移入回收站);支持按标题、标签或正文搜索'}
</p>
</div>
@@ -337,7 +337,7 @@ export default function AdminPostsPage() {
<th>作者</th>
<th>标签</th>
<th>评论</th>
<th>精华</th>
<th>推荐</th>
<th>全局置顶</th>
<th>板块置顶</th>
<th>编辑锁</th>
@@ -396,7 +396,7 @@ export default function AdminPostsPage() {
</Button>
)}
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
{p.featured ? '取消精华' : '精华'}
{p.featured ? '取消推荐' : '推荐'}
</Button>
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
{p.pinned ? '取消全局置顶' : '全局置顶'}

View File

@@ -11,9 +11,11 @@ import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
import { DEFAULT_BRANDING, seedSiteBrandingCache } from '../../hooks/useSiteBranding';
import { clearAllFeedCache } from '../../utils/feedCache';
import { normalizeAsideWidgets, resolveAsideWidgets, mergeForumLimitsWithAsideWidgets, resolveSavedAsideWidgets } from '../../utils/asideWidgets';
import { normalizeFeedSortTabs } from '../../utils/feedSortTabs';
import AsideWidgetList from '../../components/admin/AsideWidgetList';
import FeedSortTabList from '../../components/admin/FeedSortTabList';
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, StorageConfig, SiteBranding, AsideWidget } from '../../api/types';
import { DEFAULT_ASIDE_WIDGETS } from '../../api/types';
import { DEFAULT_ASIDE_WIDGETS, DEFAULT_FEED_SORT_TABS } from '../../api/types';
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'storage' | 'filter' | 'system';
@@ -305,6 +307,7 @@ export default function AdminSettingsPage() {
nav_show_showcase: false,
footer_show_showcase: false,
feed_list_style: 'title',
feed_sort_tabs: normalizeFeedSortTabs(s.limits?.feed_sort_tabs ?? DEFAULT_FEED_SORT_TABS),
permalink_enabled: false,
permalink_ext: 'html',
...s.limits,
@@ -916,7 +919,7 @@ export default function AdminSettingsPage() {
<section className="admin-settings-section" id="settings-feed-list">
<div className="admin-settings-section-head">
<h3>列表呈现</h3>
<p>首页及帖子列表的信息密度与缩略图展示</p>
<p>首页及帖子列表的信息密度、缩略图与排序标签</p>
</div>
<div className="admin-settings-table" role="group" aria-label="列表呈现">
<div className="admin-settings-row">
@@ -946,6 +949,16 @@ export default function AdminSettingsPage() {
</span>
</div>
</div>
<div className="admin-settings-subsection">
<h4 className="admin-settings-subsection-title">首页排序标签</h4>
<p className="admin-settings-subsection-desc">
拖拽调整顺序;在「显示名称」框中改首页文案;右侧开关控制启停。第一个启用项为默认排序
</p>
<FeedSortTabList
tabs={normalizeFeedSortTabs(limits.feed_sort_tabs ?? DEFAULT_FEED_SORT_TABS)}
onChange={tabs => setLimits(prev => prev ? { ...prev, feed_sort_tabs: tabs } : prev)}
/>
</div>
</section>
<section className="admin-settings-section" id="settings-permalink">

View File

@@ -0,0 +1,106 @@
import { create } from 'zustand';
import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar';
/** 列表缓存过期时间:超过后返回首页时静默刷新,仍先展示旧数据 */
export const FEED_STALE_MS = 5 * 60 * 1000;
/** 单条 Feed 筛选对应的缓存快照 */
export type FeedCacheEntry = {
posts: PostItem[];
postTotal: number;
page: number;
scrollTop: number;
/** 上次成功拉取 API 的时间戳(ms) */
lastFetchTime: number;
};
type FeedKeyParts = {
boardId: number;
keyword: string;
sort: FeedSort;
tag?: string;
author?: string;
titleOnly?: boolean;
};
/** 与筛选条件一一对应的缓存键 */
export function feedCacheKey(parts: FeedKeyParts): string {
const {
boardId,
keyword,
sort,
tag = '',
author = '',
titleOnly = false,
} = parts;
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
}
type HomeStoreState = {
/** 按筛选键存放多份列表,首页 / 板块 / 搜索互不覆盖 */
feeds: Record<string, FeedCacheEntry>;
getFeed: (key: string) => FeedCacheEntry | null;
setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void;
/** 滚动时只更新 scrollTop,不改动列表数据 */
patchScroll: (key: string, scrollTop: number) => void;
isStale: (key: string, now?: number) => boolean;
clearFeed: (key: string) => void;
clearAll: () => void;
};
export const useHomeStore = create<HomeStoreState>((set, get) => ({
feeds: {},
getFeed: (key) => get().feeds[key] ?? null,
setFeed: (key, data) => {
set((state) => ({
feeds: {
...state.feeds,
[key]: {
posts: data.posts,
postTotal: data.postTotal,
page: data.page,
scrollTop: data.scrollTop,
lastFetchTime: data.lastFetchTime ?? Date.now(),
},
},
}));
},
patchScroll: (key, scrollTop) => {
const prev = get().feeds[key];
if (!prev) return;
// 数值未变则跳过,减少无意义的 store 更新
if (prev.scrollTop === scrollTop) return;
set((state) => ({
feeds: {
...state.feeds,
[key]: { ...prev, scrollTop },
},
}));
},
isStale: (key, now = Date.now()) => {
const entry = get().feeds[key];
if (!entry) return true;
return now - entry.lastFetchTime > FEED_STALE_MS;
},
clearFeed: (key) => {
set((state) => {
if (!(key in state.feeds)) return state;
const next = { ...state.feeds };
delete next[key];
return { feeds: next };
});
},
clearAll: () => set({ feeds: {} }),
}));
/** 非 React 路径(如 feedCache 封装)直接读写 */
export function getHomeStoreState() {
return useHomeStore.getState();
}

View File

@@ -2,6 +2,22 @@
@tailwind components;
@tailwind utilities;
/* 文档入口壳层(原 index.html 内联 style,改为外链 CSS,对齐 Gitea 式 link stylesheet) */
html { scrollbar-gutter: stable; }
html, body, #root { height: 100%; margin: 0; touch-action: pan-x pan-y; }
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.app-header { height: 56px; flex-shrink: 0; }
.site-footer { flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; touch-action: pan-x pan-y; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; touch-action: pan-x pan-y; }
.sidebar { width: 210px; flex-shrink: 0; }
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; touch-action: pan-x pan-y; }
.aside-panel { width: 280px; flex-shrink: 0; }
@media (max-width: 1100px) { .aside-panel { display: none; } }
@media (max-width: 768px) { .sidebar { display: none; } }
@layer base {
:root {
--background: 210 20% 98%;
@@ -227,6 +243,30 @@ a:hover { text-decoration: underline; }
border-bottom: 1px solid var(--j13-border);
}
/* 站内跳转顶栏细进度条(2px,类 Next.js / YouTube) */
.top-progress {
position: fixed;
top: 0;
left: 0;
height: 2px;
z-index: 9999;
pointer-events: none;
background: var(--j13-green);
box-shadow: 0 0 8px rgba(24, 160, 88, 0.55), 0 0 2px rgba(24, 160, 88, 0.9);
transition: width 0.2s ease-out;
border-radius: 0 1px 1px 0;
}
.top-progress--done {
opacity: 0;
transition: width 0.12s ease-out, opacity 0.2s ease-out 0.05s;
}
html.dark .top-progress {
background: #36d399;
box-shadow: 0 0 8px rgba(54, 211, 153, 0.5), 0 0 2px rgba(54, 211, 153, 0.85);
}
/* 站点页脚 */
.site-footer {
flex-shrink: 0;
@@ -305,6 +345,13 @@ a:hover { text-decoration: underline; }
}
}
@media (min-width: 769px) {
/* 桌面:只保留壳层贴底页脚,隐藏列表内流入页脚 */
.post-list-scroll > .site-footer {
display: none;
}
}
.admin-brand-sections > .admin-settings-section {
margin-top: 20px;
padding-top: 16px;
@@ -475,6 +522,30 @@ img.site-brand-logo-img {
flex-shrink: 0;
font-size: 15px;
color: var(--color-text-3);
display: inline-flex;
align-items: center;
justify-content: center;
}
.header-search-icon svg {
width: 16px;
height: 16px;
}
/* SSR 主题钮:随 html.dark 切换日月图标 */
.ssr-theme-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.ssr-theme-icon--sun {
display: none;
}
html.dark .ssr-theme-icon--moon {
display: none;
}
html.dark .ssr-theme-icon--sun {
display: inline-flex;
}
.header-search-input {
@@ -1020,6 +1091,11 @@ img.site-brand-logo-img {
transition: background 0.15s, color 0.15s;
}
/* 桌面默认隐藏;手机在 max-width:768 中显示(需压过 .header-icon-btn) */
.header-icon-btn.header-menu-btn {
display: none;
}
.header-icon-btn:hover {
background: var(--color-fill-3);
color: var(--color-text-1);
@@ -1910,10 +1986,17 @@ img.site-brand-logo-img {
.sidebar { display: none; }
.header-inner { padding: 0 12px; gap: 8px; }
.header-brand { flex-shrink: 0; }
.header-logo-text { display: none; }
.header-icon-btn.header-menu-btn { display: flex; }
.header-theme-btn { display: none; }
.header-compose-btn__label { display: none; }
.header-search-wrap { max-width: none; }
.header-compose-btn { width: 34px; padding: 0; justify-content: center; }
.header-action-group { gap: 4px; }
/* 手机:藏壳层贴底页脚(列表内 InFlow 负责) */
.app-frame > .site-footer { display: none; }
.post-search-field__input {
font-size: 16px;
}
@@ -2189,6 +2272,30 @@ body:has(.admin-topbar) .ptr-indicator {
-ms-overflow-style: none;
}
/* 文档 SSR:静态列表(非 virtualizer absolute 行) */
.content-surface--ssr {
position: relative;
display: flex;
flex-direction: column;
}
a.post-row {
text-decoration: none;
color: inherit;
display: flex;
}
.feed-empty {
padding: 48px 16px;
text-align: center;
color: var(--color-text-3);
font-size: 14px;
}
.ssr-home .feed-header-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-1);
}
.virtual-list-wrap::-webkit-scrollbar,
.post-list-scroll::-webkit-scrollbar {
display: none;
@@ -2703,7 +2810,7 @@ body:has(.admin-topbar) .ptr-indicator {
.board-chip.active.board-chip--6 { background: var(--board-6-bg); color: var(--board-6-color); }
.board-chip.active.board-chip--7 { background: var(--board-7-bg); color: var(--board-7-color); }
/* 帖子排序栏:与左侧栏 active 样式保持一致 */
/* 帖子排序栏:与左侧栏 active 样式保持一致;flex:1 把「共 N 条」推到工具栏右缘 */
.feed-sort-bar {
display: flex;
align-items: center;
@@ -2731,6 +2838,7 @@ body:has(.admin-topbar) .ptr-indicator {
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
@@ -2751,6 +2859,14 @@ body:has(.admin-topbar) .ptr-indicator {
flex-shrink: 0;
}
.feed-sort-tab.is-pending {
color: var(--j13-green);
}
.feed-sort-tab__spin {
animation: ptr-spin 0.7s linear infinite;
}
@media (max-width: 768px) {
.feed-head {
flex-wrap: wrap;
@@ -2761,7 +2877,7 @@ body:has(.admin-topbar) .ptr-indicator {
.feed-head__stats { display: none; }
.feed-toolbar {
padding: 0 12px 8px;
padding: 10px 12px 8px;
}
.page-wrap {
@@ -2775,7 +2891,7 @@ body:has(.admin-topbar) .ptr-indicator {
}
/*
* 手机 Feed:主栏整页滚动,板块条 / 最新发帖·回复排序栏随列表滚走,
* 手机 Feed:主栏整页滚动,板块条 / 新评论·新帖子排序栏随列表滚走,
* 避免它们占住视口上半,给帖子列表更多空间。
*/
.main-content--feed-mobile-scroll {
@@ -3003,13 +3119,39 @@ body:has(.admin-topbar) .ptr-indicator {
}
.post-row--v2 .post-title-row {
display: flex;
align-items: center;
gap: 4px;
}
.post-row--v2 .post-title {
font-size: 15px;
font-weight: 400;
line-height: 1.35;
/* 与侧栏类型徽章 height:20px 对齐,避免 SSR(span 标题)视觉偏高/偏低 */
line-height: 20px;
}
/* 标题右侧类型徽章:统一盒高与文字居中(投票等原先仅靠 padding,易与问答徽章不齐) */
.post-row--v2 .post-title-type-badges {
display: inline-flex;
align-items: center;
align-self: center;
line-height: 1;
}
.post-row--v2 .post-title-type-badges .post-type-badge,
.post-row--v2 .post-title-type-badges .post-qa-badge,
.post-row--v2 .post-title-type-badges .post-bounty-badge {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 20px;
padding: 0 7px;
margin-right: 0;
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.post-row--v2 .post-excerpt {
@@ -3231,6 +3373,8 @@ body:has(.admin-topbar) .ptr-indicator {
.post-row--v2 .post-stats {
gap: 8px;
flex-shrink: 0;
margin-left: 0;
}
.post-row--v2 .post-stat {
@@ -3468,15 +3612,15 @@ body:has(.admin-topbar) .ptr-indicator {
.post-feature-badge {
padding: 0 7px 0 5px;
color: #b45309;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.22);
color: var(--j13-green);
background: var(--j13-green-bg);
border: 1px solid color-mix(in srgb, var(--j13-green) 28%, transparent);
}
.dark .post-feature-badge {
color: #fbbf24;
background: rgba(251, 191, 36, 0.14);
border-color: rgba(251, 191, 36, 0.22);
color: var(--j13-green);
background: var(--j13-green-bg);
border-color: color-mix(in srgb, var(--j13-green) 35%, transparent);
}
.post-feature-badge .post-featured-icon {
@@ -3501,6 +3645,21 @@ body:has(.admin-topbar) .ptr-indicator {
flex-shrink: 0;
}
/* 类型徽章缀在标题后:不参与收缩,标题在剩余宽度内 ellipsis */
.post-title-type-badges {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
max-width: none;
}
.post-title-type-badges .post-type-badge,
.post-title-type-badges .post-qa-badge,
.post-title-type-badges .post-bounty-badge {
margin-right: 0;
}
.post-title {
display: block;
overflow: hidden;
@@ -3513,8 +3672,9 @@ body:has(.admin-topbar) .ptr-indicator {
color: var(--color-text-1);
text-decoration: none;
transition: color 0.15s;
/* 不抢满行宽:短标题时类型徽章紧挨末字;过长时在剩余空间内省略 */
min-width: 0;
flex: 1;
flex: 0 1 auto;
}
a.post-title:visited {
@@ -3611,12 +3771,12 @@ a.post-title:visited {
display: inline-block;
vertical-align: -0.15em;
flex-shrink: 0;
color: #d97706;
color: var(--j13-green);
stroke-width: 1.75;
}
.dark .post-featured-icon {
color: #fbbf24;
color: var(--j13-green);
}
.post-meta {
@@ -7205,6 +7365,14 @@ a.waline-comment-author:hover {
width: 15px;
height: 15px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.widget-card-icon svg {
width: 15px;
height: 15px;
}
.widget-card-icon--hot { color: #e74c3c; }
@@ -7230,6 +7398,11 @@ a.waline-comment-author:hover {
padding: 0 8px;
font-size: 12px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
border-radius: var(--radius-sm, 6px);
color: var(--color-text-2);
text-decoration: none;
}
.widget-friend-links-title {
@@ -11110,7 +11283,7 @@ button.profile-stat:hover strong {
}
}
/* 路由懒加载占位已改为 FeedPageSkeleton,保留类名兼容 */
/* 路由懒加载占位类名保留兼容(前台已改顶栏进度 + 空白) */
/* ========== React 管理后台 ========== */
.admin-shell { min-height: 100vh; background: hsl(var(--background)); }
@@ -11572,6 +11745,21 @@ button.profile-stat:hover strong {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.admin-settings-subsection {
margin-top: 1.25rem;
}
.admin-settings-subsection-title {
margin: 0 0 0.25rem;
font-size: 14px;
font-weight: 600;
color: var(--foreground, #0f172a);
}
.admin-settings-subsection-desc {
margin: 0 0 0.75rem;
font-size: 12px;
line-height: 1.45;
color: hsl(var(--muted-foreground));
}
/* 设置表内紧凑开关(勿复用 .admin-mail-switch,其绝对定位会破坏后台布局) */
.admin-settings-switch {
@@ -14521,6 +14709,51 @@ button.post-poll__option,
font-weight: 600;
color: var(--foreground, #0f172a);
}
.admin-sortable-row__field {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.28rem;
min-width: 0;
}
.admin-sortable-row__field-name {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
color: hsl(var(--muted-foreground));
}
.admin-sortable-row__name-wrap {
position: relative;
display: block;
width: 100%;
max-width: 16rem;
}
.admin-sortable-row__name-wrap .admin-sortable-row__name-input {
width: 100%;
height: 36px;
padding-right: 2rem;
font-weight: 500;
color: hsl(var(--foreground));
background: hsl(var(--muted) / 0.55);
border: 1px solid hsl(var(--foreground) / 0.16);
box-shadow: inset 0 1px 1px hsl(var(--foreground) / 0.04);
}
.admin-sortable-row__name-wrap .admin-sortable-row__name-input:hover {
border-color: hsl(var(--foreground) / 0.28);
}
.admin-sortable-row__name-wrap .admin-sortable-row__name-input:focus-visible {
background: hsl(var(--background));
border-color: var(--j13-green);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--j13-green) 22%, transparent);
}
.admin-sortable-row__name-icon {
position: absolute;
right: 0.55rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.admin-sortable-row__hint {
font-size: 12px;
line-height: 1.45;
@@ -15356,6 +15589,18 @@ button.post-poll__option,
font-size: 12px;
color: hsl(var(--muted-foreground));
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-monitor-today-hint {
font-size: 10px;
font-weight: 400;
line-height: 1.3;
opacity: 0.85;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-monitor-today-value {
font-size: 22px;
@@ -15365,6 +15610,13 @@ button.post-poll__option,
line-height: 1.2;
word-break: break-all;
}
.admin-monitor-map-sub {
margin: 2px 0 0;
font-size: 12px;
font-weight: 400;
color: hsl(var(--muted-foreground));
line-height: 1.4;
}
.admin-monitor-main {
display: grid;

View File

@@ -0,0 +1,43 @@
import type { User } from '../api/types';
/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot) */
let seededUser: User | null | undefined;
let seededUnread = 0;
let hasAuthSeed = false;
/** 未读数可被 MainLayout 同步读取 */
let bootUnread = 0;
export function seedAuthFromHomeBoot(user: User | null | undefined, unread: number) {
hasAuthSeed = true;
seededUser = user ?? null;
seededUnread = Math.max(0, unread | 0);
bootUnread = seededUnread;
}
type AuthSeed = { hasSeed: boolean; user: User | null; unread: number };
/** 只读种子,不清空(StrictMode 双挂 / useState initializer 可重复调用) */
export function peekAuthSeed(): AuthSeed {
if (!hasAuthSeed) {
return { hasSeed: false, user: null, unread: bootUnread };
}
return { hasSeed: true, user: seededUser ?? null, unread: seededUnread };
}
/** mount 后清除用户种子,避免后续误用 */
export function clearAuthSeed() {
hasAuthSeed = false;
seededUser = undefined;
seededUnread = 0;
}
/** @deprecated 改用 peekAuthSeed + clearAuthSeed;保留兼容 */
export function takeAuthSeed(): AuthSeed {
const s = peekAuthSeed();
if (s.hasSeed) clearAuthSeed();
return s;
}
export function getBootUnread(): number {
return bootUnread;
}

View File

@@ -2,6 +2,22 @@
const RELOAD_AT_KEY = 'j13:chunk-reload-at';
const RELOAD_COOLDOWN_MS = 15_000;
/** 从 HTML 中提取入口 module script(Vite 产物:/assets/index-*.js) */
function extractEntryScriptSrc(html: string): string | null {
const re = /<script[^>]*\stype=["']module["'][^>]*\ssrc=["']([^"']+)["'][^>]*>/i;
const m = html.match(re);
if (m?.[1]) return m[1];
// src 在 type 之前
const re2 = /<script[^>]*\ssrc=["']([^"']+)["'][^>]*\stype=["']module["'][^>]*>/i;
return html.match(re2)?.[1] ?? null;
}
/** 当前文档已加载的入口 script src */
export function getDocumentEntryScriptSrc(): string | null {
const el = document.querySelector<HTMLScriptElement>('script[type="module"][src]');
return el?.getAttribute('src') ?? null;
}
/** 判断是否为「发版后旧 JS chunk 失效」类错误 */
export function isChunkLoadError(error: unknown): boolean {
const msg = error instanceof Error
@@ -32,3 +48,29 @@ export function reloadForStaleChunk(): boolean {
window.location.reload();
return true;
}
/**
* 软刷新前对比入口 HTML 中的 index-*.js 哈希。
* 发版后当前页 chunk 仍在内存,裸 import() 不会重新拉文件,必须主动检测。
* @returns 是否已触发硬刷新(调用方应停止后续软刷新)
*/
export async function reloadIfShellStale(): Promise<boolean> {
const current = getDocumentEntryScriptSrc();
if (!current) return false;
try {
const res = await fetch(`${window.location.pathname}${window.location.search}`, {
cache: 'no-store',
headers: { Accept: 'text/html' },
credentials: 'same-origin',
});
if (!res.ok) return false;
const html = await res.text();
const next = extractEntryScriptSrc(html);
if (!next || next === current) return false;
return reloadForStaleChunk();
} catch {
// 网络失败:跳过,继续软刷新
return false;
}
}

View File

@@ -1,10 +1,19 @@
import type { NavigateFunction } from 'react-router-dom';
import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar';
import {
feedCacheKey,
getHomeStoreState,
type FeedCacheEntry,
} from '../store/homeStore';
import { clearSessionSnapshots } from './sessionPageCache';
import { softRefreshCurrentPage } from './softRefresh';
import { transitionTo } from './spaTransition';
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
export type FeedNavState = { refreshFeed?: boolean };
/** 对外暴露的缓存形状(兼容旧调用方;不含 lastFetchTime) */
export type FeedCache = {
posts: PostItem[];
postTotal: number;
@@ -12,18 +21,13 @@ export type FeedCache = {
scrollTop: number;
};
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
const store = new Map<string, FeedCache>();
function cacheKey(
boardId: number,
keyword: string,
sort: FeedSort,
tag = '',
author = '',
titleOnly = false,
) {
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
function toPublic(entry: FeedCacheEntry): FeedCache {
return {
posts: entry.posts,
postTotal: entry.postTotal,
page: entry.page,
scrollTop: entry.scrollTop,
};
}
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
@@ -35,10 +39,12 @@ export function getFeedCache(
author = '',
titleOnly = false,
): FeedCache | null {
return store.get(cacheKey(boardId, keyword, sort, tag, author, titleOnly)) ?? null;
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
const entry = getHomeStoreState().getFeed(key);
return entry ? toPublic(entry) : null;
}
/** 保存帖子列表缓存 */
/** 保存帖子列表缓存(写入 Zustand,并记录拉取时间) */
export function setFeedCache(
boardId: number,
keyword: string,
@@ -48,20 +54,55 @@ export function setFeedCache(
author = '',
titleOnly = false,
) {
store.set(cacheKey(boardId, keyword, sort, tag, author, titleOnly), data);
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
getHomeStoreState().setFeed(key, data);
}
/** 清除所有帖子列表缓存 */
/** 清除所有帖子列表缓存(手动刷新 / 帖子变更时连详情快照一起作废) */
export function clearAllFeedCache() {
store.clear();
getHomeStoreState().clearAll();
clearSessionSnapshots('post:');
}
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
export const FEED_RESET_EVENT = 'feed-reset';
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
export function navigateFeed(nav: NavigateFunction, url: string) {
clearAllFeedCache();
window.dispatchEvent(new Event(FEED_RESET_EVENT));
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
/** 手机下拉刷新(Feed 页):强制重拉列表并重置滚动,不整页 reload */
export const FEED_PULL_REFRESH_EVENT = 'feed-pull-refresh';
/** 手机下拉:强制刷新当前前台页(绕过会话快照,非整页 reload) */
export const PAGE_FORCE_REFRESH_EVENT = 'page-force-refresh';
/** 当前浏览器地址是否已是目标 Feed URL(忽略 hash) */
function isSameFeedUrl(url: string): boolean {
try {
const target = new URL(url, window.location.origin);
return (
window.location.pathname === target.pathname
&& window.location.search === target.search
);
} catch {
return false;
}
}
/**
* 导航到帖子列表。
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时软刷新(带顶栏进度条)。
*/
export function navigateFeed(nav: NavigateFunction, url: string, opts?: { refresh?: boolean }) {
const same = isSameFeedUrl(url);
const refresh = opts?.refresh ?? same;
if (refresh) {
if (same) {
void softRefreshCurrentPage(url, { progress: true });
return;
}
void transitionTo(nav, url, {
force: true,
state: { refreshFeed: true } satisfies FeedNavState,
});
return;
}
void transitionTo(nav, url);
}

View File

@@ -0,0 +1,41 @@
import type { FeedSortId, FeedSortTab } from '../api/types';
import { DEFAULT_FEED_SORT_TABS } from '../api/types';
const FEED_SORT_IDS: FeedSortId[] = ['reply', 'latest', 'hot'];
const DEFAULT_LABELS: Record<FeedSortId, string> = {
reply: '新评论',
latest: '新帖子',
hot: '推荐帖',
};
/** 校验并补全 Feed 排序标签;至少保留一项启用 */
export function normalizeFeedSortTabs(tabs?: FeedSortTab[] | null): FeedSortTab[] {
const seen = new Set<FeedSortId>();
const out: FeedSortTab[] = [];
for (const t of tabs ?? []) {
if (!FEED_SORT_IDS.includes(t.id) || seen.has(t.id)) continue;
seen.add(t.id);
const label = (t.label || '').trim() || DEFAULT_LABELS[t.id];
out.push({ id: t.id, label, enabled: !!t.enabled });
}
for (const id of FEED_SORT_IDS) {
if (seen.has(id)) continue;
const fallback = DEFAULT_FEED_SORT_TABS.find(t => t.id === id)!;
out.push({ ...fallback });
}
if (!out.some(t => t.enabled) && out.length > 0) {
out[0] = { ...out[0], enabled: true };
}
return out;
}
/** 启用中的排序标签(按配置顺序) */
export function enabledFeedSortTabs(tabs?: FeedSortTab[] | null): FeedSortTab[] {
return normalizeFeedSortTabs(tabs).filter(t => t.enabled);
}
/** 默认排序 = 第一个启用项 */
export function getDefaultFeedSort(tabs?: FeedSortTab[] | null): FeedSortId {
return enabledFeedSortTabs(tabs)[0]?.id ?? 'reply';
}

View File

@@ -0,0 +1,110 @@
import type {
Board,
CheckInStatus,
CommunityShowcaseItem,
ForumLimitsPublic,
ForumStats,
PostItem,
RecentComment,
RecentUser,
SiteBranding,
SitePageSummary,
TagCount,
User,
} from '../api/types';
import { seedSiteBrandingCache } from '../hooks/useSiteBranding';
import { seedForumLimitsCache } from '../hooks/useForumLimits';
import { seedSitePagesCache } from '../hooks/useSitePages';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import {
setCachedBoards,
setCachedRecentComments,
setCachedRecentUsers,
setCachedStats,
setCachedTags,
} from './layoutCache';
import { setSessionSnapshot } from './sessionPageCache';
import { seedAuthFromHomeBoot } from './authBoot';
import { checkInCacheKey } from '../hooks/useCheckIn';
import type { FeedSort } from '../components/FeedSortBar';
/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */
export type HomeBootPayload = {
board_id: number;
sort: string;
keyword: string;
tag: string;
author: string;
title_only: boolean;
posts: PostItem[];
post_total: number;
page: number;
boards: Board[];
stats: ForumStats;
recent_comments: RecentComment[];
recent_users: RecentUser[];
tags: TagCount[];
showcase: CommunityShowcaseItem[];
pages: SitePageSummary[];
limits: ForumLimitsPublic;
branding: SiteBranding;
user?: User | null;
unread_messages?: number;
check_in?: CheckInStatus | null;
};
declare global {
interface Window {
__J13_HOME_BOOT__?: HomeBootPayload;
}
}
/** 读取并清除文档 SSR 注入的首页 boot,灌入各层缓存 */
export function consumeHomeBoot(): HomeBootPayload | null {
const boot = window.__J13_HOME_BOOT__;
try {
delete window.__J13_HOME_BOOT__;
} catch {
window.__J13_HOME_BOOT__ = undefined;
}
if (!boot || typeof boot !== 'object') return null;
if (boot.limits) seedForumLimitsCache(boot.limits);
if (boot.branding) seedSiteBrandingCache(boot.branding);
if (Array.isArray(boot.pages)) seedSitePagesCache(boot.pages);
if (Array.isArray(boot.boards)) setCachedBoards(boot.boards);
if (boot.stats) setCachedStats(boot.stats);
if (Array.isArray(boot.recent_comments)) setCachedRecentComments(boot.recent_comments);
if (Array.isArray(boot.recent_users)) setCachedRecentUsers(boot.recent_users);
if (Array.isArray(boot.tags)) setCachedTags(boot.tags);
if (Array.isArray(boot.showcase)) setSessionSnapshot('showcase', boot.showcase);
// 鉴权 / 签到:有 user 字段即种子(含 null = 已确认访客)
if ('user' in boot) {
seedAuthFromHomeBoot(boot.user ?? null, boot.unread_messages ?? 0);
const uid = boot.user?.id;
if (uid && boot.check_in) {
setSessionSnapshot(checkInCacheKey(uid), boot.check_in);
}
}
const sort = (boot.sort || 'reply') as FeedSort;
const key = feedCacheKey({
boardId: boot.board_id || 0,
keyword: boot.keyword || '',
tag: boot.tag || '',
author: boot.author || '',
titleOnly: !!boot.title_only,
sort,
});
const posts = Array.isArray(boot.posts) ? boot.posts : [];
getHomeStoreState().setFeed(key, {
posts,
postTotal: boot.post_total ?? posts.length,
page: boot.page || 1,
scrollTop: 0,
lastFetchTime: Date.now(),
});
return boot;
}

View File

@@ -0,0 +1,41 @@
/** 首页 SSR hydrate 首帧同构标志(仅 / 与板块首页) */
declare global {
interface Window {
__J13_HYDRATING_HOME__?: boolean;
}
}
let hydrating = false;
export function beginHomeHydrate() {
hydrating = true;
try {
window.__J13_HYDRATING_HOME__ = true;
} catch {
/* ignore */
}
}
export function endHomeHydrate() {
hydrating = false;
try {
delete window.__J13_HYDRATING_HOME__;
} catch {
try {
window.__J13_HYDRATING_HOME__ = undefined;
} catch {
/* ignore */
}
}
}
/** 首帧是否必须与 Go SSR DOM 同构 */
export function isHomeHydrating(): boolean {
if (hydrating) return true;
try {
return !!window.__J13_HYDRATING_HOME__;
} catch {
return false;
}
}

View File

@@ -1,5 +1,6 @@
import type { NavigateFunction } from 'react-router-dom';
import { postPath, type PermalinkOpts } from './permalink';
import { transitionTo } from './spaTransition';
export type OpenForumPostOpts = PermalinkOpts & {
/** 跳转到指定楼层(#floor-N) */
@@ -18,5 +19,5 @@ export function openForumPost(
window.open(path, '_blank', 'noopener,noreferrer');
return;
}
nav(path);
void transitionTo(nav, path);
}

View File

@@ -0,0 +1,447 @@
import type { To } from 'react-router-dom';
import { api } from '../api/client';
import type {
CheckInStatus,
Comment,
CommunityShowcaseItem,
FriendLinkApply,
PollView,
PostItem,
PostLotteryView,
SitePage,
} from '../api/types';
import { parseFeedSort } from '../components/FeedSortBar';
import { checkInCacheKey } from '../hooks/useCheckIn';
import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForumLimits';
import { ensureSiteBrandingLoaded } from '../hooks/useSiteBranding';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { resolveAsideWidgets } from './asideWidgets';
import { isTimeDiffSignificant } from './content';
import { loadMyCommentIds } from './guest';
import {
setCachedBoards,
setCachedStats,
setCachedRecentComments,
setCachedRecentUsers,
setCachedTags,
} from './layoutCache';
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
import { parsePermalinkID, parsePermalinkSlug } from './permalink';
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
/** 与 PostDetailPage 会话快照同形,供预取写入 */
type PostDetailSnapshot = {
post: PostItem;
comments: Comment[];
poll: PollView | null;
lottery: PostLotteryView | null;
liked: boolean;
favorited: boolean;
canEdit: boolean;
isEdited: boolean;
editBlockReason: string;
editWindowHours: number;
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
scrollTop: number;
};
function resolveUrl(to: To): URL {
if (typeof to === 'string') return new URL(to, window.location.origin);
const path = to.pathname ?? '/';
const search = to.search ?? '';
const hash = to.hash ?? '';
return new URL(path + search + hash, window.location.origin);
}
/** 预加载对应路由的 lazy chunk(与 App.tsx lazyWithRetry 对齐) */
function preloadChunk(pathname: string): Promise<unknown> {
let load: Promise<unknown>;
if (pathname === '/' || /^\/board\//.test(pathname)) {
load = import('../pages/HomePage');
} else if (/^\/post\/[^/]+\/edit$/.test(pathname) || pathname === '/compose') {
load = import('../pages/ComposePage');
} else if (/^\/post\//.test(pathname)) {
load = import('../pages/PostDetailPage');
} else if (pathname === '/profile') {
load = import('../pages/ProfilePage');
} else if (/^\/user\//.test(pathname)) {
load = import('../pages/UserProfilePage');
} else if (pathname === '/favorites') {
load = import('../pages/FavoritesPage');
} else if (pathname === '/projects') {
load = import('../pages/ProjectsPage');
} else if (pathname === '/links') {
load = import('../pages/LinksPage');
} else if (pathname === '/showcase') {
load = import('../pages/ShowcasePage');
} else if (pathname === '/messages') {
load = import('../pages/MessagesPage');
} else if (/^\/page\//.test(pathname)) {
load = import('../pages/SitePageView');
} else if (pathname === '/login') {
load = import('../pages/LoginPage');
} else if (pathname === '/register') {
load = import('../pages/RegisterPage');
} else if (pathname === '/forgot-password') {
load = import('../pages/ForgotPasswordPage');
} else {
return Promise.resolve();
}
// 发版后旧 hashed URL 404:硬刷新,挂起 Promise 避免落到 toast
return load.catch((err: unknown) => {
if (isChunkLoadError(err) && reloadForStaleChunk()) {
return new Promise(() => {});
}
throw err;
});
}
async function prefetchFeed(url: URL, force: boolean): Promise<void> {
const limits = getCachedForumLimits();
const pageSize = Math.max(1, limits.page_size_default);
const boardMatch = url.pathname.match(/^\/board\/([^/]+)/);
const boardId = boardMatch ? (parsePermalinkID(boardMatch[1]) || 0) : 0;
const keyword = url.searchParams.get('keyword') || '';
const tag = url.searchParams.get('tag') || '';
const author = url.searchParams.get('author') || '';
const titleOnly = url.searchParams.get('title_only') === '1';
const sort = parseFeedSort(url.searchParams.get('sort'), limits.feed_sort_tabs);
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
if (!force) {
const hit = getHomeStoreState().getFeed(key);
if (hit && hit.posts.length > 0) return;
}
// force:不提前清空 store,等新数据写入时覆盖,避免软刷新中间态读到空列表
const data = await api.posts({
page: 1,
size: pageSize,
board_id: boardId || '',
keyword: tag ? '' : keyword,
tag: tag || '',
author: tag ? '' : author,
title_only: !tag && titleOnly ? '1' : '',
sort,
});
getHomeStoreState().setFeed(key, {
posts: Array.isArray(data.posts) ? data.posts : [],
postTotal: data.total ?? 0,
page: 1,
scrollTop: 0,
lastFetchTime: Date.now(),
});
}
async function prefetchPost(id: number, force: boolean): Promise<void> {
const key = `post:${id}`;
if (!force && getSessionSnapshot(key) !== undefined) return;
// force:保留旧快照直到新数据写回,避免软刷新中间态空白
const myIds = loadMyCommentIds();
const [detail, comm] = await Promise.all([
api.post(id),
api.comments(id, myIds),
]);
const snap: PostDetailSnapshot = {
post: detail.post,
comments: Array.isArray(comm.comments) ? comm.comments : [],
poll: detail.poll ?? null,
lottery: detail.lottery ?? null,
liked: detail.liked,
favorited: detail.favorited,
canEdit: detail.can_edit ?? false,
isEdited: detail.is_edited
?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
editBlockReason: detail.edit_block_reason ?? '',
editWindowHours: detail.post_edit_window_hours ?? 0,
bountyCanRefund: detail.bounty_can_refund ?? true,
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
scrollTop: 0,
};
setSessionSnapshot(key, snap);
}
async function prefetchUser(id: number, force: boolean): Promise<void> {
const profileKey = `user:${id}`;
if (!force && getSessionSnapshot(profileKey) !== undefined) return;
const limits = getCachedForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const [profileRes, postsRes] = await Promise.all([
api.userProfile(id),
api.posts({ user_id: id, page: 1, size: pageSize, sort: 'latest' }),
]);
setSessionSnapshot(profileKey, {
profile: profileRes.user,
stats: profileRes.stats ?? null,
});
setSessionSnapshot(`${profileKey}:posts:1:${pageSize}`, {
posts: Array.isArray(postsRes.posts) ? postsRes.posts : [],
total: postsRes.total ?? 0,
});
}
async function prefetchKeyed<T>(
key: string,
force: boolean,
fetcher: () => Promise<T>,
): Promise<void> {
if (!force && getSessionSnapshot(key) !== undefined) return;
const data = await fetcher();
setSessionSnapshot(key, data);
}
/** 静默预热签到(401 / 失败不阻断跳转) */
async function prefetchCheckIn(force: boolean): Promise<void> {
try {
const me = await api.me();
const id = me.user?.id;
if (!id) return;
const key = checkInCacheKey(id);
if (!force && getSessionSnapshot<CheckInStatus>(key) !== undefined) return;
const d = await api.checkInStatus();
setSessionSnapshot(key, d.check_in);
} catch {
// 未登录或接口失败:忽略
}
}
/** 右栏展柜与全页共用 showcase 快照 */
async function prefetchShowcaseIfEnabled(force: boolean): Promise<void> {
const limits = getCachedForumLimits();
const enabled = resolveAsideWidgets(limits).some((w) => w.id === 'showcase' && w.enabled);
if (!enabled) return;
await prefetchKeyed<CommunityShowcaseItem[]>('showcase', force, () =>
api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
).catch(() => undefined);
}
/** MainLayout 壳层:签到 +(可选)展柜,与主内容并行 */
function prefetchShell(force: boolean): Promise<void> {
return Promise.all([
prefetchCheckIn(force),
prefetchShowcaseIfEnabled(force),
]).then(() => undefined);
}
/** 是否会挂载前台 MainLayout(非纯 auth / 后台页) */
export function isMainLayoutPath(pathname: string): boolean {
if (pathname.startsWith('/admin')) return false;
if (pathname === '/login' || pathname === '/register' || pathname === '/forgot-password') return false;
return true;
}
/** 按目标 URL 预热会话快照 / Feed 缓存(不改 fetch 缓存策略) */
export async function prefetchData(to: To, opts?: { force?: boolean }): Promise<void> {
const force = !!opts?.force;
const url = resolveUrl(to);
const { pathname } = url;
const shell = isMainLayoutPath(pathname) ? prefetchShell(force) : Promise.resolve();
if (pathname === '/' || /^\/board\//.test(pathname)) {
await Promise.all([prefetchFeed(url, force), shell]);
return;
}
const postEdit = pathname.match(/^\/post\/([^/]+)\/edit$/);
if (postEdit) {
await shell;
return;
}
const postMatch = pathname.match(/^\/post\/([^/]+)/);
if (postMatch) {
const id = parsePermalinkID(postMatch[1]);
await Promise.all([
id && !Number.isNaN(id) ? prefetchPost(id, force) : Promise.resolve(),
shell,
]);
return;
}
const userMatch = pathname.match(/^\/user\/([^/]+)/);
if (userMatch) {
const id = parsePermalinkID(userMatch[1]);
await Promise.all([
id && !Number.isNaN(id) ? prefetchUser(id, force) : Promise.resolve(),
shell,
]);
return;
}
if (pathname === '/favorites') {
await Promise.all([
prefetchKeyed('favorites', force, () =>
api.favorites().then((d) => (Array.isArray(d.favorites) ? d.favorites : [])),
),
shell,
]);
return;
}
if (pathname === '/projects') {
await Promise.all([
prefetchKeyed(`projects:1:`, force, () =>
api.projects({ page: 1, limit: 30 }).then((d) => ({
list: Array.isArray(d.projects) ? d.projects : [],
total: d.total ?? 0,
totalPages: d.total_pages ?? 0,
})),
),
shell,
]);
return;
}
if (pathname === '/links') {
await Promise.all([
prefetchKeyed<FriendLinkApply[]>('links:applies', force, () =>
api.myFriendLinkApplies().then((r) => r.applies ?? []).catch(() => []),
),
shell,
]);
return;
}
if (pathname === '/showcase') {
await Promise.all([
prefetchKeyed('showcase', force, () =>
api.communityShowcase().then((r) => (Array.isArray(r.items) ? r.items : [])),
),
shell,
]);
return;
}
if (pathname === '/messages') {
await Promise.all([
prefetchKeyed('messages:conv:1', force, () =>
api.messageConversations({ page: 1, size: 30 }).then((r) => ({
conversations: r.conversations || [],
total: r.total || 0,
page: r.page || 1,
})),
),
shell,
]);
return;
}
if (pathname === '/profile' || pathname === '/compose') {
await shell;
return;
}
const pageMatch = pathname.match(/^\/page\/([^/]+)/);
if (pageMatch) {
const slug = parsePermalinkSlug(pageMatch[1]);
await Promise.all([
slug
? prefetchKeyed<SitePage | null>(`sitepage:${slug}`, force, () =>
api.page(slug).then((d) => d.page),
)
: Promise.resolve(),
shell,
]);
}
}
/** chunk + 数据并行预热 */
export async function prefetchRoute(to: To, opts?: { force?: boolean }): Promise<void> {
const url = resolveUrl(to);
await Promise.all([
preloadChunk(url.pathname),
prefetchData(to, opts),
]);
}
/** 壳层数据:boards/stats/站点页/右栏(与冷启动、软刷新共用) */
export async function prefetchLayoutShell(opts?: { force?: boolean }): Promise<void> {
const force = !!opts?.force;
await ensureForumLimitsLoaded({ force });
const limits = getCachedForumLimits();
const widgets = resolveAsideWidgets(limits);
const showRecentComments = widgets.some((w) => w.id === 'recent_comments' && w.enabled);
const showRecentUsers = widgets.some((w) => w.id === 'recent_users' && w.enabled);
const showTagCloud = widgets.some((w) => w.id === 'tag_cloud' && w.enabled);
const hideAside = typeof window !== 'undefined'
&& window.matchMedia('(max-width: 1100px)').matches;
const tasks: Promise<unknown>[] = [
api.boards().then((d) => {
setCachedBoards(d.boards ?? []);
}).catch(() => undefined),
api.stats().then((next) => {
if (next) setCachedStats(next);
}).catch(() => undefined),
ensureSitePagesLoaded({ force }),
];
// 软刷新:与壳层同拍重拉站名 / 友链等品牌文案
if (force) {
tasks.push(ensureSiteBrandingLoaded({ force: true }).catch(() => undefined));
}
if (!hideAside) {
if (showRecentComments) {
tasks.push(
api.recentComments().then((d) => {
setCachedRecentComments(Array.isArray(d.comments) ? d.comments : []);
}).catch(() => undefined),
);
}
if (showRecentUsers) {
tasks.push(
api.recentUsers().then((d) => {
setCachedRecentUsers(Array.isArray(d.users) ? d.users : []);
}).catch(() => undefined),
);
}
if (showTagCloud) {
tasks.push(
api.tags(40).then((d) => {
setCachedTags(Array.isArray(d.tags) ? d.tags : []);
}).catch(() => undefined),
);
}
}
await Promise.all(tasks);
}
let coldBootEnsured = false;
/** main.tsx 是否已完成前台冷启动预热(MainLayout 可同步放行) */
export function wasColdBootEnsured(): boolean {
return coldBootEnsured;
}
/**
* 冷启动:在 createRoot 之前静默预热当前前台路由与壳层。
* 不触发顶栏进度条;失败也放行,避免永久空白。
*/
export async function ensureColdBootReady(to?: string): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`;
const url = resolveUrl(path);
if (!isMainLayoutPath(url.pathname)) {
coldBootEnsured = true;
return;
}
try {
await Promise.all([
prefetchRoute(path, { force: false }),
prefetchLayoutShell(),
]);
} catch {
// 忽略:仍挂载 App
} finally {
coldBootEnsured = true;
}
}

View File

@@ -0,0 +1,45 @@
/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空 */
const MAX_ENTRIES = 48;
const order: string[] = [];
const store = new Map<string, unknown>();
function touch(key: string) {
const i = order.indexOf(key);
if (i >= 0) order.splice(i, 1);
order.push(key);
while (order.length > MAX_ENTRIES) {
const old = order.shift();
if (old) store.delete(old);
}
}
export function getSessionSnapshot<T>(key: string): T | undefined {
if (!store.has(key)) return undefined;
touch(key);
return store.get(key) as T;
}
export function setSessionSnapshot<T>(key: string, data: T): void {
store.set(key, data);
touch(key);
}
export function deleteSessionSnapshot(key: string): void {
if (!store.delete(key)) return;
const i = order.indexOf(key);
if (i >= 0) order.splice(i, 1);
}
/** 不传 prefix 则清空全部;否则只删该前缀的 key */
export function clearSessionSnapshots(prefix?: string): void {
if (!prefix) {
store.clear();
order.length = 0;
return;
}
for (const key of [...store.keys()]) {
if (key.startsWith(prefix)) deleteSessionSnapshot(key);
}
}

View File

@@ -0,0 +1,38 @@
import { isChunkLoadError, reloadForStaleChunk, reloadIfShellStale } from './chunkLoad';
import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute';
import { doneTransition, startTransition } from './spaTransition';
/** 软刷新齐套后的单一提交:各组件同一拍从 cache/快照同步 UI,禁止分批闪烁 */
export const PAGE_SOFT_REFRESH_COMMIT_EVENT = 'page-soft-refresh-commit';
export type SoftRefreshOpts = {
/** 是否显示顶栏进度条(Logo 刷新开;下拉关) */
progress?: boolean;
};
/**
* 软刷新当前页:先检测入口壳是否过期,再预热齐套后派发一次 commit。
* `progress: true` 时走顶栏进度条。
*/
export async function softRefreshCurrentPage(to?: string, opts?: SoftRefreshOpts): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`;
const id = opts?.progress ? startTransition() : undefined;
try {
// 发版后旧壳仍在内存:先对比入口 script,过期则硬刷新
if (await reloadIfShellStale()) {
return;
}
await Promise.all([
prefetchRoute(path, { force: true }),
prefetchLayoutShell({ force: true }),
]);
} catch (e: unknown) {
if (isChunkLoadError(e) && reloadForStaleChunk()) {
return;
}
// 其它错误仍派发 commit,让界面有机会用已有缓存自愈
} finally {
if (id != null) doneTransition(id);
}
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
}

View File

@@ -0,0 +1,113 @@
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
import { prefetchRoute } from './prefetchRoute';
type Listener = (active: boolean) => void;
const listeners = new Set<Listener>();
let activeCount = 0;
let seq = 0;
function emit() {
const active = activeCount > 0;
listeners.forEach((fn) => fn(active));
}
/** 订阅顶栏进度条显隐 */
export function subscribeSpaTransition(fn: Listener): () => void {
listeners.add(fn);
fn(activeCount > 0);
return () => { listeners.delete(fn); };
}
/** 开始一次过渡(可嵌套,全部结束才收条) */
export function startTransition(): number {
const id = ++seq;
activeCount += 1;
emit();
return id;
}
/** 结束过渡;若 id 已过期则忽略 */
export function doneTransition(id?: number) {
if (id != null && id !== seq && activeCount > 0) {
// 仍允许减计数,避免卡死;连续点击用 seq 作废预取即可
}
activeCount = Math.max(0, activeCount - 1);
emit();
}
export function isSpaTransitionActive(): boolean {
return activeCount > 0;
}
export type TransitionToOpts = NavigateOptions & {
/** 强制重拉(Logo / 同 URL 刷新),跳过会话快照 */
force?: boolean;
/** 跳过等待,立即导航(纠偏、POP 等) */
immediate?: boolean;
/** 不显示顶栏进度条(软刷新换页) */
silent?: boolean;
};
function toPathname(to: To): string {
if (typeof to === 'number') return '';
if (typeof to === 'string') {
try {
return new URL(to, window.location.origin).pathname;
} catch {
return to.split('?')[0].split('#')[0];
}
}
return to.pathname ?? '';
}
function isOnlyHashChange(to: To): boolean {
if (typeof to !== 'string') return false;
if (!to.startsWith('#')) return false;
return true;
}
/**
* 站内点击:顶栏进度条 + 预热 chunk/接口后再跳转。
* `nav(-1)`、仅 hash、immediate、外链风格路径直接走。
*/
export async function transitionTo(
nav: NavigateFunction,
to: To | number,
opts?: TransitionToOpts,
): Promise<void> {
const { force, immediate, silent, ...navOpts } = opts ?? {};
if (typeof to === 'number' || immediate || isOnlyHashChange(to as To)) {
nav(to as To, navOpts);
return;
}
const target = to as To;
const path = toPathname(target);
// 后台第一期不做等待跳转
if (path.startsWith('/admin')) {
nav(target, navOpts);
return;
}
const id = silent ? undefined : startTransition();
const mySeq = silent ? seq : (id as number);
try {
await prefetchRoute(target, { force: !!force });
if (!silent && mySeq !== seq) return;
nav(target, navOpts);
} catch (e: unknown) {
if (!silent && mySeq !== seq) return;
// 发版后 chunk 404:整页刷新,不 toast 英文原错
if (isChunkLoadError(e) && reloadForStaleChunk()) return;
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
if (id != null) {
if (mySeq === seq) doneTransition(id);
else doneTransition();
}
}
}

View File

@@ -1,13 +1,40 @@
import path from 'path';
import { defineConfig } from 'vite';
import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';
// 与 Go 后端默认端口一致,可通过 VITE_API_PORT 覆盖
const apiPort = process.env.VITE_API_PORT || '3000';
const apiTarget = `http://localhost:${apiPort}`;
/** 把 stylesheet 挪到 head 靠前(先于 module script),接近 Gitea 外链 CSS 顺序 */
function htmlStylesheetsFirst(): Plugin {
return {
name: 'html-stylesheets-first',
enforce: 'post',
transformIndexHtml(html) {
// 只处理真实标签,忽略注释里的示例文字
const linkRe = /<link\b(?![^>]*\/?>)[^>]*\brel=["']stylesheet["'][^>]*>\s*/gi;
// 更稳妥:逐个找 link 标签
const styles: string[] = [];
const out = html.replace(/<link\b[^>]*>/gi, (tag) => {
if (/\brel=["']stylesheet["']/i.test(tag)) {
styles.push(tag);
return '';
}
return tag;
});
if (!styles.length) return html;
const inject = `${styles.join('\n ')}\n `;
if (/<script\b/i.test(out)) {
return out.replace(/<script\b/i, `${inject}<script`);
}
return out.replace(/<\/head>/i, ` ${inject}</head>`);
},
};
}
export default defineConfig({
plugins: [react()],
plugins: [react(), htmlStylesheetsFirst()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),

View File

@@ -286,7 +286,7 @@ func (h *Handlers) APIAdminBoardPinPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": msg, "board_pinned": req.BoardPinned})
}
// APIAdminFeaturePost 设为精华/取消精华(JSON)
// APIAdminFeaturePost 设为推荐/取消推荐(JSON)
func (h *Handlers) APIAdminFeaturePost(c *gin.Context) {
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
@@ -300,9 +300,9 @@ func (h *Handlers) APIAdminFeaturePost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
msg := "已取消精华"
msg := "已取消推荐"
if req.Featured {
msg = "已设为精华"
msg = "已设为推荐"
}
c.JSON(http.StatusOK, gin.H{"message": msg, "featured": req.Featured})
}
@@ -967,6 +967,11 @@ func (h *Handlers) APIPosts(c *gin.Context) {
author := strings.TrimSpace(c.Query("author"))
titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true")
sort := strings.TrimSpace(c.Query("sort"))
if sort == "" {
sort = h.Settings.DefaultFeedSort()
}
q := service.PostListQuery{
BoardID: uint(boardID),
UserID: uint(userID),
@@ -976,7 +981,7 @@ func (h *Handlers) APIPosts(c *gin.Context) {
Tag: tag,
Author: author,
TitleOnly: titleOnly,
Sort: c.DefaultQuery("sort", "latest"),
Sort: sort,
ViewerID: h.currentUserID(c),
ViewerIsAdmin: h.isAdmin(c),
}

View File

@@ -2,18 +2,16 @@ package handler
import (
"encoding/json"
"fmt"
"html"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/embed_static"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
"github.com/gin-gonic/gin"
)
var (
@@ -50,6 +48,18 @@ func (h *Handlers) RobotsTxt(c *gin.Context) {
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
}
// FaviconICO 约定路径 /favicon.ico:有品牌图标则 302 到实际上传 URL,否则 404
func (h *Handlers) FaviconICO(c *gin.Context) {
href := strings.TrimSpace(h.Settings.SiteBranding().Favicon)
if href == "" {
c.Status(http.StatusNotFound)
return
}
// 相对路径保持站内跳转;绝对 URL 也可 Redirect
c.Header("Cache-Control", "public, max-age=86400")
c.Redirect(http.StatusFound, href)
}
// SitemapXML 公开页面站点地图
func (h *Handlers) SitemapXML(c *gin.Context) {
base := h.publicBaseURL(c)
@@ -147,9 +157,9 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
}
// ServePublicSPA 公开页入口:
// - 普通用户:干净 SPA + <head> meta(无正文预渲染,避免刷新闪屏)
// - 搜索/社交爬虫:服务端 HTML(动态渲染)
// ServePublicSPA 公开页入口:人机统一响应,不再按 User-Agent 分叉。
// - 首页 / 板块:完整首屏 SSR(serveFeedDocument)
// - 其余公开页:干净 SPA + <head> meta
// - 伪静态:按后台配置的后缀做规范 URL,非规范路径 301
func (h *Handlers) ServePublicSPA(c *gin.Context) {
path := c.Request.URL.Path
@@ -181,11 +191,6 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
}
}
isBot := service.IsSEOCrawler(c.Request.UserAgent())
if isBot {
c.Header("Vary", "User-Agent")
}
// 板块首页(含可选伪静态后缀)
if bm := permalink.MatchBoardPath(path); bm.OK {
if bm.NeedsCanonicalRedirect(path) {
@@ -194,7 +199,7 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
}
board, err := h.Board.GetByID(bm.ID)
if err != nil {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
h.serveNotFound(c, base, siteName, siteKeywords, path)
return
}
desc := strings.TrimSpace(board.Description)
@@ -209,11 +214,7 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
OGType: "website",
OGImage: defaultImage,
}, siteName, siteKeywords)
if isBot {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board)))
return
}
embed_static.ServeSPAWithMeta(c, meta)
h.serveFeedDocument(c, meta, board.ID)
return
}
@@ -225,14 +226,10 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
}
post, err := h.Post.FindByID(pm.ID)
if err != nil || !service.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
h.serveNotFound(c, base, siteName, siteKeywords, path)
return
}
postKeywords := service.JoinSEOKeywords(post.Board.Name, siteKeywords)
if isBot {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post)))
return
}
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, postKeywords))
return
}
@@ -245,11 +242,7 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
}
user, err := h.User.GetByID(um.ID)
if err != nil || user.Banned {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
return
}
if isBot {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user)))
h.serveNotFound(c, base, siteName, siteKeywords, path)
return
}
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, siteKeywords))
@@ -264,7 +257,7 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
}
page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c))
if err != nil {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
h.serveNotFound(c, base, siteName, siteKeywords, path)
return
}
desc := service.ExcerptFromHTML(page.Content, seoDescMax)
@@ -276,36 +269,26 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
OGType: "article",
OGImage: defaultImage,
}, siteName, siteKeywords)
if isBot {
body := fmt.Sprintf(`<h1>%s</h1><div>%s</div>`, html.EscapeString(page.Title), page.Content)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderBotHTML(meta, body)))
return
}
embed_static.ServeSPAWithMeta(c, meta)
return
}
// 未知路径 → 404
if !isKnownPublicPath(path) {
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
h.serveNotFound(c, base, siteName, siteKeywords, path)
return
}
// 其余已知路由:SPA + head meta;首页对爬虫额外返回可读正文
// 其余已知路由:SPA + head meta;首页/Feed 返回完整首屏 HTML
meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
if isBot && (path == "/" || path == "") {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand)))
if path == "/" || path == "" {
h.serveFeedDocument(c, meta, 0)
return
}
embed_static.ServeSPAWithMeta(c, meta)
}
func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string, isBot bool) {
if isBot {
c.Header("Vary", "User-Agent")
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(botNotFoundHTML(base, siteName, keywords, path)))
return
}
func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string) {
embed_static.ServeSPAWithMeta(c, notFoundPageMeta(base, siteName, keywords, path))
}

View File

@@ -1,158 +0,0 @@
package handler
import (
"fmt"
"html"
"strings"
"time"
"git.iioio.com/freefire/jiang13-forum/embed_static"
"git.iioio.com/freefire/jiang13-forum/model"
"git.iioio.com/freefire/jiang13-forum/service"
)
// 爬虫专用伪静态 HTML(无 SPA;仅 User-Agent 命中爬虫时返回,避免用户刷新闪屏)
func renderBotHTML(meta *embed_static.SPAPageMeta, bodyInner string) string {
if meta == nil {
meta = &embed_static.SPAPageMeta{}
}
ogType := strings.TrimSpace(meta.OGType)
if ogType == "" {
ogType = "website"
}
locale := strings.TrimSpace(meta.Locale)
if locale == "" {
locale = "zh_CN"
}
var b strings.Builder
b.WriteString("<!DOCTYPE html><html lang=\"zh-CN\"><head>")
b.WriteString("<meta charset=\"UTF-8\"/>")
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>")
writeEscapedTag(&b, "title", meta.Title)
writeEscapedMeta(&b, "name", "description", meta.Description)
writeEscapedMeta(&b, "name", "keywords", meta.Keywords)
if meta.Robots != "" {
writeEscapedMeta(&b, "name", "robots", meta.Robots)
}
if meta.Canonical != "" {
b.WriteString(`<link rel="canonical" href="` + html.EscapeString(meta.Canonical) + `"/>`)
}
writeEscapedMeta(&b, "property", "og:type", ogType)
writeEscapedMeta(&b, "property", "og:site_name", meta.SiteName)
writeEscapedMeta(&b, "property", "og:locale", locale)
writeEscapedMeta(&b, "property", "og:title", meta.Title)
writeEscapedMeta(&b, "property", "og:description", meta.Description)
writeEscapedMeta(&b, "property", "og:url", meta.Canonical)
writeEscapedMeta(&b, "property", "og:image", meta.OGImage)
card := "summary"
if strings.TrimSpace(meta.OGImage) != "" {
card = "summary_large_image"
}
writeEscapedMeta(&b, "name", "twitter:card", card)
writeEscapedMeta(&b, "name", "twitter:title", meta.Title)
writeEscapedMeta(&b, "name", "twitter:description", meta.Description)
writeEscapedMeta(&b, "name", "twitter:image", meta.OGImage)
if meta.JSONLD != "" {
b.WriteString(`<script type="application/ld+json">`)
b.WriteString(meta.JSONLD)
b.WriteString(`</script>`)
}
b.WriteString(`<style>
body{font-family:system-ui,sans-serif;line-height:1.6;max-width:800px;margin:24px auto;padding:0 16px;color:#222}
a{color:#2d6a4f}img{max-width:100%;height:auto}
.meta{color:#666;font-size:14px;margin:8px 0 20px}
.nav{margin:32px 0;font-size:14px}
</style>`)
b.WriteString("</head><body>")
b.WriteString(bodyInner)
b.WriteString(`<p class="nav"><a href="/">← 返回首页</a></p>`)
b.WriteString("</body></html>")
return b.String()
}
func writeEscapedTag(b *strings.Builder, tag, text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
b.WriteString("<" + tag + ">" + html.EscapeString(text) + "</" + tag + ">")
}
func writeEscapedMeta(b *strings.Builder, attr, key, content string) {
content = strings.TrimSpace(content)
if content == "" {
return
}
b.WriteString("<meta " + attr + "=\"" + html.EscapeString(key) + "\" content=\"" + html.EscapeString(content) + "\"/>")
}
func (h *Handlers) botBoardHTML(meta *embed_static.SPAPageMeta, board model.Board) string {
desc := strings.TrimSpace(board.Description)
if desc == "" {
desc = meta.Description
}
body := fmt.Sprintf(`<h1>%s</h1><p class="meta">%s</p>`,
html.EscapeString(board.Name),
html.EscapeString(desc),
)
return renderBotHTML(meta, body)
}
func (h *Handlers) botHomeHTML(meta *embed_static.SPAPageMeta, brand service.SiteBranding) string {
name := strings.TrimSpace(brand.Name)
if name == "" {
name = "姜十三论坛"
}
intro := brand.MetaDescription()
if intro == "" {
intro = brand.Slogan
}
var body strings.Builder
body.WriteString("<h1>" + html.EscapeString(name) + "</h1>")
if intro != "" {
body.WriteString("<p>" + html.EscapeString(intro) + "</p>")
}
body.WriteString(`<p><a href="/projects">浏览项目</a></p>`)
return renderBotHTML(meta, body.String())
}
func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *model.Post) string {
meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
content := service.SanitizePostHTML(service.RedactGatedPostHTML(post.Content))
author := service.DisplayName(&post.User)
var body strings.Builder
body.WriteString("<article>")
body.WriteString("<h1>" + html.EscapeString(post.Title) + "</h1>")
body.WriteString(`<p class="meta">`)
body.WriteString(html.EscapeString(author))
body.WriteString(" · ")
body.WriteString(html.EscapeString(post.CreatedAt.Local().Format("2006-01-02 15:04")))
if post.Board.Name != "" {
body.WriteString(" · ")
body.WriteString(html.EscapeString(post.Board.Name))
}
body.WriteString("</p>")
body.WriteString(content)
body.WriteString("</article>")
return renderBotHTML(meta, body.String())
}
func (h *Handlers) botUserHTML(base, siteName, defaultImage, keywords string, user *model.User) string {
meta := attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, keywords)
name := service.DisplayName(user)
sig := strings.TrimSpace(user.Signature)
var body strings.Builder
body.WriteString("<h1>" + html.EscapeString(name) + " 的主页</h1>")
if sig != "" {
body.WriteString("<p>" + html.EscapeString(sig) + "</p>")
}
body.WriteString(fmt.Sprintf(`<p class="meta">加入于 %s</p>`, html.EscapeString(user.CreatedAt.Local().Format(time.DateOnly))))
return renderBotHTML(meta, body.String())
}
func botNotFoundHTML(base, siteName, keywords, path string) string {
meta := notFoundPageMeta(base, siteName, keywords, path)
body := `<h1>页面不存在</h1><p>您访问的页面不存在或已删除。</p>`
return renderBotHTML(meta, body)
}

1097
handler/ssr_home.go Normal file

File diff suppressed because it is too large Load Diff

361
handler/ssr_icons.go Normal file
View File

@@ -0,0 +1,361 @@
package handler
import (
"strconv"
"strings"
"time"
)
// 内联 SVG:path 对齐 lucide-react@1.18(由 scripts/extract-lucide-paths.mjs 抽取)
func ssrSVG(size int, paths ...string) string {
return ssrSVGWithClass(size, "", paths...)
}
func ssrSVGWithClass(size int, className string, paths ...string) string {
var b strings.Builder
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="`)
b.WriteString(strconv.Itoa(size))
b.WriteString(`" height="`)
b.WriteString(strconv.Itoa(size))
b.WriteString(`" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`)
if className != "" {
b.WriteString(` class="`)
b.WriteString(className)
b.WriteString(`"`)
}
b.WriteString(` aria-hidden="true">`)
for _, p := range paths {
b.WriteString(p)
}
b.WriteString(`</svg>`)
return b.String()
}
func ssrIconSearch() string {
return ssrSVGWithClass(16, "header-search-icon",
`<path d="m21 21-4.34-4.34"/>`,
`<circle cx="11" cy="11" r="8"/>`,
)
}
func ssrIconSliders() string {
return ssrSVG(15,
`<path d="M10 5H3"/>`,
`<path d="M12 19H3"/>`,
`<path d="M14 3v4"/>`,
`<path d="M16 17v4"/>`,
`<path d="M21 12h-9"/>`,
`<path d="M21 19h-5"/>`,
`<path d="M21 5h-7"/>`,
`<path d="M8 10v4"/>`,
`<path d="M8 12H3"/>`,
)
}
func ssrIconPlus() string {
return ssrSVG(16,
`<path d="M5 12h14"/>`,
`<path d="M12 5v14"/>`,
)
}
func ssrIconMenu() string {
return ssrSVG(18,
`<path d="M4 5h16"/>`,
`<path d="M4 12h16"/>`,
`<path d="M4 19h16"/>`,
)
}
func ssrIconMoon() string {
return ssrSVG(18,
`<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/>`,
)
}
func ssrIconSun() string {
return ssrSVG(18,
`<circle cx="12" cy="12" r="4"/>`,
`<path d="M12 2v2"/>`,
`<path d="M12 20v2"/>`,
`<path d="m4.93 4.93 1.41 1.41"/>`,
`<path d="m17.66 17.66 1.41 1.41"/>`,
`<path d="M2 12h2"/>`,
`<path d="M20 12h2"/>`,
`<path d="m6.34 17.66-1.41 1.41"/>`,
`<path d="m19.07 4.93-1.41 1.41"/>`,
)
}
func ssrIconMail() string {
return ssrSVG(18,
`<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/>`,
`<rect x="2" y="4" width="20" height="16" rx="2"/>`,
)
}
func ssrIconHome() string {
return ssrSVG(18,
`<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/>`,
`<path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>`,
)
}
func ssrIconStar() string {
return ssrSVG(18,
`<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>`,
)
}
func ssrIconFolderGit() string {
return ssrSVG(18,
`<path d="M18 19a5 5 0 0 1-5-5v8"/>`,
`<path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/>`,
`<circle cx="13" cy="12" r="2"/>`,
`<circle cx="20" cy="19" r="2"/>`,
)
}
func ssrIconLink2() string {
return ssrSVG(18,
`<path d="M9 17H7A5 5 0 0 1 7 7h2"/>`,
`<path d="M15 7h2a5 5 0 1 1 0 10h-2"/>`,
`<line x1="8" x2="16" y1="12" y2="12"/>`,
)
}
func ssrWidgetIconLink2() string {
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--links",
`<path d="M9 17H7A5 5 0 0 1 7 7h2"/>`,
`<path d="M15 7h2a5 5 0 1 1 0 10h-2"/>`,
`<line x1="8" x2="16" y1="12" y2="12"/>`,
)
}
func ssrIconEarth() string {
return ssrSVG(18,
`<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/>`,
`<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"/>`,
`<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/>`,
`<circle cx="12" cy="12" r="10"/>`,
)
}
func ssrWidgetIconEarth() string {
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--showcase",
`<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/>`,
`<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"/>`,
`<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/>`,
`<circle cx="12" cy="12" r="10"/>`,
)
}
func ssrIconFileText() string {
return ssrSVG(18,
`<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>`,
`<path d="M14 2v5a1 1 0 0 0 1 1h5"/>`,
`<path d="M10 9H8"/>`,
`<path d="M16 13H8"/>`,
`<path d="M16 17H8"/>`,
)
}
func ssrIconLayoutDashboard() string {
return ssrSVG(18,
`<rect width="7" height="9" x="3" y="3" rx="1"/>`,
`<rect width="7" height="5" x="14" y="3" rx="1"/>`,
`<rect width="7" height="9" x="14" y="12" rx="1"/>`,
`<rect width="7" height="5" x="3" y="16" rx="1"/>`,
)
}
func ssrIconMessageCircle() string {
return ssrSVG(16,
`<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>`,
)
}
func ssrWidgetIconMessageCircle() string {
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--notice",
`<path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/>`,
)
}
func ssrIconClock() string {
return ssrSVG(16,
`<circle cx="12" cy="12" r="10"/>`,
`<path d="M12 6v6l4 2"/>`,
)
}
func ssrIconBadgeCheck() string {
return ssrSVG(16,
`<path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/>`,
`<path d="m9 12 2 2 4-4"/>`,
)
}
func ssrIconTags() string {
return ssrSVG(16,
`<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z"/>`,
`<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193"/>`,
`<circle cx="10.5" cy="6.5" r=".5" fill="currentColor"/>`,
)
}
func ssrWidgetIconTags() string {
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--tags",
`<path d="M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z"/>`,
`<path d="M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193"/>`,
`<circle cx="10.5" cy="6.5" r=".5" fill="currentColor"/>`,
)
}
func ssrIconUserPlus() string {
return ssrSVG(16,
`<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>`,
`<circle cx="9" cy="7" r="4"/>`,
`<line x1="19" x2="19" y1="8" y2="14"/>`,
`<line x1="22" x2="16" y1="11" y2="11"/>`,
)
}
func ssrWidgetIconUserPlus() string {
return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--users",
`<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>`,
`<circle cx="9" cy="7" r="4"/>`,
`<line x1="19" x2="19" y1="8" y2="14"/>`,
`<line x1="22" x2="16" y1="11" y2="11"/>`,
)
}
func ssrIconCalendarCheck() string {
return ssrSVG(18,
`<path d="M8 2v4"/>`,
`<path d="M16 2v4"/>`,
`<rect width="18" height="18" x="3" y="4" rx="2"/>`,
`<path d="M3 10h18"/>`,
`<path d="m9 16 2 2 4-4"/>`,
)
}
func ssrIconCheck() string {
return ssrSVG(18,
`<path d="M20 6 9 17l-5-5"/>`,
)
}
func ssrIconGift() string {
return ssrSVG(15,
`<path d="M12 7v14"/>`,
`<path d="M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8"/>`,
`<path d="M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5"/>`,
`<rect x="3" y="7" width="18" height="4" rx="1"/>`,
)
}
func ssrIconPanelRight() string {
return ssrSVG(18,
`<rect width="18" height="18" x="3" y="3" rx="2"/>`,
`<path d="M15 3v18"/>`,
)
}
// 板块图标 path(key 对齐 BOARD_ICON_OPTIONS / AllowedBoardIcons)
var ssrBoardIconInner = map[string]string{
"code-2": `<path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/>`,
"coffee": `<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>`,
"help-circle": `<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>`,
"message-square": `<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/>`,
"lightbulb": `<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>`,
"book-open": `<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>`,
"gamepad-2": `<line x1="6" x2="10" y1="11" y2="11"/><line x1="8" x2="8" y1="9" y2="13"/><line x1="15" x2="15.01" y1="12" y2="12"/><line x1="18" x2="18.01" y1="10" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>`,
"palette": `<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/>`,
"music": `<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>`,
"camera": `<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>`,
"heart": `<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/>`,
"zap": `<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>`,
"globe": `<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>`,
"users": `<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>`,
"briefcase": `<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>`,
"graduation-cap": `<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z"/><path d="M22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/>`,
"shopping-bag": `<path d="M16 10a4 4 0 0 1-8 0"/><path d="M3.103 6.034h17.794"/><path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z"/>`,
"map-pin": `<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>`,
"megaphone": `<path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"/><path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14"/><path d="M8 6v8"/>`,
"flame": `<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"/>`,
"star": `<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>`,
"folder": `<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>`,
"wrench": `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>`,
"cpu": `<path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/>`,
}
// 与前端 DEFAULT_ICONS 顺序一致(按 themeIndex 回退)
var ssrBoardDefaultIcons = []string{
"code-2",
"coffee",
"help-circle",
"message-square",
"lightbulb",
"book-open",
"gamepad-2",
"palette",
}
// ssrBoardIconSVG 输出板块 Lucide 图标;class 打在 svg 上(与 React BoardIconDisplay 一致)
func ssrBoardIconSVG(icon string, themeIndex int, className string) string {
key := strings.TrimSpace(strings.ToLower(icon))
inner, ok := ssrBoardIconInner[key]
if !ok || inner == "" {
if themeIndex < 0 {
themeIndex = 0
}
key = ssrBoardDefaultIcons[themeIndex%len(ssrBoardDefaultIcons)]
inner = ssrBoardIconInner[key]
}
return ssrSVGWithClass(18, className, inner)
}
// formatSSRRelativeTime 与前端 formatTime 同规则
func formatSSRRelativeTime(t time.Time) string {
if t.IsZero() {
return ""
}
now := time.Now()
diffSec := now.Sub(t).Seconds()
if diffSec < 0 {
diffSec = 0
}
if diffSec < 60 {
return "刚刚"
}
if diffSec < 3600 {
return strconv.Itoa(int(diffSec/60)) + "分钟前"
}
if diffSec < 86400 {
return strconv.Itoa(int(diffSec/3600)) + "小时前"
}
diffDay := int(diffSec / 86400)
if diffDay < 30 {
return strconv.Itoa(diffDay) + "天前"
}
if t.Year() == now.Year() {
return strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
}
return strconv.Itoa(t.Year()) + "年" + strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
}
// formatSSRShortDateTime 与前端 formatShortDateTime 同规则(MM-DD HH:mm)
func formatSSRShortDateTime(t time.Time) string {
if t.IsZero() {
return ""
}
local := t.Local()
pad := func(n int) string {
if n < 10 {
return "0" + strconv.Itoa(n)
}
return strconv.Itoa(n)
}
return pad(int(local.Month())) + "-" + pad(local.Day()) + " " + pad(local.Hour()) + ":" + pad(local.Minute())
}

View File

@@ -115,7 +115,7 @@ type Post struct {
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
Featured bool `gorm:"default:false;index" json:"featured"` // 推荐帖(原精华)
EditLocked bool `gorm:"default:false" json:"edit_locked"`
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected

View File

@@ -57,6 +57,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}
return b
})
embed_static.SetSPAFaviconURL(func() string {
return settingsSvc.SiteBranding().Favicon
})
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := service.NewUserService(filter, settingsSvc)
boardSvc := service.NewBoardService()
@@ -114,9 +117,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 健康检查(容器 / 负载均衡探活)
r.GET("/health", h.APIHealth)
// SEO:抓取规则与站点地图
// SEO:抓取规则、站点地图、约定 favicon
r.GET("/robots.txt", h.RobotsTxt)
r.GET("/sitemap.xml", h.SitemapXML)
r.GET("/favicon.ico", h.FaviconICO)
// OIDC Provider(Gitea 等外部站点 SSO)
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
@@ -326,8 +330,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
})
})
} else {
r.GET("/", h.ServePublicSPA)
r.NoRoute(func(c *gin.Context) {
// 文档请求也解析登录 cookie,首页 SSR 才能输出头像/签到/收藏等完整壳
r.GET("/", authMW.OptionalAuth(), h.ServePublicSPA)
r.NoRoute(authMW.OptionalAuth(), func(c *gin.Context) {
if embed_static.IsSPARoute(c.Request.URL.Path) {
h.ServePublicSPA(c)
return

View File

@@ -40,7 +40,7 @@ var seoCrawlerTokens = []string{
"oai-searchbot",
}
// IsSEOCrawler 是否为需要服务端 HTML 的爬虫 / 预览 bot(动态渲染)
// IsSEOCrawler 是否为搜索引擎 / 社交预览类爬虫(供访问监控打 is_bot;公开页不再按 UA 分叉 HTML)
func IsSEOCrawler(userAgent string) bool {
ua := strings.ToLower(strings.TrimSpace(userAgent))
if ua == "" {

View File

@@ -27,79 +27,73 @@ type cnRegionMeta struct {
// cnRegionMap 中国省级英文名/别名 → 中文名 + 地图 ISO
var cnRegionMap = map[string]cnRegionMeta{
"beijing": {Name: "北京", ISO: "BJ"},
"peking": {Name: "北京", ISO: "BJ"},
"tianjin": {Name: "天津", ISO: "TJ"},
"shanghai": {Name: "上海", ISO: "SH"},
"chongqing": {Name: "重庆", ISO: "CQ"},
"chungking": {Name: "重庆", ISO: "CQ"},
"hebei": {Name: "河北", ISO: "HE"},
"shanxi": {Name: "山西", ISO: "SX"},
"liaoning": {Name: "辽宁", ISO: "LN"},
"jilin": {Name: "吉林", ISO: "JL"},
"heilongjiang": {Name: "黑龙江", ISO: "HL"},
"jiangsu": {Name: "江苏", ISO: "JS"},
"zhejiang": {Name: "浙江", ISO: "ZJ"},
"anhui": {Name: "安徽", ISO: "AH"},
"fujian": {Name: "福建", ISO: "FJ"},
"jiangxi": {Name: "江西", ISO: "JX"},
"shandong": {Name: "山东", ISO: "SD"},
"henan": {Name: "河南", ISO: "HA"},
"hubei": {Name: "湖北", ISO: "HB"},
"hunan": {Name: "湖南", ISO: "HN"},
"guangdong": {Name: "广东", ISO: "GD"},
"guangxi": {Name: "广西", ISO: "GX"},
"hainan": {Name: "海南", ISO: "HI"},
"sichuan": {Name: "四川", ISO: "SC"},
"guizhou": {Name: "贵州", ISO: "GZ"},
"yunnan": {Name: "云南", ISO: "YN"},
"xizang": {Name: "西藏", ISO: "XZ"},
"tibet": {Name: "西藏", ISO: "XZ"},
"shaanxi": {Name: "陕西", ISO: "SN"},
"beijing": {Name: "北京", ISO: "BJ"},
"peking": {Name: "北京", ISO: "BJ"},
"tianjin": {Name: "天津", ISO: "TJ"},
"shanghai": {Name: "上海", ISO: "SH"},
"chongqing": {Name: "重庆", ISO: "CQ"},
"chungking": {Name: "重庆", ISO: "CQ"},
"hebei": {Name: "河北", ISO: "HE"},
"shanxi": {Name: "山西", ISO: "SX"},
"liaoning": {Name: "辽宁", ISO: "LN"},
"jilin": {Name: "吉林", ISO: "JL"},
"heilongjiang": {Name: "黑龙江", ISO: "HL"},
"jiangsu": {Name: "江苏", ISO: "JS"},
"zhejiang": {Name: "浙江", ISO: "ZJ"},
"anhui": {Name: "安徽", ISO: "AH"},
"fujian": {Name: "福建", ISO: "FJ"},
"jiangxi": {Name: "江西", ISO: "JX"},
"shandong": {Name: "山东", ISO: "SD"},
"henan": {Name: "河南", ISO: "HA"},
"hubei": {Name: "湖北", ISO: "HB"},
"hunan": {Name: "湖南", ISO: "HN"},
"guangdong": {Name: "广东", ISO: "GD"},
"guangxi": {Name: "广西", ISO: "GX"},
"hainan": {Name: "海南", ISO: "HI"},
"sichuan": {Name: "四川", ISO: "SC"},
"guizhou": {Name: "贵州", ISO: "GZ"},
"yunnan": {Name: "云南", ISO: "YN"},
"xizang": {Name: "西藏", ISO: "XZ"},
"tibet": {Name: "西藏", ISO: "XZ"},
"shaanxi": {Name: "陕西", ISO: "SN"},
"shaanxi province": {Name: "陕西", ISO: "SN"},
"gansu": {Name: "甘肃", ISO: "GS"},
"qinghai": {Name: "青海", ISO: "QH"},
"ningxia": {Name: "宁夏", ISO: "NX"},
"xinjiang": {Name: "新疆", ISO: "XJ"},
"inner mongolia": {Name: "内蒙古", ISO: "NM"},
"nei mongol": {Name: "内蒙古", ISO: "NM"},
"hong kong": {Name: "香港", ISO: "HK"},
"macau": {Name: "澳门", ISO: "MO"},
"macao": {Name: "澳门", ISO: "MO"},
"taiwan": {Name: "台湾", ISO: "TW"},
"gansu": {Name: "甘肃", ISO: "GS"},
"qinghai": {Name: "青海", ISO: "QH"},
"ningxia": {Name: "宁夏", ISO: "NX"},
"xinjiang": {Name: "新疆", ISO: "XJ"},
"xinjiang uygur": {Name: "新疆", ISO: "XJ"},
"inner mongolia": {Name: "内蒙古", ISO: "NM"},
"nei mongol": {Name: "内蒙古", ISO: "NM"},
"guangxi zhuangzu": {Name: "广西", ISO: "GX"},
"ningxia huizu": {Name: "宁夏", ISO: "NX"},
"hong kong": {Name: "香港", ISO: "HK"},
"macau": {Name: "澳门", ISO: "MO"},
"macao": {Name: "澳门", ISO: "MO"},
"taiwan": {Name: "台湾", ISO: "TW"},
// 台湾各县市(IP2Location 常直接给县市名作为 region)
"changhua": {Name: "台湾", ISO: "TW"},
"chiayi": {Name: "台湾", ISO: "TW"},
"hsinchu": {Name: "台湾", ISO: "TW"},
"hualien": {Name: "台湾", ISO: "TW"},
"kaohsiung": {Name: "台湾", ISO: "TW"},
"keelung": {Name: "台湾", ISO: "TW"},
"kinmen": {Name: "台湾", ISO: "TW"},
"lienchiang": {Name: "台湾", ISO: "TW"},
"miaoli": {Name: "台湾", ISO: "TW"},
"nantou": {Name: "台湾", ISO: "TW"},
"new taipei": {Name: "台湾", ISO: "TW"},
"penghu": {Name: "台湾", ISO: "TW"},
"pingtung": {Name: "台湾", ISO: "TW"},
"taichung": {Name: "台湾", ISO: "TW"},
"tainan": {Name: "台湾", ISO: "TW"},
"taipei": {Name: "台湾", ISO: "TW"},
"taitung": {Name: "台湾", ISO: "TW"},
"taoyuan": {Name: "台湾", ISO: "TW"},
"yilan": {Name: "台湾", ISO: "TW"},
"yunlin": {Name: "台湾", ISO: "TW"},
}
// cityZh 常见城市英文名 → 中文
var cityZh = map[string]string{
"beijing": "北京", "peking": "北京",
"shanghai": "上海",
"chongqing": "重庆", "chungking": "重庆",
"tianjin": "天津",
"hangzhou": "杭州",
"shenzhen": "深圳",
"guangzhou": "广州", "canton": "广州",
"chengdu": "成都",
"wuhan": "武汉",
"xi'an": "西安", "xian": "西安",
"nanjing": "南京", "nanking": "南京",
"suzhou": "苏州",
"qingdao": "青岛",
"dalian": "大连",
"ningbo": "宁波",
"xiamen": "厦门", "amoy": "厦门",
"changsha": "长沙",
"zhengzhou": "郑州",
"jinan": "济南",
"harbin": "哈尔滨",
"shenyang": "沈阳",
"kunming": "昆明",
"nanning": "南宁",
"urumqi": "乌鲁木齐",
"lhasa": "拉萨",
"hohhot": "呼和浩特",
"taipei": "台北",
"hong kong": "香港",
}
// cityZh 定义见 geo_zh_cities.go(LITE 中国城市全表 + 同音复合键)
// asnOrgZh 常见 ASN / AS 组织名 → 中文运营商
var asnOrgZh = map[uint]string{
@@ -189,7 +183,7 @@ func mapCNRegionCity(g *GeoInfo) {
}
}
if g.City != "" {
if zh, ok := lookupCityZh(g.City); ok {
if zh, ok := lookupCityZh(g.RegionISO, g.Region, g.City); ok {
g.City = zh
}
}
@@ -214,7 +208,7 @@ func lookupCNRegion(name string) (cnRegionMeta, bool) {
return meta, true
}
// 去掉常见后缀再试
for _, suffix := range []string{" province", " sheng", " autonomous region", " municipality"} {
for _, suffix := range []string{" province", " sheng", " autonomous region", " municipality", " zhuangzu", " huizu", " uygur"} {
if strings.HasSuffix(key, suffix) {
if meta, ok := cnRegionMap[strings.TrimSuffix(key, suffix)]; ok {
return meta, true
@@ -224,14 +218,69 @@ func lookupCNRegion(name string) (cnRegionMeta, bool) {
return cnRegionMeta{}, false
}
func lookupCityZh(name string) (string, bool) {
key := strings.ToLower(strings.TrimSpace(name))
if zh, ok := cityZh[key]; ok {
return zh, true
// lookupCityZh 将 IP2Location 英文城市名映射为中文。
// 同音城市优先用「省ISO|城市」复合键;并尝试去掉 city/shi 后缀与撇号变体。
func lookupCityZh(regionISO, region, name string) (string, bool) {
key := normalizeCityKey(name)
if key == "" {
return "", false
}
iso := strings.ToLower(strings.TrimSpace(regionISO))
if iso == "" {
if meta, ok := lookupCNRegion(region); ok {
iso = strings.ToLower(meta.ISO)
}
}
candidates := cityKeyCandidates(key)
if iso != "" {
for _, c := range candidates {
if zh, ok := cityZh[iso+"|"+c]; ok {
return zh, true
}
}
}
for _, c := range candidates {
if zh, ok := cityZh[c]; ok {
return zh, true
}
}
return "", false
}
func normalizeCityKey(name string) string {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" || key == "-" {
return ""
}
return key
}
func cityKeyCandidates(key string) []string {
seen := map[string]struct{}{}
var out []string
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
if _, ok := seen[s]; ok {
return
}
seen[s] = struct{}{}
out = append(out, s)
}
add(key)
add(strings.ReplaceAll(key, "'", ""))
for _, suffix := range []string{" city", " shi"} {
if strings.HasSuffix(key, suffix) {
base := strings.TrimSuffix(key, suffix)
add(base)
add(strings.ReplaceAll(base, "'", ""))
}
}
return out
}
func mapASOrgZh(org string) string {
l := strings.ToLower(strings.TrimSpace(org))
if l == "" {

422
service/geo_zh_cities.go Normal file
View File

@@ -0,0 +1,422 @@
package service
// 由 IP2Location LITE-DB3 中国(含港澳台)city 采样生成。
// 键为小写英文城市名;同音城市使用「省ISO|城市」复合键(如 ah|suzhou)。
var cityZh = map[string]string{
"ah|suzhou": "宿州",
"aksu": "阿克苏",
"altay": "阿勒泰",
"amoy": "厦门",
"ankang": "安康",
"anqing": "安庆",
"anshan": "鞍山",
"anshun": "安顺",
"anyang": "安阳",
"baicheng": "白城",
"baise": "百色",
"baishan": "白山",
"baiyin": "白银",
"banqiao": "板桥",
"baoding": "保定",
"baoji": "宝鸡",
"baoshan": "保山",
"baotou": "包头",
"barkam": "马尔康",
"bayannur": "巴彦淖尔",
"bazhong": "巴中",
"beihai": "北海",
"beijing": "北京",
"beitun": "北屯",
"bengbu": "蚌埠",
"benxi": "本溪",
"bijie": "毕节",
"binzhou": "滨州",
"bole": "博乐",
"bozhou": "亳州",
"cangzhou": "沧州",
"canton": "广州",
"changchun": "长春",
"changde": "常德",
"changhua": "彰化",
"changji": "昌吉",
"changsha": "长沙",
"changzhi": "长治",
"changzhou": "常州",
"chaoyang": "朝阳",
"chaozhou": "潮州",
"chengde": "承德",
"chengdu": "成都",
"chenzhou": "郴州",
"chiayi": "嘉义",
"chifeng": "赤峰",
"chizhou": "池州",
"chongqing": "重庆",
"chongzuo": "崇左",
"chungking": "重庆",
"chuxiong": "楚雄",
"chuzhou": "滁州",
"dali": "大理",
"dalian": "大连",
"dandong": "丹东",
"danzhou": "儋州",
"daqing": "大庆",
"datong": "大同",
"dazhou": "达州",
"delingha": "德令哈",
"deyang": "德阳",
"dezhou": "德州",
"dingxi": "定西",
"dongguan": "东莞",
"dongying": "东营",
"douliu": "斗六",
"duyun": "都匀",
"emeishan": "峨眉山",
"enshi": "恩施",
"ezhou": "鄂州",
"fangchenggang": "防城港",
"fenyang": "汾阳",
"fj|fuzhou": "福州",
"foshan": "佛山",
"fuqing": "福清",
"fushun": "抚顺",
"fuxin": "阜新",
"fuyang": "阜阳",
"fuzhou": "福州",
"ganzhou": "赣州",
"gaobeidian": "高碑店",
"gongyi": "巩义",
"gs|longnan": "陇南",
"guang'an": "广安",
"guangan": "广安",
"guangyuan": "广元",
"guangzhou": "广州",
"guigang": "贵港",
"guilin": "桂林",
"guiyang": "贵阳",
"guyuan": "固原",
"gx|yulin": "玉林",
"haerbin": "哈尔滨",
"haidong": "海东",
"haikou": "海口",
"haining": "海宁",
"hami": "哈密",
"handan": "邯郸",
"hangzhou": "杭州",
"hanzhong": "汉中",
"harbin": "哈尔滨",
"hebi": "鹤壁",
"hechi": "河池",
"hefei": "合肥",
"hegang": "鹤岗",
"heihe": "黑河",
"hengchun": "恒春",
"hengshui": "衡水",
"hengyang": "衡阳",
"heyuan": "河源",
"heze": "菏泽",
"hezhou": "贺州",
"hl|yichun": "伊春",
"hohhot": "呼和浩特",
"hong kong": "香港",
"honghe": "红河",
"hotan": "和田",
"hsinchu": "新竹",
"huai'an": "淮安",
"huaian": "淮安",
"huaibei": "淮北",
"huaihua": "怀化",
"huainan": "淮南",
"hualien": "花莲",
"huanggang": "黄冈",
"huangshan": "黄山",
"huangshi": "黄石",
"huhehot": "呼和浩特",
"huizhou": "惠州",
"huludao": "葫芦岛",
"hulunbuir": "呼伦贝尔",
"huzhou": "湖州",
"ji'an": "吉安",
"jiamusi": "佳木斯",
"jian": "吉安",
"jiangmen": "江门",
"jiaozuo": "焦作",
"jiaxing": "嘉兴",
"jiayuguan": "嘉峪关",
"jieyang": "揭阳",
"jilin": "吉林",
"jinan": "济南",
"jinchang": "金昌",
"jincheng": "晋城",
"jingdezhen": "景德镇",
"jinghong": "景洪",
"jingmen": "荆门",
"jingzhou": "荆州",
"jinhua": "金华",
"jining": "济宁",
"jinzhong": "晋中",
"jinzhou": "锦州",
"jishou": "吉首",
"jiujiang": "九江",
"jiuquan": "酒泉",
"jixi": "鸡西",
"jiyuan": "济源",
"js|suzhou": "苏州",
"js|taizhou": "泰州",
"jx|fuzhou": "抚州",
"jx|longnan": "龙南",
"jx|yichun": "宜春",
"kaifeng": "开封",
"kaili": "凯里",
"kangding": "康定",
"kaohsiung": "高雄",
"karamay": "克拉玛依",
"kashgar": "喀什",
"keelung": "基隆",
"korla": "库尔勒",
"kuk liu": "谷寮",
"kunming": "昆明",
"kuytun": "奎屯",
"laibin": "来宾",
"laiyang": "莱阳",
"langfang": "廊坊",
"lanzhou": "兰州",
"leshan": "乐山",
"lhasa": "拉萨",
"lianyungang": "连云港",
"liaocheng": "聊城",
"liaoyang": "辽阳",
"liaoyuan": "辽源",
"lichuan": "利川",
"lijiang": "丽江",
"lincang": "临沧",
"linfen": "临汾",
"linqing": "临清",
"linxia": "临夏",
"linyi": "临沂",
"lishui": "丽水",
"liupanshui": "六盘水",
"liuzhou": "柳州",
"lo so shing": "芦须城",
"longnan": "陇南",
"longyan": "龙岩",
"loudi": "娄底",
"lu'an": "六安",
"luan": "六安",
"luohe": "漯河",
"luoyang": "洛阳",
"luzhou": "泸州",
"ma'anshan": "马鞍山",
"maanshan": "马鞍山",
"macao": "澳门",
"macau": "澳门",
"magong": "马公",
"mangshi": "芒市",
"maoming": "茂名",
"meishan": "眉山",
"meizhou": "梅州",
"mianyang": "绵阳",
"miaoli": "苗栗",
"mudanjiang": "牡丹江",
"nagqu": "那曲",
"nanchang": "南昌",
"nanchong": "南充",
"nangan": "南竿",
"nanjing": "南京",
"nanking": "南京",
"nanning": "南宁",
"nanping": "南平",
"nantong": "南通",
"nantou": "南投",
"nanyang": "南阳",
"neijiang": "内江",
"new taipei": "新北",
"ningbo": "宁波",
"ningde": "宁德",
"ordos": "鄂尔多斯",
"pan long wan": "蟠龙湾",
"panjin": "盘锦",
"panzhihua": "攀枝花",
"peking": "北京",
"pengzhou": "彭州",
"pingdingshan": "平顶山",
"pingliang": "平凉",
"pingtung": "屏东",
"pingxiang": "萍乡",
"pu'er": "普洱",
"puer": "普洱",
"puli": "埔里",
"puning": "普宁",
"putian": "莆田",
"puyang": "濮阳",
"qamdo": "昌都",
"qianjiang": "潜江",
"qingdao": "青岛",
"qingyang": "庆阳",
"qingyuan": "清远",
"qinhuangdao": "秦皇岛",
"qinzhou": "钦州",
"qionghai": "琼海",
"qiqihar": "齐齐哈尔",
"qitaihe": "七台河",
"quanzhou": "泉州",
"qujing": "曲靖",
"quzhou": "衢州",
"renqiu": "任丘",
"rizhao": "日照",
"sanmenxia": "三门峡",
"sanming": "三明",
"sanya": "三亚",
"shanghai": "上海",
"shangluo": "商洛",
"shangqiu": "商丘",
"shangrao": "上饶",
"shannan": "山南",
"shantou": "汕头",
"shanwei": "汕尾",
"shaoguan": "韶关",
"shaoxing": "绍兴",
"shaoyang": "邵阳",
"shenyang": "沈阳",
"shenzhen": "深圳",
"shihezi": "石河子",
"shijiazhuang": "石家庄",
"shiyan": "十堰",
"shizuishan": "石嘴山",
"shuangyashan": "双鸭山",
"shuozhou": "朔州",
"siping": "四平",
"sn|yulin": "榆林",
"songyuan": "松原",
"suihua": "绥化",
"suining": "遂宁",
"suizhou": "随州",
"suqian": "宿迁",
"suzhou": "苏州",
"sx|jincheng": "晋城",
"tacheng": "塔城",
"tai wong ha": "大王下",
"tai'an": "泰安",
"taian": "泰安",
"taibao": "太保",
"taichung": "台中",
"tainan": "台南",
"taipei": "台北",
"taitung": "台东",
"taiyuan": "太原",
"taizhou": "台州",
"tangshan": "唐山",
"taoyuan": "桃园",
"tianjin": "天津",
"tianmen": "天门",
"tianshui": "天水",
"tieling": "铁岭",
"to fung shan": "道风山",
"tongchuan": "铜川",
"tonghua": "通化",
"tongliao": "通辽",
"tongling": "铜陵",
"tongren": "铜仁",
"tsim sha tsui": "尖沙咀",
"turpan": "吐鲁番",
"tw|jincheng": "金城",
"ulanhot": "乌兰浩特",
"ulanqab": "乌兰察布",
"urumqi": "乌鲁木齐",
"wafangdian": "瓦房店",
"weifang": "潍坊",
"weihai": "威海",
"weinan": "渭南",
"wenchang": "文昌",
"wenling": "温岭",
"wenshan": "文山",
"wenzhou": "温州",
"wong fung leng": "黄峰岭",
"wuchuan": "吴川",
"wuhai": "乌海",
"wuhan": "武汉",
"wuhu": "芜湖",
"wulumuqi": "乌鲁木齐",
"wuwei": "武威",
"wuxi": "无锡",
"wuzhishan": "五指山",
"wuzhong": "吴忠",
"wuzhou": "梧州",
"xi'an": "西安",
"xiamen": "厦门",
"xian": "西安",
"xiangtan": "湘潭",
"xiangyang": "襄阳",
"xianning": "咸宁",
"xiantao": "仙桃",
"xianyang": "咸阳",
"xiaogan": "孝感",
"xichang": "西昌",
"xilinhot": "锡林浩特",
"xingtai": "邢台",
"xingyi": "兴义",
"xining": "西宁",
"xinxiang": "新乡",
"xinyang": "信阳",
"xinying": "新营",
"xinyu": "新余",
"xinzhou": "忻州",
"xuancheng": "宣城",
"xuchang": "许昌",
"xuzhou": "徐州",
"ya'an": "雅安",
"yaan": "雅安",
"yan'an": "延安",
"yanan": "延安",
"yancheng": "盐城",
"yangjiang": "阳江",
"yangquan": "阳泉",
"yangzhou": "扬州",
"yanji": "延吉",
"yantai": "烟台",
"yibin": "宜宾",
"yichang": "宜昌",
"yichun": "宜春",
"yilan": "宜兰",
"yinchuan": "银川",
"yingkou": "营口",
"yingtan": "鹰潭",
"yiwu": "义乌",
"yiyang": "益阳",
"yongzhou": "永州",
"yuanlin": "员林",
"yueyang": "岳阳",
"yujing": "玉井",
"yulin": "玉林",
"yuncheng": "运城",
"yunfu": "云浮",
"yunlin": "云林",
"yushu": "玉树",
"yuxi": "玉溪",
"yuyao": "余姚",
"zaozhuang": "枣庄",
"zhangjiajie": "张家界",
"zhangjiakou": "张家口",
"zhangshu": "樟树",
"zhangye": "张掖",
"zhangzhou": "漳州",
"zhanjiang": "湛江",
"zhaoqing": "肇庆",
"zhaotong": "昭通",
"zhengzhou": "郑州",
"zhenjiang": "镇江",
"zhongshan": "中山",
"zhongwei": "中卫",
"zhoukou": "周口",
"zhoushan": "舟山",
"zhuhai": "珠海",
"zhuji": "诸暨",
"zhumadian": "驻马店",
"zhuozhou": "涿州",
"zhuzhou": "株洲",
"zibo": "淄博",
"zigong": "自贡",
"ziyang": "资阳",
"zj|taizhou": "台州",
"zunhua": "遵化",
"zunyi": "遵义",
}

View File

@@ -0,0 +1,392 @@
package service
// 由 tmp_cities_only.tsv 生成:断言 LITE 采样到的 CN/HK/MO/TW 城市均可中文化。
var liteCNCities = []struct{ region, city string }{
{"Anhui", "Anqing"},
{"Anhui", "Bengbu"},
{"Anhui", "Bozhou"},
{"Anhui", "Chizhou"},
{"Anhui", "Chuzhou"},
{"Anhui", "Fuyang"},
{"Anhui", "Hefei"},
{"Anhui", "Huaibei"},
{"Anhui", "Huainan"},
{"Anhui", "Huangshan"},
{"Anhui", "Lu'an"},
{"Anhui", "Ma'anshan"},
{"Anhui", "Suzhou"},
{"Anhui", "Tongling"},
{"Anhui", "Wuhu"},
{"Anhui", "Xuancheng"},
{"Beijing", "Beijing"},
{"Changhua", "Changhua"},
{"Changhua", "Yuanlin"},
{"Chiayi", "Chiayi"},
{"Chiayi", "Taibao"},
{"Chongqing", "Chongqing"},
{"Fujian", "Fuqing"},
{"Fujian", "Fuzhou"},
{"Fujian", "Longyan"},
{"Fujian", "Nanping"},
{"Fujian", "Ningde"},
{"Fujian", "Putian"},
{"Fujian", "Quanzhou"},
{"Fujian", "Sanming"},
{"Fujian", "Xiamen"},
{"Fujian", "Zhangzhou"},
{"Gansu", "Baiyin"},
{"Gansu", "Dingxi"},
{"Gansu", "Jiayuguan"},
{"Gansu", "Jinchang"},
{"Gansu", "Jiuquan"},
{"Gansu", "Lanzhou"},
{"Gansu", "Linxia"},
{"Gansu", "Longnan"},
{"Gansu", "Pingliang"},
{"Gansu", "Qingyang"},
{"Gansu", "Tianshui"},
{"Gansu", "Wuwei"},
{"Gansu", "Zhangye"},
{"Guangdong", "Chaozhou"},
{"Guangdong", "Dongguan"},
{"Guangdong", "Foshan"},
{"Guangdong", "Guangzhou"},
{"Guangdong", "Heyuan"},
{"Guangdong", "Huizhou"},
{"Guangdong", "Jiangmen"},
{"Guangdong", "Jieyang"},
{"Guangdong", "Maoming"},
{"Guangdong", "Meizhou"},
{"Guangdong", "Puning"},
{"Guangdong", "Qingyuan"},
{"Guangdong", "Shantou"},
{"Guangdong", "Shanwei"},
{"Guangdong", "Shaoguan"},
{"Guangdong", "Shenzhen"},
{"Guangdong", "Wuchuan"},
{"Guangdong", "Yangjiang"},
{"Guangdong", "Yunfu"},
{"Guangdong", "Zhanjiang"},
{"Guangdong", "Zhaoqing"},
{"Guangdong", "Zhongshan"},
{"Guangdong", "Zhuhai"},
{"Guangxi Zhuangzu", "Baise"},
{"Guangxi Zhuangzu", "Beihai"},
{"Guangxi Zhuangzu", "Chongzuo"},
{"Guangxi Zhuangzu", "Fangchenggang"},
{"Guangxi Zhuangzu", "Guigang"},
{"Guangxi Zhuangzu", "Guilin"},
{"Guangxi Zhuangzu", "Hechi"},
{"Guangxi Zhuangzu", "Hezhou"},
{"Guangxi Zhuangzu", "Laibin"},
{"Guangxi Zhuangzu", "Liuzhou"},
{"Guangxi Zhuangzu", "Nanning"},
{"Guangxi Zhuangzu", "Qinzhou"},
{"Guangxi Zhuangzu", "Wuzhou"},
{"Guangxi Zhuangzu", "Yulin"},
{"Guizhou", "Anshun"},
{"Guizhou", "Bijie"},
{"Guizhou", "Duyun"},
{"Guizhou", "Guiyang"},
{"Guizhou", "Kaili"},
{"Guizhou", "Liupanshui"},
{"Guizhou", "Tongren"},
{"Guizhou", "Xingyi"},
{"Guizhou", "Zunyi"},
{"Hainan", "Danzhou"},
{"Hainan", "Haikou"},
{"Hainan", "Qionghai"},
{"Hainan", "Sanya"},
{"Hainan", "Wenchang"},
{"Hainan", "Wuzhishan"},
{"Hebei", "Baoding"},
{"Hebei", "Cangzhou"},
{"Hebei", "Chengde"},
{"Hebei", "Gaobeidian"},
{"Hebei", "Handan"},
{"Hebei", "Hengshui"},
{"Hebei", "Langfang"},
{"Hebei", "Qinhuangdao"},
{"Hebei", "Renqiu"},
{"Hebei", "Shijiazhuang"},
{"Hebei", "Tangshan"},
{"Hebei", "Xingtai"},
{"Hebei", "Zhangjiakou"},
{"Hebei", "Zhuozhou"},
{"Hebei", "Zunhua"},
{"Heilongjiang", "Daqing"},
{"Heilongjiang", "Harbin"},
{"Heilongjiang", "Hegang"},
{"Heilongjiang", "Heihe"},
{"Heilongjiang", "Jiamusi"},
{"Heilongjiang", "Jixi"},
{"Heilongjiang", "Mudanjiang"},
{"Heilongjiang", "Qiqihar"},
{"Heilongjiang", "Qitaihe"},
{"Heilongjiang", "Shuangyashan"},
{"Heilongjiang", "Suihua"},
{"Heilongjiang", "Yichun"},
{"Henan", "Anyang"},
{"Henan", "Gongyi"},
{"Henan", "Hebi"},
{"Henan", "Jiaozuo"},
{"Henan", "Jiyuan"},
{"Henan", "Kaifeng"},
{"Henan", "Luohe"},
{"Henan", "Luoyang"},
{"Henan", "Nanyang"},
{"Henan", "Pingdingshan"},
{"Henan", "Puyang"},
{"Henan", "Sanmenxia"},
{"Henan", "Shangqiu"},
{"Henan", "Xinxiang"},
{"Henan", "Xinyang"},
{"Henan", "Xuchang"},
{"Henan", "Zhengzhou"},
{"Henan", "Zhoukou"},
{"Henan", "Zhumadian"},
{"Hong Kong", "Hong Kong"},
{"Hong Kong", "Kuk Liu"},
{"Hong Kong", "Lo So Shing"},
{"Hong Kong", "Pan Long Wan"},
{"Hong Kong", "Tai Wong Ha"},
{"Hong Kong", "To Fung Shan"},
{"Hong Kong", "Tsim Sha Tsui"},
{"Hong Kong", "Wong Fung Leng"},
{"Hsinchu", "Hsinchu"},
{"Hualien", "Hualien"},
{"Hubei", "Enshi"},
{"Hubei", "Ezhou"},
{"Hubei", "Huanggang"},
{"Hubei", "Huangshi"},
{"Hubei", "Jingmen"},
{"Hubei", "Jingzhou"},
{"Hubei", "Lichuan"},
{"Hubei", "Qianjiang"},
{"Hubei", "Shiyan"},
{"Hubei", "Suizhou"},
{"Hubei", "Tianmen"},
{"Hubei", "Wuhan"},
{"Hubei", "Xiangyang"},
{"Hubei", "Xianning"},
{"Hubei", "Xiantao"},
{"Hubei", "Xiaogan"},
{"Hubei", "Yichang"},
{"Hunan", "Changde"},
{"Hunan", "Changsha"},
{"Hunan", "Chenzhou"},
{"Hunan", "Hengyang"},
{"Hunan", "Huaihua"},
{"Hunan", "Jishou"},
{"Hunan", "Loudi"},
{"Hunan", "Shaoyang"},
{"Hunan", "Xiangtan"},
{"Hunan", "Yiyang"},
{"Hunan", "Yongzhou"},
{"Hunan", "Yueyang"},
{"Hunan", "Zhangjiajie"},
{"Hunan", "Zhuzhou"},
{"Jiangsu", "Changzhou"},
{"Jiangsu", "Huai'an"},
{"Jiangsu", "Lianyungang"},
{"Jiangsu", "Nanjing"},
{"Jiangsu", "Nantong"},
{"Jiangsu", "Suqian"},
{"Jiangsu", "Suzhou"},
{"Jiangsu", "Taizhou"},
{"Jiangsu", "Wuxi"},
{"Jiangsu", "Xuzhou"},
{"Jiangsu", "Yancheng"},
{"Jiangsu", "Yangzhou"},
{"Jiangsu", "Zhenjiang"},
{"Jiangxi", "Fuzhou"},
{"Jiangxi", "Ganzhou"},
{"Jiangxi", "Ji'an"},
{"Jiangxi", "Jingdezhen"},
{"Jiangxi", "Jiujiang"},
{"Jiangxi", "Longnan"},
{"Jiangxi", "Nanchang"},
{"Jiangxi", "Pingxiang"},
{"Jiangxi", "Shangrao"},
{"Jiangxi", "Xinyu"},
{"Jiangxi", "Yichun"},
{"Jiangxi", "Yingtan"},
{"Jiangxi", "Zhangshu"},
{"Jilin", "Baicheng"},
{"Jilin", "Baishan"},
{"Jilin", "Changchun"},
{"Jilin", "Jilin"},
{"Jilin", "Liaoyuan"},
{"Jilin", "Siping"},
{"Jilin", "Songyuan"},
{"Jilin", "Tonghua"},
{"Jilin", "Yanji"},
{"Kaohsiung", "Kaohsiung"},
{"Keelung", "Keelung"},
{"Kinmen", "Jincheng"},
{"Liaoning", "Anshan"},
{"Liaoning", "Benxi"},
{"Liaoning", "Chaoyang"},
{"Liaoning", "Dalian"},
{"Liaoning", "Dandong"},
{"Liaoning", "Fushun"},
{"Liaoning", "Fuxin"},
{"Liaoning", "Huludao"},
{"Liaoning", "Jinzhou"},
{"Liaoning", "Liaoyang"},
{"Liaoning", "Panjin"},
{"Liaoning", "Shenyang"},
{"Liaoning", "Tieling"},
{"Liaoning", "Wafangdian"},
{"Liaoning", "Yingkou"},
{"Lienchiang", "Nangan"},
{"Macao", "Macau"},
{"Miaoli", "Miaoli"},
{"Nantou", "Nantou"},
{"Nantou", "Puli"},
{"Nei Mongol", "Baotou"},
{"Nei Mongol", "Bayannur"},
{"Nei Mongol", "Chifeng"},
{"Nei Mongol", "Hohhot"},
{"Nei Mongol", "Hulunbuir"},
{"Nei Mongol", "Ordos"},
{"Nei Mongol", "Tongliao"},
{"Nei Mongol", "Ulanhot"},
{"Nei Mongol", "Ulanqab"},
{"Nei Mongol", "Wuhai"},
{"Nei Mongol", "Xilinhot"},
{"New Taipei", "Banqiao"},
{"Ningxia Huizu", "Guyuan"},
{"Ningxia Huizu", "Shizuishan"},
{"Ningxia Huizu", "Wuzhong"},
{"Ningxia Huizu", "Yinchuan"},
{"Ningxia Huizu", "Zhongwei"},
{"Penghu", "Magong"},
{"Pingtung", "Hengchun"},
{"Pingtung", "Pingtung"},
{"Qinghai", "Delingha"},
{"Qinghai", "Haidong"},
{"Qinghai", "Xining"},
{"Qinghai", "Yushu"},
{"Shaanxi", "Ankang"},
{"Shaanxi", "Baoji"},
{"Shaanxi", "Hanzhong"},
{"Shaanxi", "Shangluo"},
{"Shaanxi", "Tongchuan"},
{"Shaanxi", "Weinan"},
{"Shaanxi", "Xi'an"},
{"Shaanxi", "Xianyang"},
{"Shaanxi", "Yan'an"},
{"Shaanxi", "Yulin"},
{"Shandong", "Binzhou"},
{"Shandong", "Dezhou"},
{"Shandong", "Dongying"},
{"Shandong", "Heze"},
{"Shandong", "Jinan"},
{"Shandong", "Jining"},
{"Shandong", "Laiyang"},
{"Shandong", "Liaocheng"},
{"Shandong", "Linqing"},
{"Shandong", "Linyi"},
{"Shandong", "Qingdao"},
{"Shandong", "Rizhao"},
{"Shandong", "Tai'an"},
{"Shandong", "Weifang"},
{"Shandong", "Weihai"},
{"Shandong", "Yantai"},
{"Shandong", "Zaozhuang"},
{"Shandong", "Zibo"},
{"Shanghai", "Shanghai"},
{"Shanxi", "Changzhi"},
{"Shanxi", "Datong"},
{"Shanxi", "Fenyang"},
{"Shanxi", "Jincheng"},
{"Shanxi", "Jinzhong"},
{"Shanxi", "Linfen"},
{"Shanxi", "Shuozhou"},
{"Shanxi", "Taiyuan"},
{"Shanxi", "Xinzhou"},
{"Shanxi", "Yangquan"},
{"Shanxi", "Yuncheng"},
{"Sichuan", "Barkam"},
{"Sichuan", "Bazhong"},
{"Sichuan", "Chengdu"},
{"Sichuan", "Dazhou"},
{"Sichuan", "Deyang"},
{"Sichuan", "Emeishan"},
{"Sichuan", "Guang'an"},
{"Sichuan", "Guangyuan"},
{"Sichuan", "Kangding"},
{"Sichuan", "Leshan"},
{"Sichuan", "Luzhou"},
{"Sichuan", "Meishan"},
{"Sichuan", "Mianyang"},
{"Sichuan", "Nanchong"},
{"Sichuan", "Neijiang"},
{"Sichuan", "Panzhihua"},
{"Sichuan", "Pengzhou"},
{"Sichuan", "Suining"},
{"Sichuan", "Xichang"},
{"Sichuan", "Ya'an"},
{"Sichuan", "Yibin"},
{"Sichuan", "Zigong"},
{"Sichuan", "Ziyang"},
{"Taichung", "Taichung"},
{"Tainan", "Tainan"},
{"Tainan", "Xinying"},
{"Tainan", "Yujing"},
{"Taipei", "Taipei"},
{"Taitung", "Taitung"},
{"Taoyuan", "Taoyuan"},
{"Tianjin", "Tianjin"},
{"Xinjiang Uygur", "Aksu"},
{"Xinjiang Uygur", "Altay"},
{"Xinjiang Uygur", "Beitun"},
{"Xinjiang Uygur", "Bole"},
{"Xinjiang Uygur", "Changji"},
{"Xinjiang Uygur", "Hami"},
{"Xinjiang Uygur", "Hotan"},
{"Xinjiang Uygur", "Karamay"},
{"Xinjiang Uygur", "Kashgar"},
{"Xinjiang Uygur", "Korla"},
{"Xinjiang Uygur", "Kuytun"},
{"Xinjiang Uygur", "Shihezi"},
{"Xinjiang Uygur", "Tacheng"},
{"Xinjiang Uygur", "Turpan"},
{"Xizang", "Lhasa"},
{"Xizang", "Nagqu"},
{"Xizang", "Qamdo"},
{"Xizang", "Shannan"},
{"Yilan", "Yilan"},
{"Yunlin", "Douliu"},
{"Yunnan", "Baoshan"},
{"Yunnan", "Chuxiong"},
{"Yunnan", "Dali"},
{"Yunnan", "Honghe"},
{"Yunnan", "Jinghong"},
{"Yunnan", "Kunming"},
{"Yunnan", "Lijiang"},
{"Yunnan", "Lincang"},
{"Yunnan", "Mangshi"},
{"Yunnan", "Pu'er"},
{"Yunnan", "Qujing"},
{"Yunnan", "Wenshan"},
{"Yunnan", "Yuxi"},
{"Yunnan", "Zhaotong"},
{"Zhejiang", "Haining"},
{"Zhejiang", "Hangzhou"},
{"Zhejiang", "Huzhou"},
{"Zhejiang", "Jiaxing"},
{"Zhejiang", "Jinhua"},
{"Zhejiang", "Lishui"},
{"Zhejiang", "Ningbo"},
{"Zhejiang", "Quzhou"},
{"Zhejiang", "Shaoxing"},
{"Zhejiang", "Taizhou"},
{"Zhejiang", "Wenling"},
{"Zhejiang", "Wenzhou"},
{"Zhejiang", "Yiwu"},
{"Zhejiang", "Yuyao"},
{"Zhejiang", "Zhoushan"},
{"Zhejiang", "Zhuji"},
}

140
service/geo_zh_test.go Normal file
View File

@@ -0,0 +1,140 @@
package service
import "testing"
func TestApplyGeoZh_Huainan(t *testing.T) {
g := &GeoInfo{Country: "CN", Region: "Anhui", City: "Huainan", ASN: 4134, ASOrg: "Chinanet"}
ApplyGeoZh(g)
if g.Region != "安徽" || g.RegionISO != "AH" {
t.Fatalf("region=%q iso=%q", g.Region, g.RegionISO)
}
if g.City != "淮南" {
t.Fatalf("city=%q want 淮南", g.City)
}
if g.ASOrg != "中国电信" {
t.Fatalf("as_org=%q", g.ASOrg)
}
}
func TestApplyGeoZh_AmbiguousSuzhou(t *testing.T) {
js := &GeoInfo{Country: "CN", Region: "Jiangsu", City: "Suzhou"}
ApplyGeoZh(js)
if js.City != "苏州" {
t.Fatalf("jiangsu suzhou=%q", js.City)
}
ah := &GeoInfo{Country: "CN", Region: "Anhui", City: "Suzhou"}
ApplyGeoZh(ah)
if ah.City != "宿州" {
t.Fatalf("anhui suzhou=%q", ah.City)
}
}
func TestApplyGeoZh_AmbiguousTaizhouFuzhouYichunYulin(t *testing.T) {
cases := []struct {
region, city, want string
}{
{"Jiangsu", "Taizhou", "泰州"},
{"Zhejiang", "Taizhou", "台州"},
{"Fujian", "Fuzhou", "福州"},
{"Jiangxi", "Fuzhou", "抚州"},
{"Heilongjiang", "Yichun", "伊春"},
{"Jiangxi", "Yichun", "宜春"},
{"Guangxi Zhuangzu", "Yulin", "玉林"},
{"Shaanxi", "Yulin", "榆林"},
{"Gansu", "Longnan", "陇南"},
{"Jiangxi", "Longnan", "龙南"},
{"Shanxi", "Jincheng", "晋城"},
{"Kinmen", "Jincheng", "金城"},
}
for _, c := range cases {
g := &GeoInfo{Country: "CN", Region: c.region, City: c.city}
if c.region == "Kinmen" {
g.Country = "TW"
}
ApplyGeoZh(g)
if g.City != c.want {
t.Fatalf("%s %s → %q want %q", c.region, c.city, g.City, c.want)
}
}
}
func TestApplyGeoZh_Aliases(t *testing.T) {
for _, city := range []string{"Xi'an", "Xian", "xi'an"} {
g := &GeoInfo{Country: "CN", Region: "Shaanxi", City: city}
ApplyGeoZh(g)
if g.City != "西安" {
t.Fatalf("%q → %q", city, g.City)
}
}
g := &GeoInfo{Country: "CN", Region: "Jiangsu", City: "Huai'an"}
ApplyGeoZh(g)
if g.City != "淮安" {
t.Fatalf("Huai'an → %q", g.City)
}
}
func TestApplyGeoZh_NonCNUnchanged(t *testing.T) {
g := &GeoInfo{Country: "US", Region: "California", City: "San Francisco"}
ApplyGeoZh(g)
if g.City != "San Francisco" || g.Region != "California" {
t.Fatalf("got region=%q city=%q", g.Region, g.City)
}
}
func TestApplyGeoZh_RegionAliases(t *testing.T) {
g := &GeoInfo{Country: "CN", Region: "Guangxi Zhuangzu", City: "Nanning"}
ApplyGeoZh(g)
if g.Region != "广西" || g.RegionISO != "GX" || g.City != "南宁" {
t.Fatalf("got %+v", g)
}
g2 := &GeoInfo{Country: "CN", Region: "Xinjiang Uygur", City: "Kashgar"}
ApplyGeoZh(g2)
if g2.Region != "新疆" || g2.City != "喀什" {
t.Fatalf("got %+v", g2)
}
}
func TestApplyGeoZh_CitySuffix(t *testing.T) {
g := &GeoInfo{Country: "CN", Region: "Anhui", City: "Huainan City"}
ApplyGeoZh(g)
if g.City != "淮南" {
t.Fatalf("city=%q", g.City)
}
}
func TestLookupCityZh_FallbackWithoutRegion(t *testing.T) {
zh, ok := lookupCityZh("", "", "Suzhou")
if !ok || zh != "苏州" {
t.Fatalf("fallback suzhou=%q ok=%v", zh, ok)
}
}
func TestApplyGeoZh_LiteCNCitiesCoverage(t *testing.T) {
var missing []string
for _, row := range liteCNCities {
country := "CN"
rk := row.region
switch rk {
case "Hong Kong":
country = "HK"
case "Macao":
country = "MO"
case "Changhua", "Chiayi", "Hsinchu", "Hualien", "Kaohsiung", "Keelung",
"Kinmen", "Lienchiang", "Miaoli", "Nantou", "New Taipei", "Penghu",
"Pingtung", "Taichung", "Tainan", "Taipei", "Taitung", "Taoyuan",
"Yilan", "Yunlin":
country = "TW"
}
g := &GeoInfo{Country: country, Region: row.region, City: row.city}
ApplyGeoZh(g)
if g.City == "" || g.City == row.city {
// 仍为英文原文则未映射(中文不应等于英文,除非本身就是纯 ASCII 且已是目标——LITE 全是拼音)
if g.City == row.city {
missing = append(missing, row.region+"/"+row.city+"→"+g.City)
}
}
}
if len(missing) > 0 {
t.Fatalf("未中文化 %d 条,示例: %v", len(missing), missing[:min(20, len(missing))])
}
}

View File

@@ -47,7 +47,7 @@ type PostListQuery struct {
Tag string // 精确标签筛选(整枚匹配,不走 keyword LIKE)
Author string // 作者用户名或昵称(解析为 UserID)
TitleOnly bool // 关键词仅匹配标题
Sort string // latest | reply | hot
Sort string // reply | latest | hot(hot=仅推荐帖)
ViewerID uint // 当前查看者(用于 pending 仅作者可见)
ViewerIsAdmin bool
Status string // 管理端筛选:pending|published|rejected|all;空则按可见性规则
@@ -343,6 +343,11 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
normalized := "LOWER(',' || REPLACE(REPLACE(REPLACE(IFNULL(tags,''), ',', ','), ', ', ','), ' ,', ',') || ',')"
db = db.Where(normalized+" LIKE ? ESCAPE '\\'", "%,"+escaped+",%")
}
sortKey := normalizePostSort(q.Sort)
if sortKey == "hot" {
// 推荐帖:只展示人工推荐
db = db.Where("featured = ?", true)
}
var total int64
db.Count(&total)
var posts []model.Post
@@ -350,7 +355,7 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
if q.BoardID > 0 {
db = db.Order("board_pinned desc")
}
switch normalizePostSort(q.Sort) {
switch sortKey {
case "reply":
// 有回复的帖子优先,按最后回复时间倒序;无回复的帖子沉底(仅计已公开评论)
db = db.Order(`(
@@ -365,6 +370,7 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
) DESC`)
db = db.Order("posts.created_at DESC")
case "hot":
// 仅推荐帖:按互动再按 id
db = db.Order("like_count desc, view_count desc")
default:
db = db.Order("id desc")
@@ -375,10 +381,11 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
func normalizePostSort(sort string) string {
switch sort {
case "reply", "hot":
case "latest", "hot":
return sort
default:
return "latest"
// 含空串 / reply:默认新评论
return "reply"
}
}

View File

@@ -50,6 +50,7 @@ const (
SettingNavShowShowcase = "nav_show_showcase"
SettingFooterShowShowcase = "footer_show_showcase"
SettingFeedListStyle = "feed_list_style"
SettingFeedSortTabs = "feed_sort_tabs"
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
@@ -158,7 +159,8 @@ type ForumLimits struct {
NavShowShowcase bool `json:"nav_show_showcase"`
FooterShowShowcase bool `json:"footer_show_showcase"`
FeedListStyle string `json:"feed_list_style"`
FeedListStyle string `json:"feed_list_style"`
FeedSortTabs []FeedSortTab `json:"feed_sort_tabs"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
@@ -170,12 +172,23 @@ type AsideWidget struct {
Enabled bool `json:"enabled"`
}
// FeedSortTab 首页 Feed 排序标签(名称 / 顺序 / 启停)
type FeedSortTab struct {
ID string `json:"id"` // reply | latest | hot
Label string `json:"label"` // 展示名
Enabled bool `json:"enabled"`
}
const (
AsideWidgetTagCloud = "tag_cloud"
AsideWidgetRecentComments = "recent_comments"
AsideWidgetRecentUsers = "recent_users"
AsideWidgetFriendLinks = "friend_links"
AsideWidgetShowcase = "showcase"
FeedSortReply = "reply"
FeedSortLatest = "latest"
FeedSortHot = "hot" // 仅推荐帖(featured)
)
var asideWidgetDefaultOrder = []string{
@@ -186,6 +199,18 @@ var asideWidgetDefaultOrder = []string{
AsideWidgetShowcase,
}
var feedSortTabDefaultOrder = []string{
FeedSortReply,
FeedSortLatest,
FeedSortHot,
}
var feedSortTabDefaultLabels = map[string]string{
FeedSortReply: "新评论",
FeedSortLatest: "新帖子",
FeedSortHot: "推荐帖",
}
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
type ForumLimitsPublic struct {
PostTitleMax int `json:"post_title_max"`
@@ -214,7 +239,8 @@ type ForumLimitsPublic struct {
NavShowShowcase bool `json:"nav_show_showcase"`
FooterShowShowcase bool `json:"footer_show_showcase"`
FeedListStyle string `json:"feed_list_style"`
FeedListStyle string `json:"feed_list_style"`
FeedSortTabs []FeedSortTab `json:"feed_sort_tabs"`
PermalinkEnabled bool `json:"permalink_enabled"`
PermalinkExt string `json:"permalink_ext"`
@@ -261,6 +287,7 @@ var forumSettingDefs = []settingDef{
var feedSettingDefaults = map[string]string{
SettingFeedListStyle: "title",
SettingFeedSortTabs: `[{"id":"reply","label":"新评论","enabled":true},{"id":"latest","label":"新帖子","enabled":true},{"id":"hot","label":"推荐帖","enabled":true}]`,
}
var asideSettingDefaults = map[string]string{
@@ -656,6 +683,7 @@ func (s *ForumSettingsService) Limits() ForumLimits {
FooterShowShowcase: s.FooterShowShowcase(),
FeedListStyle: s.FeedListStyle(),
FeedSortTabs: s.FeedSortTabs(),
PermalinkEnabled: permalink.Enabled,
PermalinkExt: permalink.Ext,
@@ -692,6 +720,7 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
FooterShowShowcase: limits.FooterShowShowcase,
FeedListStyle: limits.FeedListStyle,
FeedSortTabs: limits.FeedSortTabs,
PermalinkEnabled: limits.PermalinkEnabled,
PermalinkExt: limits.PermalinkExt,
@@ -776,6 +805,14 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
if err := s.setString(SettingFeedListStyle, style); err != nil {
return err
}
tabs := NormalizeFeedSortTabs(in.FeedSortTabs)
tabsJSON, err := json.Marshal(tabs)
if err != nil {
return err
}
if err := s.setString(SettingFeedSortTabs, string(tabsJSON)); err != nil {
return err
}
return nil
}
@@ -1064,6 +1101,76 @@ func (s *ForumSettingsService) FeedListStyle() string {
return v
}
func isValidFeedSortTabID(id string) bool {
switch id {
case FeedSortReply, FeedSortLatest, FeedSortHot:
return true
default:
return false
}
}
// NormalizeFeedSortTabs 校验并补全 Feed 排序标签(顺序保留,至少一项启用)
func NormalizeFeedSortTabs(in []FeedSortTab) []FeedSortTab {
seen := make(map[string]bool, len(feedSortTabDefaultOrder))
out := make([]FeedSortTab, 0, len(feedSortTabDefaultOrder))
for _, t := range in {
id := strings.TrimSpace(t.ID)
if !isValidFeedSortTabID(id) || seen[id] {
continue
}
seen[id] = true
label := strings.TrimSpace(t.Label)
if label == "" {
label = feedSortTabDefaultLabels[id]
}
out = append(out, FeedSortTab{ID: id, Label: label, Enabled: t.Enabled})
}
for _, id := range feedSortTabDefaultOrder {
if seen[id] {
continue
}
out = append(out, FeedSortTab{
ID: id,
Label: feedSortTabDefaultLabels[id],
Enabled: true,
})
}
hasEnabled := false
for _, t := range out {
if t.Enabled {
hasEnabled = true
break
}
}
if !hasEnabled && len(out) > 0 {
out[0].Enabled = true
}
return out
}
func (s *ForumSettingsService) FeedSortTabs() []FeedSortTab {
raw := strings.TrimSpace(s.getString(SettingFeedSortTabs, ""))
if raw == "" {
raw = feedSettingDefaults[SettingFeedSortTabs]
}
var tabs []FeedSortTab
if raw != "" {
_ = json.Unmarshal([]byte(raw), &tabs)
}
return NormalizeFeedSortTabs(tabs)
}
// DefaultFeedSort 返回配置中第一个启用的排序键
func (s *ForumSettingsService) DefaultFeedSort() string {
for _, t := range s.FeedSortTabs() {
if t.Enabled {
return t.ID
}
}
return FeedSortReply
}
// MailConfig 读取 SMTP 配置(密码不回显明文)
func (s *ForumSettingsService) MailConfig() MailConfig {
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))