Compare commits
16 Commits
c6ce44f8b4
...
v1.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 94823c0821 | |||
| b608ec85ba | |||
| 6048d71759 | |||
| 37d14a22d5 | |||
| a02d103cfe | |||
| 21c6233fc0 | |||
| 4ca5c17e2e | |||
| cf7a37eccc | |||
| 44eed97fe0 | |||
| 9e134916a4 | |||
| ba60a9b1c4 | |||
| 132ca5c82c | |||
| 185649db7c | |||
| 9d926750b2 | |||
| a91d917fa9 | |||
| 34f3d088ad |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 写入主库
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
31
frontend/package-lock.json
generated
31
frontend/package-lock.json
generated
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
60
frontend/scripts/extract-lucide-paths.mjs
Normal file
60
frontend/scripts/extract-lucide-paths.mjs
Normal 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');
|
||||
168
frontend/scripts/gen-ssr-icons.mjs
Normal file
168
frontend/scripts/gen-ssr-icons.mjs
Normal 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');
|
||||
@@ -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>
|
||||
|
||||
@@ -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(与后台监控采集开关同步) */
|
||||
|
||||
@@ -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} 积分`)
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@ type PageLoaderProps = {
|
||||
fullScreen?: boolean;
|
||||
};
|
||||
|
||||
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */
|
||||
/** 通用路由懒加载占位(后台 / 全屏独立页);前台 MainLayout 用顶栏进度条 + 空白 */
|
||||
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
227
frontend/src/components/StaticFeedList.tsx
Normal file
227
frontend/src/components/StaticFeedList.tsx
Normal 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(/ /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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
75
frontend/src/components/TopProgressBar.tsx
Normal file
75
frontend/src/components/TopProgressBar.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
89
frontend/src/components/admin/FeedSortTabList.tsx
Normal file
89
frontend/src/components/admin/FeedSortTabList.tsx
Normal 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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
144
frontend/src/hooks/useSessionResource.ts
Normal file
144
frontend/src/hooks/useSessionResource.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 : '修改失败');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? '取消全局置顶' : '全局置顶'}
|
||||
|
||||
@@ -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">
|
||||
|
||||
106
frontend/src/store/homeStore.ts
Normal file
106
frontend/src/store/homeStore.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
43
frontend/src/utils/authBoot.ts
Normal file
43
frontend/src/utils/authBoot.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
41
frontend/src/utils/feedSortTabs.ts
Normal file
41
frontend/src/utils/feedSortTabs.ts
Normal 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';
|
||||
}
|
||||
110
frontend/src/utils/homeBoot.ts
Normal file
110
frontend/src/utils/homeBoot.ts
Normal 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;
|
||||
}
|
||||
41
frontend/src/utils/homeHydrate.ts
Normal file
41
frontend/src/utils/homeHydrate.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
447
frontend/src/utils/prefetchRoute.ts
Normal file
447
frontend/src/utils/prefetchRoute.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
frontend/src/utils/sessionPageCache.ts
Normal file
45
frontend/src/utils/sessionPageCache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
frontend/src/utils/softRefresh.ts
Normal file
38
frontend/src/utils/softRefresh.ts
Normal 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));
|
||||
}
|
||||
113
frontend/src/utils/spaTransition.ts
Normal file
113
frontend/src/utils/spaTransition.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
1097
handler/ssr_home.go
Normal file
File diff suppressed because it is too large
Load Diff
361
handler/ssr_icons.go
Normal file
361
handler/ssr_icons.go
Normal 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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
422
service/geo_zh_cities.go
Normal 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": "遵义",
|
||||
}
|
||||
392
service/geo_zh_lite_cities_test.go
Normal file
392
service/geo_zh_lite_cities_test.go
Normal 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
140
service/geo_zh_test.go
Normal 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))])
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user