feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动

补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 07:07:38 +08:00
parent 9e134916a4
commit 44eed97fe0
26 changed files with 2530 additions and 159 deletions

View File

@@ -5,12 +5,16 @@ import (
"encoding/json" "encoding/json"
"html" "html"
"net/http" "net/http"
"regexp"
"strings" "strings"
"github.com/gin-gonic/gin" "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 { type SPAPageMeta struct {
Title string // 完整 <title> Title string // 完整 <title>
Description string Description string
@@ -23,6 +27,8 @@ type SPAPageMeta struct {
Robots string // 如 noindex,nofollow Robots string // 如 noindex,nofollow
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签) JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
Status int // HTTP 状态码0 视为 200 Status int // HTTP 状态码0 视为 200
RootHTML string // 可选:写入 #root 的首屏 HTML首页文档 SSR
BootJSON []byte // 可选window.__J13_HOME_BOOT__ 合法 JSON
} }
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口 // ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
@@ -56,15 +62,16 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>")) data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
} }
var head strings.Builder // —— 静态 SEO HTMLmeta / OG / JSON-LD紧跟 </title>,不经 JS ——
writeMeta(&head, "description", meta.Description) var seo strings.Builder
writeMeta(&head, "keywords", meta.Keywords) writeMeta(&seo, "description", meta.Description)
writeMeta(&seo, "keywords", meta.Keywords)
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" { 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) + `"/>`)
} }
robots := strings.TrimSpace(meta.Robots) robots := strings.TrimSpace(meta.Robots)
if robots != "" { if robots != "" {
writeMeta(&head, "robots", robots) writeMeta(&seo, "robots", robots)
} }
ogType := strings.TrimSpace(meta.OGType) ogType := strings.TrimSpace(meta.OGType)
@@ -75,36 +82,63 @@ func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
if locale == "" { if locale == "" {
locale = "zh_CN" locale = "zh_CN"
} }
writeProp(&head, "og:type", ogType) writeProp(&seo, "og:type", ogType)
writeProp(&head, "og:site_name", meta.SiteName) writeProp(&seo, "og:site_name", meta.SiteName)
writeProp(&head, "og:locale", locale) writeProp(&seo, "og:locale", locale)
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title)) writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title))
writeProp(&head, "og:description", meta.Description) writeProp(&seo, "og:description", meta.Description)
writeProp(&head, "og:url", meta.Canonical) writeProp(&seo, "og:url", meta.Canonical)
writeProp(&head, "og:image", meta.OGImage) writeProp(&seo, "og:image", meta.OGImage)
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage)) writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage))
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title)) writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title))
writeMetaName(&head, "twitter:description", meta.Description) writeMetaName(&seo, "twitter:description", meta.Description)
writeMetaName(&head, "twitter:image", meta.OGImage) writeMetaName(&seo, "twitter:image", meta.OGImage)
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" { if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
head.WriteString(`<script type="application/ld+json">`) // 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入
head.WriteString(jsonld) seo.WriteString(`<script type="application/ld+json">`)
head.WriteString(`</script>`) seo.WriteString(string(bytes.ReplaceAll([]byte(jsonld), []byte("<"), []byte(`\u003c`))))
seo.WriteString(`</script>`)
} }
// 同步注入品牌配置,避免 React 首屏用默认名闪一下 if seo.Len() > 0 {
if boot := spaBrandingBootScript(); boot != "" { data = bytes.Replace(data, []byte("</title>"), []byte("</title>\n"+seo.String()), 1)
head.WriteString(boot)
} }
if head.Len() > 0 { // —— 可执行 boot 脚本仍放在 </head> 前 ——
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1) 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 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__=...; 内联脚本 // spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
func spaBrandingBootScript() string { func spaBrandingBootScript() string {
if spaBrandJSONFn == nil { if spaBrandJSONFn == nil {
@@ -114,7 +148,6 @@ func spaBrandingBootScript() string {
if len(raw) == 0 || !json.Valid(raw) { if len(raw) == 0 || !json.Valid(raw) {
return "" return ""
} }
// 防止 JSON 字符串中的 </script> 提前闭合标签
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`)) safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>" return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
} }

