- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined: 抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫 - 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到 各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记 - 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false, useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出, head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块) - 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、 探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image - Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、 自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生 历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView + scroll-margin-top 适配 sticky 顶栏
133 lines
3.0 KiB
Go
133 lines
3.0 KiB
Go
package service
|
||
|
||
import (
|
||
"html"
|
||
"net/url"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"git.iioio.com/freefire/jiang13-forum/model"
|
||
)
|
||
|
||
var (
|
||
imgSrcRe = regexp.MustCompile(`(?i)<img[^>]+src=["']([^"']+)["']`)
|
||
)
|
||
|
||
// SitemapURL 站点地图条目
|
||
type SitemapURL struct {
|
||
Loc string
|
||
LastMod time.Time
|
||
ChangeFreq string
|
||
Priority string
|
||
}
|
||
|
||
// SitePublicBaseURL 公开站点根地址(无尾斜杠)
|
||
// 优先管理后台 OIDC 中的 ROOT_URL,否则根据请求 Host 推断
|
||
func (s *ForumSettingsService) SitePublicBaseURL(requestOrigin string) string {
|
||
root := normalizeRootURL(s.getString(SettingOIDCRootURL, ""))
|
||
if root == "" {
|
||
root = normalizeRootURL(requestOrigin)
|
||
}
|
||
return strings.TrimRight(root, "/")
|
||
}
|
||
|
||
// AbsoluteURL 将相对路径拼成绝对 URL。
|
||
// base 为空或非 http(s) 时返回空串,避免邮件等场景出现无法点击的相对路径。
|
||
func AbsoluteURL(base, pathOrURL string) string {
|
||
pathOrURL = strings.TrimSpace(pathOrURL)
|
||
if pathOrURL == "" {
|
||
return ""
|
||
}
|
||
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
|
||
return pathOrURL
|
||
}
|
||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||
if base == "" || (!strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://")) {
|
||
return ""
|
||
}
|
||
if !strings.HasPrefix(pathOrURL, "/") {
|
||
pathOrURL = "/" + pathOrURL
|
||
}
|
||
return base + pathOrURL
|
||
}
|
||
|
||
// TruncateRunes 按 rune 截断并加省略号
|
||
func TruncateRunes(s string, max int) string {
|
||
s = strings.TrimSpace(s)
|
||
if max <= 0 || s == "" {
|
||
return s
|
||
}
|
||
if utf8.RuneCountInString(s) <= max {
|
||
return s
|
||
}
|
||
runes := []rune(s)
|
||
if max < 2 {
|
||
return string(runes[:max])
|
||
}
|
||
return string(runes[:max-1]) + "…"
|
||
}
|
||
|
||
// ExcerptFromHTML 从 HTML 生成摘要(剥离标签、解码实体)
|
||
func ExcerptFromHTML(htmlContent string, maxRunes int) string {
|
||
plain := StripHTMLForSearch(htmlContent)
|
||
plain = html.UnescapeString(plain)
|
||
return TruncateRunes(plain, maxRunes)
|
||
}
|
||
|
||
// FirstImageURL 提取正文中第一张图片的 src
|
||
func FirstImageURL(htmlContent string) string {
|
||
m := imgSrcRe.FindStringSubmatch(htmlContent)
|
||
if len(m) < 2 {
|
||
return ""
|
||
}
|
||
src := strings.TrimSpace(m[1])
|
||
// 忽略 data: 内联图
|
||
if strings.HasPrefix(src, "data:") {
|
||
return ""
|
||
}
|
||
return src
|
||
}
|
||
|
||
// DisplayName 用户展示名
|
||
func DisplayName(u *model.User) string {
|
||
if u == nil {
|
||
return ""
|
||
}
|
||
if n := strings.TrimSpace(u.Nickname); n != "" {
|
||
return n
|
||
}
|
||
return strings.TrimSpace(u.Username)
|
||
}
|
||
|
||
// QueryBoardHome 板块首页相对路径(规范伪静态路径)
|
||
func QueryBoardHome(boardID uint, p PermalinkConfig) string {
|
||
if boardID == 0 {
|
||
return "/"
|
||
}
|
||
return p.BoardPath(boardID)
|
||
}
|
||
|
||
// LegacyQueryBoardHome 旧版 query 形式(/?board=id),仅用于 301 重定向
|
||
func LegacyQueryBoardHome(boardID uint) string {
|
||
if boardID == 0 {
|
||
return "/"
|
||
}
|
||
return "/?board=" + url.QueryEscape(itoaUint(boardID))
|
||
}
|
||
|
||
func itoaUint(n uint) string {
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
var buf [20]byte
|
||
i := len(buf)
|
||
for n > 0 {
|
||
i--
|
||
buf[i] = byte('0' + n%10)
|
||
n /= 10
|
||
}
|
||
return string(buf[i:])
|
||
}
|