feat: 首页 Go SSR 与 React hydrate 同构,消壳层与帖行闪动
补齐侧栏/右栏图标与徽章、鉴权种子、StaticFeedList,并修正嵌套 a 与标题徽章对齐。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 入口
|
||||
@@ -56,15 +62,16 @@ 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),紧跟 </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) + `"/>`)
|
||||
}
|
||||
robots := strings.TrimSpace(meta.Robots)
|
||||
if robots != "" {
|
||||
writeMeta(&head, "robots", robots)
|
||||
writeMeta(&seo, "robots", robots)
|
||||
}
|
||||
|
||||
ogType := strings.TrimSpace(meta.OGType)
|
||||
@@ -75,36 +82,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 +148,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>"
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
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');
|
||||
@@ -10,7 +10,8 @@ export default function AsideCheckInStrip() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { status, loading, busy, doCheckIn } = useCheckIn(!!user && !authLoading);
|
||||
|
||||
// 鉴权未完成:空白,避免「登录签到」→「今日已签到」闪一下
|
||||
// 鉴权未完成且无种子:空白,避免访客签到卡闪一下再消失
|
||||
// 有 SSR boot 时 loading 一开始就是 false
|
||||
if (authLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,12 @@ export function feedSortLabel(sort: FeedSort, tabs?: FeedSortTab[] | null): stri
|
||||
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 options = useMemo(
|
||||
() => enabledFeedSortTabs(limits.feed_sort_tabs).map(t => ({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react';
|
||||
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 { PostHeading } from '../utils/postHeadings';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
@@ -79,10 +78,6 @@ export default function RightPanel({
|
||||
[asideWidgets],
|
||||
);
|
||||
|
||||
const handleApplyClick = () => {
|
||||
nav('/links?apply=1');
|
||||
};
|
||||
|
||||
const renderWidget = (widget: AsideWidget) => {
|
||||
switch (widget.id) {
|
||||
case 'showcase':
|
||||
@@ -97,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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -17,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 {
|
||||
@@ -31,8 +36,9 @@ 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(() => {
|
||||
|
||||
@@ -85,6 +85,13 @@ export function ensureForumLimitsLoaded(): Promise<ForumLimitsPublic> {
|
||||
return fetchLimits();
|
||||
}
|
||||
|
||||
/** 文档 SSR / 管理端:同步写入 limits 模块缓存 */
|
||||
export function seedForumLimitsCache(limits: ForumLimitsPublic) {
|
||||
cached = limits;
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||
export function invalidateForumLimitsCache() {
|
||||
cached = null;
|
||||
|
||||
@@ -67,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,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 { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
@@ -43,24 +43,35 @@ 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<{
|
||||
@@ -506,7 +517,7 @@ 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">
|
||||
@@ -731,6 +742,7 @@ export default function MainLayout() {
|
||||
activeBoard={boardId}
|
||||
onSelectBoard={setBoardId}
|
||||
boardsLoading={boardsLoading}
|
||||
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -805,6 +817,7 @@ export default function MainLayout() {
|
||||
loading={asideLoading}
|
||||
asideWidgets={asideWidgets}
|
||||
onPostClick={openPost}
|
||||
|
||||
postDetail={isPostDetail ? {
|
||||
author: postOutline?.author ?? null,
|
||||
publishedAt: postOutline?.publishedAt,
|
||||
|
||||
@@ -1,22 +1,89 @@
|
||||
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 { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
|
||||
|
||||
applyTheme(getStoredTheme());
|
||||
consumeHomeBoot();
|
||||
|
||||
async function boot() {
|
||||
const path = `${window.location.pathname}${window.location.search}`;
|
||||
// 前台:齐套前不挂载;完成后一次 createRoot
|
||||
if (isMainLayoutPath(window.location.pathname)) {
|
||||
await ensureColdBootReady(path);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
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>,
|
||||
</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, useCallback, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } from 'react';
|
||||
import {
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
@@ -12,6 +12,7 @@ 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 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 { 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';
|
||||
@@ -189,6 +191,13 @@ export default function HomePage() {
|
||||
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);
|
||||
@@ -534,13 +543,25 @@ export default function HomePage() {
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<div className="feed-top__bar">
|
||||
<FeedHeader
|
||||
keyword={view.keyword}
|
||||
tag={view.tag}
|
||||
author={view.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={view.sort}
|
||||
@@ -559,32 +580,36 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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}
|
||||
/>
|
||||
{!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>
|
||||
);
|
||||
|
||||
@@ -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%;
|
||||
@@ -499,6 +515,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 {
|
||||
@@ -2213,6 +2253,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;
|
||||
@@ -3035,13 +3099,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 {
|
||||
@@ -7253,6 +7343,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; }
|
||||
@@ -7278,6 +7376,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 {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 }) {
|
||||
const same = isSameFeedUrl(url);
|
||||
const refresh = opts?.refresh ?? same;
|
||||
if (refresh) {
|
||||
if (same) {
|
||||
void softRefreshCurrentPage(url);
|
||||
void softRefreshCurrentPage(url, { progress: true });
|
||||
return;
|
||||
}
|
||||
void transitionTo(nav, url, {
|
||||
force: true,
|
||||
silent: true,
|
||||
state: { refreshFeed: true } satisfies FeedNavState,
|
||||
});
|
||||
return;
|
||||
|
||||
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,13 +1,21 @@
|
||||
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。
|
||||
* 软刷新当前页:预热齐套后派发一次 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 id = opts?.progress ? startTransition() : undefined;
|
||||
try {
|
||||
await Promise.all([
|
||||
prefetchRoute(path, { force: true }),
|
||||
@@ -17,4 +25,5 @@ export async function softRefreshCurrentPage(to?: string): Promise<void> {
|
||||
// 仍派发 commit,让界面有机会用已有缓存自愈
|
||||
}
|
||||
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
|
||||
if (id != null) doneTransition(id);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -209,11 +209,8 @@ 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)
|
||||
// 板块首页:真人与爬虫共用完整首屏 HTML
|
||||
h.serveFeedDocument(c, meta, board.ID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,10 +288,10 @@ func (h *Handlers) ServePublicSPA(c *gin.Context) {
|
||||
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)
|
||||
|
||||
1090
handler/ssr_home.go
Normal file
1090
handler/ssr_home.go
Normal file
File diff suppressed because it is too large
Load Diff
353
handler/ssr_icons.go
Normal file
353
handler/ssr_icons.go
Normal file
@@ -0,0 +1,353 @@
|
||||
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 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())
|
||||
}
|
||||
@@ -326,8 +326,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
|
||||
|
||||
Reference in New Issue
Block a user