View File

@@ -5,23 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="description" content="拾三一隅,自在交流" /> <meta name="description" content="拾三一隅,自在交流" />
<title>姜十三论坛 - 拾三一隅,自在交流</title> <title>姜十三论坛 - 拾三一隅,自在交流</title>
<style> <!-- 样式由 Vite 构建注入外链 CSS不在此内联 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>
<script> <script>
(function () { (function () {
var theme = localStorage.getItem('j13-theme') || 'light'; var theme = localStorage.getItem('j13-theme') || 'light';

View File

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

View File

@@ -0,0 +1,168 @@
import fs from 'fs';
import path from 'path';
const paths = JSON.parse(fs.readFileSync('tmp-lucide-paths.json', 'utf8'));
function splitInner(inner) {
const tags = [];
const re = /<([a-z]+)([^>]*)\/>/g;
let m;
while ((m = re.exec(inner))) {
tags.push(`<${m[1]}${m[2]}/>`);
}
return tags;
}
function goFunc(name, size, key, className = '') {
const tags = splitInner(paths[key]);
const lines = tags.map((t) => `\t\t\`${t}\`,`).join('\n');
if (className) {
return `func ${name}() string {\n\treturn ssrSVGWithClass(${size}, "${className}",\n${lines}\n\t)\n}\n\n`;
}
return `func ${name}() string {\n\treturn ssrSVG(${size},\n${lines}\n\t)\n}\n\n`;
}
const boardKeys = [
'code-2', 'coffee', 'help-circle', 'message-square', 'lightbulb', 'book-open',
'gamepad-2', 'palette', 'music', 'camera', 'heart', 'zap', 'globe', 'users',
'briefcase', 'graduation-cap', 'shopping-bag', 'map-pin', 'megaphone', 'flame',
'star', 'folder', 'wrench', 'cpu',
];
const defaults = [
'code-2', 'coffee', 'help-circle', 'message-square',
'lightbulb', 'book-open', 'gamepad-2', 'palette',
];
let out = `package handler
import (
\t"strconv"
\t"strings"
\t"time"
)
// 内联 SVGpath 对齐 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 += '// 板块图标 pathkey 对齐 BOARD_ICON_OPTIONS / AllowedBoardIcons\nvar ssrBoardIconInner = map[string]string{\n';
for (const k of boardKeys) {
out += `\t"${k}": \`${paths[k]}\`,\n`;
}
out += '}\n\n';
out += '// 与前端 DEFAULT_ICONS 顺序一致(按 themeIndex 回退)\nvar ssrBoardDefaultIcons = []string{\n';
for (const k of defaults) {
out += `\t"${k}",\n`;
}
out += '}\n\n';
out += `// ssrBoardIconSVG 输出板块 Lucide 图标class 打在 svg 上(与 React BoardIconDisplay 一致)
func ssrBoardIconSVG(icon string, themeIndex int, className string) string {
\tkey := strings.TrimSpace(strings.ToLower(icon))
\tinner, ok := ssrBoardIconInner[key]
\tif !ok || inner == "" {
\t\tif themeIndex < 0 {
\t\t\tthemeIndex = 0
\t\t}
\t\tkey = ssrBoardDefaultIcons[themeIndex%len(ssrBoardDefaultIcons)]
\t\tinner = ssrBoardIconInner[key]
\t}
\treturn ssrSVGWithClass(18, className, inner)
}
// formatSSRRelativeTime 与前端 formatTime 同规则
func formatSSRRelativeTime(t time.Time) string {
\tif t.IsZero() {
\t\treturn ""
\t}
\tnow := time.Now()
\tdiffSec := now.Sub(t).Seconds()
\tif diffSec < 0 {
\t\tdiffSec = 0
\t}
\tif diffSec < 60 {
\t\treturn "刚刚"
\t}
\tif diffSec < 3600 {
\t\treturn strconv.Itoa(int(diffSec/60)) + "分钟前"
\t}
\tif diffSec < 86400 {
\t\treturn strconv.Itoa(int(diffSec/3600)) + "小时前"
\t}
\tdiffDay := int(diffSec / 86400)
\tif diffDay < 30 {
\t\treturn strconv.Itoa(diffDay) + "天前"
\t}
\tif t.Year() == now.Year() {
\t\treturn strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
\t}
\treturn strconv.Itoa(t.Year()) + "年" + strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
}
// formatSSRShortDateTime 与前端 formatShortDateTime 同规则MM-DD HH:mm
func formatSSRShortDateTime(t time.Time) string {
\tif t.IsZero() {
\t\treturn ""
\t}
\tlocal := t.Local()
\tpad := func(n int) string {
\t\tif n < 10 {
\t\t\treturn "0" + strconv.Itoa(n)
\t\t}
\t\treturn strconv.Itoa(n)
\t}
\treturn pad(int(local.Month())) + "-" + pad(local.Day()) + " " + pad(local.Hour()) + ":" + pad(local.Minute())
}
`;
const outPath = path.join('..', 'handler', 'ssr_icons.go');
fs.writeFileSync(outPath, out);
console.log('wrote', outPath, Object.keys(paths).length, 'icons');

View File

@@ -10,7 +10,8 @@ export default function AsideCheckInStrip() {
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading); const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading);
// 鉴权未完成:空白,避免「登录签到」→「今日已签到闪一下 // 鉴权未完成且无种子:空白,避免访客签到闪一下再消失
// 有 SSR boot 时 loading 一开始就是 false
if (authLoading) { if (authLoading) {
return null; return null;
} }

View File

@@ -79,7 +79,12 @@ export function feedSortLabel(sort: FeedSort, tabs?: FeedSortTab[] | null): stri
return list.find(t => t.id === sort)?.label ?? '帖子列表'; return list.find(t => t.id === sort)?.label ?? '帖子列表';
} }
export default function FeedSortBar({ value, onChange, postTotal, pendingValue }: Props) { export default function FeedSortBar({
value,
onChange,
postTotal,
pendingValue,
}: Props) {
const { limits } = useForumLimits(); const { limits } = useForumLimits();
const options = useMemo( const options = useMemo(
() => enabledFeedSortTabs(limits.feed_sort_tabs).map(t => ({ () => enabledFeedSortTabs(limits.feed_sort_tabs).map(t => ({

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react'; import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
import { useLocation, useSearchParams, useNavigate } from 'react-router-dom'; import { useLocation, useSearchParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types'; import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types';
import type { PostHeading } from '../utils/postHeadings'; import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding'; import { useSiteBranding } from '../hooks/useSiteBranding';
@@ -79,10 +78,6 @@ export default function RightPanel({
[asideWidgets], [asideWidgets],
); );
const handleApplyClick = () => {
nav('/links?apply=1');
};
const renderWidget = (widget: AsideWidget) => { const renderWidget = (widget: AsideWidget) => {
switch (widget.id) { switch (widget.id) {
case 'showcase': case 'showcase':
@@ -97,15 +92,9 @@ export default function RightPanel({
</button> </button>
</span> </span>
<Button <a href="/links?apply=1" className="widget-friend-links-apply">
type="button"
variant="ghost"
size="sm"
className="widget-friend-links-apply"
onClick={handleApplyClick}
>
</Button> </a>
</div> </div>
<div className="widget-card-body widget-card-body--friend-links"> <div className="widget-card-body widget-card-body--friend-links">
{friendLinks.length === 0 ? ( {friendLinks.length === 0 ? (

View File

@@ -9,7 +9,7 @@ function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>; return <span className="site-footer__sep" aria-hidden>·</span>;
} }
/** 站点页脚:版权、友链/展柜入口、单页、备案号 */ /** 站点页脚:版权、友链/展柜入口、单页、备案号(结构与 Go writeSSRFooter 对齐) */
export default function SiteFooter() { export default function SiteFooter() {
const { branding } = useSiteBranding(); const { branding } = useSiteBranding();
const { footerPages } = useSitePages(); const { footerPages } = useSitePages();
@@ -19,8 +19,42 @@ export default function SiteFooter() {
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/'; const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
const showFriendLinks = limits.footer_show_friend_links !== false; const showFriendLinks = limits.footer_show_friend_links !== false;
const showShowcase = !!limits.footer_show_showcase; 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 ( return (
<footer className="site-footer"> <footer className="site-footer">
@@ -38,35 +72,8 @@ export default function SiteFooter() {
</div> </div>
<nav className="site-footer__nav" aria-label="站点链接"> <nav className="site-footer__nav" aria-label="站点链接">
{showFriendLinks && ( {navItems.flatMap((node, i) =>
<span className="site-footer__friend"> i === 0 ? [node] : [<FooterSep key={`sep-${i}`} />, node],
<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>
</>
)} )}
</nav> </nav>
</div> </div>

View File

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

View File

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

View File

@@ -85,6 +85,13 @@ export function ensureForumLimitsLoaded(): Promise<ForumLimitsPublic> {
return fetchLimits(); return fetchLimits();
} }
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
cached = limits;
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
/** 清除缓存并通知已挂载的 hook 重新拉取 */ /** 清除缓存并通知已挂载的 hook 重新拉取 */
export function invalidateForumLimitsCache() { export function invalidateForumLimitsCache() {
cached = null; cached = null;

View File

@@ -67,3 +67,9 @@ export function useSitePages() {
export function invalidateSitePagesCache() { export function invalidateSitePagesCache() {
cache = null; cache = null;
} }
/** 文档 SSR同步写入站点页摘要缓存 */
export function seedSitePagesCache(pages: SitePageSummary[]) {
cache = Array.isArray(pages) ? pages : [];
pending = null;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo, Suspense, startTransition } from 'react';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react'; import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import { import {
@@ -43,24 +43,35 @@ import SiteFooter from '../components/SiteFooter';
import { userPath } from '../utils/userPath'; import { userPath } from '../utils/userPath';
import { parsePermalinkID } from '../utils/permalink'; import { parsePermalinkID } from '../utils/permalink';
import { ensureSitePagesLoaded } from '../hooks/useSitePages'; import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
import { getBootUnread } from '../utils/authBoot';
export default function MainLayout() { export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
const { branding } = useSiteBranding(); const { branding } = useSiteBranding();
useMonitorPageview(); useMonitorPageview();
const isMobile = useMediaQuery('(max-width: 768px)'); const mqMobile = useMediaQuery('(max-width: 768px)');
const hideAside = useMediaQuery('(max-width: 1100px)'); const hideAside = useMediaQuery('(max-width: 1100px)');
/** hydrate 首帧强制桌面布局SSR 为桌面三栏),随后再跟 matchMedia */
const [forceDesktop, setForceDesktop] = useState(() => isHomeHydrating());
const isMobile = forceDesktop ? false : mqMobile;
const nav = useNavigate(); const nav = useNavigate();
const loc = useLocation(); const loc = useLocation();
const [params] = useSearchParams(); const [params] = useSearchParams();
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname); 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 [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats()); const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments()); const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
const [recentUsers, setRecentUsers] = useState<RecentUser[]>(() => getCachedRecentUsers()); const [recentUsers, setRecentUsers] = useState<RecentUser[]>(() => getCachedRecentUsers());
const [unreadMessages, setUnreadMessages] = useState(0); const [unreadMessages, setUnreadMessages] = useState(() => getBootUnread());
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags()); const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0); const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
const [postOutline, setPostOutline] = useState<{ const [postOutline, setPostOutline] = useState<{
@@ -506,7 +517,7 @@ export default function MainLayout() {
}; };
return ( return (
<div className="app-shell"> <div className={cn('app-shell', forceDesktop && 'ssr-home')}>
<div className="app-frame"> <div className="app-frame">
<header className="app-header"> <header className="app-header">
<div className="header-inner"> <div className="header-inner">
@@ -731,6 +742,7 @@ export default function MainLayout() {
activeBoard={boardId} activeBoard={boardId}
onSelectBoard={setBoardId} onSelectBoard={setBoardId}
boardsLoading={boardsLoading} boardsLoading={boardsLoading}
/> />
)} )}
@@ -805,6 +817,7 @@ export default function MainLayout() {
loading={asideLoading} loading={asideLoading}
asideWidgets={asideWidgets} asideWidgets={asideWidgets}
onPostClick={openPost} onPostClick={openPost}
postDetail={isPostDetail ? { postDetail={isPostDetail ? {
author: postOutline?.author ?? null, author: postOutline?.author ?? null,
publishedAt: postOutline?.publishedAt, publishedAt: postOutline?.publishedAt,

View File

@@ -1,22 +1,89 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import { hydrateRoot, createRoot } from 'react-dom/client';
import { applyTheme, getStoredTheme } from './utils/theme'; import { applyTheme, getStoredTheme } from './utils/theme';
import App from './App'; import App from './App';
import { consumeHomeBoot } from './utils/homeBoot';
import { beginHomeHydrate } from './utils/homeHydrate';
import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute'; import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
applyTheme(getStoredTheme()); applyTheme(getStoredTheme());
consumeHomeBoot();
async function boot() { function hasSSRHome(): boolean {
const path = `${window.location.pathname}${window.location.search}`; return !!document.querySelector('#root .ssr-home');
// 前台:齐套前不挂载;完成后一次 createRoot
if (isMainLayoutPath(window.location.pathname)) {
await ensureColdBootReady(path);
} }
ReactDOM.createRoot(document.getElementById('root')!).render(
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> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </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(); void boot();

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } from 'react';
import { import {
useNavigate, useNavigate,
useOutletContext, useOutletContext,
@@ -12,6 +12,7 @@ import { api } from '../api/client';
import type { PostItem } from '../api/types'; import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout'; import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList'; import VirtualPostList from '../components/VirtualPostList';
import StaticFeedList from '../components/StaticFeedList';
import FeedHeader from '../components/FeedHeader'; import FeedHeader from '../components/FeedHeader';
import FeedSearchFilters from '../components/search/FeedSearchFilters'; import FeedSearchFilters from '../components/search/FeedSearchFilters';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar'; import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
@@ -28,6 +29,7 @@ import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs'; import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
import { openForumPost } from '../utils/openPost'; import { openForumPost } from '../utils/openPost';
import { startTransition, doneTransition } from '../utils/spaTransition'; import { startTransition, doneTransition } from '../utils/spaTransition';
import { isHomeHydrating } from '../utils/homeHydrate';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding'; import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink'; import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
@@ -189,6 +191,13 @@ export default function HomePage() {
initial.posts.length > 0 ? initial.scrollTop : null, initial.posts.length > 0 ? initial.scrollTop : null,
); );
const [listResetKey, setListResetKey] = useState(0); 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 scrollTopRef = useRef(initial.scrollTop);
const fetchSeqRef = useRef(0); const fetchSeqRef = useRef(0);
@@ -534,6 +543,17 @@ export default function HomePage() {
<div className="feed-panel"> <div className="feed-panel">
<div className="feed-top"> <div className="feed-top">
<div className="feed-top__bar"> <div className="feed-top__bar">
{!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 <FeedHeader
keyword={view.keyword} keyword={view.keyword}
tag={view.tag} tag={view.tag}
@@ -541,6 +561,7 @@ export default function HomePage() {
postTotal={postTotal} postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'} titleAs={isSiteHome ? 'h2' : 'h1'}
/> />
)}
{showSortBar && ( {showSortBar && (
<FeedSortBar <FeedSortBar
value={view.sort} value={view.sort}
@@ -559,6 +580,9 @@ export default function HomePage() {
/> />
)} )}
</div> </div>
{!useVirtualList ? (
<StaticFeedList posts={posts} sort={view.sort} boardId={view.boardId} />
) : (
<VirtualPostList <VirtualPostList
posts={posts} posts={posts}
sort={view.sort} sort={view.sort}
@@ -585,6 +609,7 @@ export default function HomePage() {
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''} boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0} noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
/> />
)}
</div> </div>
</div> </div>
); );

View File

@@ -2,6 +2,22 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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 { @layer base {
:root { :root {
--background: 210 20% 98%; --background: 210 20% 98%;
@@ -499,6 +515,30 @@ img.site-brand-logo-img {
flex-shrink: 0; flex-shrink: 0;
font-size: 15px; font-size: 15px;
color: var(--color-text-3); 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 { .header-search-input {
@@ -2213,6 +2253,30 @@ body:has(.admin-topbar) .ptr-indicator {
-ms-overflow-style: none; -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, .virtual-list-wrap::-webkit-scrollbar,
.post-list-scroll::-webkit-scrollbar { .post-list-scroll::-webkit-scrollbar {
display: none; display: none;
@@ -3035,13 +3099,39 @@ body:has(.admin-topbar) .ptr-indicator {
} }
.post-row--v2 .post-title-row { .post-row--v2 .post-title-row {
display: flex;
align-items: center;
gap: 4px; gap: 4px;
} }
.post-row--v2 .post-title { .post-row--v2 .post-title {
font-size: 15px; font-size: 15px;
font-weight: 400; font-weight: 400;
line-height: 1.35; /* 与侧栏类型徽章 height:20px 对齐,避免 SSRspan 标题)视觉偏高/偏低 */
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 { .post-row--v2 .post-excerpt {
@@ -7253,6 +7343,14 @@ a.waline-comment-author:hover {
width: 15px; width: 15px;
height: 15px; height: 15px;
flex-shrink: 0; 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; } .widget-card-icon--hot { color: #e74c3c; }
@@ -7278,6 +7376,11 @@ a.waline-comment-author:hover {
padding: 0 8px; padding: 0 8px;
font-size: 12px; font-size: 12px;
flex-shrink: 0; 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 { .widget-friend-links-title {

View File

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

View File

@@ -88,19 +88,18 @@ function isSameFeedUrl(url: string): boolean {
/** /**
* 导航到帖子列表。 * 导航到帖子列表。
* 默认:等待预热后再换页;同 URL 或 `refresh: true` 时静默软刷新(进度条)。 * 默认:等待预热后再换页;同 URL 或 `refresh: true` 时软刷新(带顶栏进度条)。
*/ */
export function navigateFeed(nav: NavigateFunction, url: string, opts?: { refresh?: boolean }) { export function navigateFeed(nav: NavigateFunction, url: string, opts?: { refresh?: boolean }) {
const same = isSameFeedUrl(url); const same = isSameFeedUrl(url);
const refresh = opts?.refresh ?? same; const refresh = opts?.refresh ?? same;
if (refresh) { if (refresh) {
if (same) { if (same) {
void softRefreshCurrentPage(url); void softRefreshCurrentPage(url, { progress: true });
return; return;
} }
void transitionTo(nav, url, { void transitionTo(nav, url, {
force: true, force: true,
silent: true,
state: { refreshFeed: true } satisfies FeedNavState, state: { refreshFeed: true } satisfies FeedNavState,
}); });
return; return;

View File

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

View File

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

View File

@@ -1,13 +1,21 @@
import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute'; import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute';
import { doneTransition, startTransition } from './spaTransition';
/** 软刷新齐套后的单一提交:各组件同一拍从 cache/快照同步 UI禁止分批闪烁 */ /** 软刷新齐套后的单一提交:各组件同一拍从 cache/快照同步 UI禁止分批闪烁 */
export const PAGE_SOFT_REFRESH_COMMIT_EVENT = 'page-soft-refresh-commit'; export const PAGE_SOFT_REFRESH_COMMIT_EVENT = 'page-soft-refresh-commit';
export type SoftRefreshOpts = {
/** 是否显示顶栏进度条Logo 刷新开;下拉关) */
progress?: boolean;
};
/** /**
* 静默刷新当前页:不改画面、无进度条,预热齐套后派发一次 commit。 * 刷新当前页:预热齐套后派发一次 commit。
* `progress: true` 时走顶栏进度条。
*/ */
export async function softRefreshCurrentPage(to?: string): Promise<void> { export async function softRefreshCurrentPage(to?: string, opts?: SoftRefreshOpts): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`; const path = to ?? `${window.location.pathname}${window.location.search}`;
const id = opts?.progress ? startTransition() : undefined;
try { try {
await Promise.all([ await Promise.all([
prefetchRoute(path, { force: true }), prefetchRoute(path, { force: true }),
@@ -17,4 +25,5 @@ export async function softRefreshCurrentPage(to?: string): Promise<void> {
// 仍派发 commit让界面有机会用已有缓存自愈 // 仍派发 commit让界面有机会用已有缓存自愈
} }
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT)); window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
if (id != null) doneTransition(id);
} }

View File

@@ -1,13 +1,40 @@
import path from 'path'; import path from 'path';
import { defineConfig } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
// 与 Go 后端默认端口一致,可通过 VITE_API_PORT 覆盖 // 与 Go 后端默认端口一致,可通过 VITE_API_PORT 覆盖
const apiPort = process.env.VITE_API_PORT || '3000'; const apiPort = process.env.VITE_API_PORT || '3000';
const apiTarget = `http://localhost:${apiPort}`; 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({ export default defineConfig({
plugins: [react()], plugins: [react(), htmlStylesheetsFirst()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),

View File

@@ -209,11 +209,8 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
OGType: "website", OGType: "website",
OGImage: defaultImage, OGImage: defaultImage,
}, siteName, siteKeywords) }, siteName, siteKeywords)
if isBot { // 板块首页:真人与爬虫共用完整首屏 HTML
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board))) h.serveFeedDocument(c, meta, board.ID)
return
}
embed_static.ServeSPAWithMeta(c, meta)
return return
} }
@@ -291,10 +288,10 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
return return
} }
// 其余已知路由SPA + head meta首页对爬虫额外返回可读正文 // 其余已知路由SPA + head meta首页/Feed 返回完整首屏 HTML
meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage) meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
if isBot && (path == "/" || path == "") { if path == "/" || path == "" {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand))) h.serveFeedDocument(c, meta, 0)
return return
} }
embed_static.ServeSPAWithMeta(c, meta) embed_static.ServeSPAWithMeta(c, meta)

1090
handler/ssr_home.go Normal file

File diff suppressed because it is too large Load Diff

353
handler/ssr_icons.go Normal file
View File

@@ -0,0 +1,353 @@
package handler
import (
"strconv"
"strings"
"time"
)
// 内联 SVGpath 对齐 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 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"/>`,
)
}
// 板块图标 pathkey 对齐 BOARD_ICON_OPTIONS / AllowedBoardIcons
var ssrBoardIconInner = map[string]string{
"code-2": `<path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/>`,
"coffee": `<path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/>`,
"help-circle": `<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>`,
"message-square": `<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z"/>`,
"lightbulb": `<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>`,
"book-open": `<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>`,
"gamepad-2": `<line x1="6" x2="10" y1="11" y2="11"/><line x1="8" x2="8" y1="9" y2="13"/><line x1="15" x2="15.01" y1="12" y2="12"/><line x1="18" x2="18.01" y1="10" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>`,
"palette": `<path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/>`,
"music": `<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>`,
"camera": `<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>`,
"heart": `<path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/>`,
"zap": `<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>`,
"globe": `<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>`,
"users": `<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/>`,
"briefcase": `<path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/><rect width="20" height="14" x="2" y="6" rx="2"/>`,
"graduation-cap": `<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z"/><path d="M22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/>`,
"shopping-bag": `<path d="M16 10a4 4 0 0 1-8 0"/><path d="M3.103 6.034h17.794"/><path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z"/>`,
"map-pin": `<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>`,
"megaphone": `<path d="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"/><path d="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14"/><path d="M8 6v8"/>`,
"flame": `<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"/>`,
"star": `<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>`,
"folder": `<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>`,
"wrench": `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>`,
"cpu": `<path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/>`,
}
// 与前端 DEFAULT_ICONS 顺序一致(按 themeIndex 回退)
var ssrBoardDefaultIcons = []string{
"code-2",
"coffee",
"help-circle",
"message-square",
"lightbulb",
"book-open",
"gamepad-2",
"palette",
}
// ssrBoardIconSVG 输出板块 Lucide 图标class 打在 svg 上(与 React BoardIconDisplay 一致)
func ssrBoardIconSVG(icon string, themeIndex int, className string) string {
key := strings.TrimSpace(strings.ToLower(icon))
inner, ok := ssrBoardIconInner[key]
if !ok || inner == "" {
if themeIndex < 0 {
themeIndex = 0
}
key = ssrBoardDefaultIcons[themeIndex%len(ssrBoardDefaultIcons)]
inner = ssrBoardIconInner[key]
}
return ssrSVGWithClass(18, className, inner)
}
// formatSSRRelativeTime 与前端 formatTime 同规则
func formatSSRRelativeTime(t time.Time) string {
if t.IsZero() {
return ""
}
now := time.Now()
diffSec := now.Sub(t).Seconds()
if diffSec < 0 {
diffSec = 0
}
if diffSec < 60 {
return "刚刚"
}
if diffSec < 3600 {
return strconv.Itoa(int(diffSec/60)) + "分钟前"
}
if diffSec < 86400 {
return strconv.Itoa(int(diffSec/3600)) + "小时前"
}
diffDay := int(diffSec / 86400)
if diffDay < 30 {
return strconv.Itoa(diffDay) + "天前"
}
if t.Year() == now.Year() {
return strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
}
return strconv.Itoa(t.Year()) + "年" + strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日"
}
// formatSSRShortDateTime 与前端 formatShortDateTime 同规则MM-DD HH:mm
func formatSSRShortDateTime(t time.Time) string {
if t.IsZero() {
return ""
}
local := t.Local()
pad := func(n int) string {
if n < 10 {
return "0" + strconv.Itoa(n)
}
return strconv.Itoa(n)
}
return pad(int(local.Month())) + "-" + pad(local.Day()) + " " + pad(local.Hour()) + ":" + pad(local.Minute())
}

View File

@@ -326,8 +326,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}) })
}) })
} else { } else {
r.GET("/", h.ServePublicSPA) // 文档请求也解析登录 cookie首页 SSR 才能输出头像/签到/收藏等完整壳
r.NoRoute(func(c *gin.Context) { r.GET("/", authMW.OptionalAuth(), h.ServePublicSPA)
r.NoRoute(authMW.OptionalAuth(), func(c *gin.Context) {
if embed_static.IsSPARoute(c.Request.URL.Path) { if embed_static.IsSPARoute(c.Request.URL.Path) {
h.ServePublicSPA(c) h.ServePublicSPA(c)
return return