公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。

后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 00:11:24 +08:00
parent 2b3d122e56
commit 40bbad2c82
51 changed files with 3042 additions and 1318 deletions

View File

@@ -217,7 +217,7 @@ docker compose up -d
### 生产环境要点 ### 生产环境要点
1. **官方 Compose:** `DEV_MODE=false`,强随机 `JWT_SECRET` 与 `POSTGRES_PASSWORD`;由你现有的 Nginx/Caddy 提供 HTTPS,并把页面与 `/api`、`/api/ws`、`/uploads` 挂到同一 Host。 1. **官方 Compose:** `DEV_MODE=false`,强随机 `JWT_SECRET` 与 `POSTGRES_PASSWORD`;由你现有的 Nginx/Caddy 提供 HTTPS,并把页面与 `/api`、`/api/ws`、`/uploads` 挂到同一 Host。
2. **CORS:** 设 `SITE_URL` 为对外 origin(不要改 `router.go` 写死域名)。 2. **CORS 与 Canonical:** 设 `SITE_URL` 为对外 origin(不要改 `router.go` 写死域名)。官方 Compose 会同时注入 api 与 web;web 用它生成 Canonical / sitemap,不要填 `http://api:3001`。
3. **自管部署:** 仍须同源反代。Workers 前端须保持 `middleware.ts` 文件名(OpenNext 不识别 `proxy.ts`),实时通道由反代承接 `/api/ws`。 3. **自管部署:** 仍须同源反代。Workers 前端须保持 `middleware.ts` 文件名(OpenNext 不识别 `proxy.ts`),实时通道由反代承接 `/api/ws`。
### WebSocket(开发 vs 生产) ### WebSocket(开发 vs 生产)

View File

@@ -8,7 +8,8 @@ JIANG13_VERSION=0.1.0
JIANG13_IMAGE_API=jiang13-bbs-api JIANG13_IMAGE_API=jiang13-bbs-api
JIANG13_IMAGE_WEB=jiang13-bbs-web JIANG13_IMAGE_WEB=jiang13-bbs-web
# 对外 origin,须与浏览器地址栏一致(含 https、无尾斜杠)。CORS 兜底。 # 对外 origin,须与浏览器地址栏一致(含 https、无尾斜杠)。
# api 用于 CORS;web 用于 Canonical / sitemap / robots(运行时读取,勿写成 http://api:3001)。
SITE_URL=https://bbs.example.com SITE_URL=https://bbs.example.com
# 发布到宿主机的地址/端口,供本机 Nginx 反代。默认只绑 loopback。 # 发布到宿主机的地址/端口,供本机 Nginx 反代。默认只绑 loopback。

View File

@@ -52,6 +52,7 @@
| 变量 | 必需 | 说明 | | 变量 | 必需 | 说明 |
|------|------|------| |------|------|------|
| `BACKEND_URL` | 是 | 固定 `http://api:3001`(SSR / middleware,勿写成公网 URL) | | `BACKEND_URL` | 是 | 固定 `http://api:3001`(SSR / middleware,勿写成公网 URL) |
| `SITE_URL` | 建议 | 与 api 相同的对外 origin。Canonical / sitemap / robots 在**运行时**读取;不要写成 `http://api:3001` |
| `PORT` / `HOSTNAME` | 否 | `3000` / `0.0.0.0` | | `PORT` / `HOSTNAME` | 否 | `3000` / `0.0.0.0` |
| `NEXT_PUBLIC_APP_VERSION` | 构建期 | 与 `VERSION` 相同;**不要**设置 `NEXT_PUBLIC_API_URL=http://api:3001`(会泄漏到浏览器) | | `NEXT_PUBLIC_APP_VERSION` | 构建期 | 与 `VERSION` 相同;**不要**设置 `NEXT_PUBLIC_API_URL=http://api:3001`(会泄漏到浏览器) |

View File

@@ -61,6 +61,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
BACKEND_URL: http://api:3001 BACKEND_URL: http://api:3001
SITE_URL: ${SITE_URL:-https://localhost}
NODE_ENV: production NODE_ENV: production
PORT: "3000" PORT: "3000"
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0

View File

@@ -7,6 +7,13 @@ map $http_upgrade $connection_upgrade {
'' close; '' close;
} }
# 明文 80 只做跳转,页面与 Cookie 都走 HTTPS
server {
listen 80;
server_name bbs.example.com;
return 301 https://$host$request_uri;
}
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name bbs.example.com; server_name bbs.example.com;

View File

@@ -12,3 +12,8 @@ NEXT_PUBLIC_API_URL=http://localhost:3001
# 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。 # 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。
# NEXT_PUBLIC_APP_VERSION=0.1.0 # NEXT_PUBLIC_APP_VERSION=0.1.0
# 访客看到的公开 origin(Canonical / sitemap)。与 Go CORS 的 SITE_URL 相同。
# 不要填 BACKEND_URL / 容器名。本地不设则开发态回退 http://localhost:3000;
# 生产缺失或非法时不会回退 localhost,而是省略 Canonical。
# SITE_URL=http://localhost:3000

View File

@@ -18,11 +18,15 @@ import {
SiteDocAnnoNav, SiteDocAnnoNav,
SiteDocBreadcrumb, SiteDocBreadcrumb,
} from "@/components/site-doc"; } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "关于本站", return {
description: "站点说明、政策与帮助文档", title: "关于本站",
}; description: "站点说明、政策与帮助文档",
...(await requestCanonical("/about")),
};
}
function PageRow({ function PageRow({
p, p,

View File

@@ -25,6 +25,7 @@ import {
AdminIconButton, AdminIconButton,
AdminPageHeader, AdminPageHeader,
AdminSegmented, AdminSegmented,
AdminSettingsPage,
AdminStatusChip, AdminStatusChip,
} from "@/components/admin"; } from "@/components/admin";
@@ -106,7 +107,7 @@ export default function AnnouncementAdmin({
} }
return ( return (
<div> <AdminSettingsPage>
<AdminPageHeader <AdminPageHeader
icon={Megaphone} icon={Megaphone}
title="站点公告管理" title="站点公告管理"
@@ -264,6 +265,6 @@ export default function AnnouncementAdmin({
if (deleteTarget) void remove(deleteTarget); if (deleteTarget) void remove(deleteTarget);
}} }}
/> />
</div> </AdminSettingsPage>
); );
} }

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Megaphone } from "lucide-react";
import { import {
apiAdminCreateAnnouncement, apiAdminCreateAnnouncement,
apiAdminUpdateAnnouncement, apiAdminUpdateAnnouncement,
@@ -13,7 +13,18 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor"; import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminSegmented, AdminSwitch } from "@/components/admin"; import {
AdminField,
AdminPageHeader,
AdminSegmented,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsPage,
AdminSettingsRow,
AdminSwitch,
} from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { ANNOUNCEMENT_COLORS } from "./announcementColors"; import { ANNOUNCEMENT_COLORS } from "./announcementColors";
type PublishKey = "published" | "draft"; type PublishKey = "published" | "draft";
@@ -38,6 +49,17 @@ function toForm(a?: Announcement | null): FormState {
}; };
} }
function sameForm(a: FormState, b: FormState): boolean {
return (
a.title === b.title &&
a.content === b.content &&
a.tag === b.tag &&
a.tag_color === b.tag_color &&
a.published === b.published &&
a.pinned === b.pinned
);
}
export default function AnnouncementComposeClient({ export default function AnnouncementComposeClient({
mode, mode,
initial, initial,
@@ -46,10 +68,13 @@ export default function AnnouncementComposeClient({
initial?: Announcement | null; initial?: Announcement | null;
}) { }) {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState<FormState>(() => toForm(initial)); const baseline = useMemo(() => toForm(initial), [initial]);
const [form, setForm] = useState<FormState>(baseline);
const formRef = useRef(form); const formRef = useRef(form);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const isEdit = mode === "edit" && initial != null; const isEdit = mode === "edit" && initial != null;
const dirty = !sameForm(form, baseline);
const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving);
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => { const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
setForm((prev) => { setForm((prev) => {
@@ -92,108 +117,102 @@ export default function AnnouncementComposeClient({
} }
} }
const saveLabel = isEdit ? "保存修改" : form.published ? "发布公告" : "保存草稿";
return ( return (
<div className="j13-compose-page w-full"> <AdminSettingsPage className="is-fill">
<div className="j13-compose-stage"> <AdminPageHeader
<header className="j13-compose-toolbar"> icon={Megaphone}
<h1 className="j13-compose-toolbar-title"> title={isEdit ? "编辑公告" : "新建公告"}
{isEdit ? "编辑公告" : "新建公告"} description="标签与置顶会出现在首页右栏;正文支持 Markdown。"
</h1> actions={
<div className="j13-compose-toolbar-actions"> <Link href="/admin/announcements" className="btn btn-line btn-sm">
<Link href="/admin/announcements" className="btn btn-line btn-sm"> <ArrowLeft size={14} /> 返回列表
<ArrowLeft size={14} /> 返回列表 </Link>
</Link> }
<button />
type="button"
className="btn btn-primary btn-sm min-w-[4.5rem]"
disabled={saving}
onClick={() => void save()}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存
</button>
</div>
</header>
<div className="j13-compose-body"> <div className="admin-settings-main admin-settings-stack">
<section className="j13-compose-section" aria-label="发布设置"> <AdminSettingsGroup title="发布设置" description="标签色与置顶只影响前台展示,不改正文。">
<h2 className="j13-compose-section-title">发布设置</h2> <AdminSettingsRow label="标签" htmlFor="anno-tag">
<div className="j13-compose-typepanel space-y-4"> <AdminField
<div> id="anno-tag"
<label className="field-label" htmlFor="anno-tag"> label="标签"
标签 embed
</label> className="admin-settings-control-text"
<input maxLength={32}
id="anno-tag" value={form.tag}
className="j13-compose-field w-full max-w-[12rem]" onChange={(tag) => patchForm({ tag })}
maxLength={32} placeholder="公告"
value={form.tag} />
onChange={(e) => patchForm({ tag: e.target.value })} </AdminSettingsRow>
placeholder="公告" <AdminSettingsRow label="标签颜色">
<div className="flex items-center gap-2 flex-wrap">
{ANNOUNCEMENT_COLORS.map((c) => (
<button
key={c.value}
type="button"
data-tip={c.label}
aria-label={c.label}
aria-pressed={form.tag_color === c.value}
className={`j13-anno-dot is-${c.value}`}
onClick={() => patchForm({ tag_color: c.value })}
/> />
</div> ))}
<div>
<p className="field-label">标签颜色</p>
<div className="flex items-center gap-2 flex-wrap">
{ANNOUNCEMENT_COLORS.map((c) => (
<button
key={c.value}
type="button"
data-tip={c.label}
aria-label={c.label}
aria-pressed={form.tag_color === c.value}
className={`j13-anno-dot is-${c.value}`}
onClick={() => patchForm({ tag_color: c.value })}
/>
))}
</div>
</div>
<div>
<p className="field-label">发布状态</p>
<AdminSegmented<PublishKey>
role="group"
ariaLabel="发布状态"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "published", label: "立即发布" },
{ key: "draft", label: "存为草稿" },
]}
/>
</div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-[14px] font-medium" style={{ color: "var(--ink)" }}>
置顶公告
</p>
<p className="meta text-[12px] mt-0.5">置顶后优先显示在首页右栏</p>
</div>
<AdminSwitch
checked={form.pinned}
onChange={() => patchForm((f) => ({ ...f, pinned: !f.pinned }))}
label="置顶公告"
/>
</div>
</div> </div>
</section> </AdminSettingsRow>
<AdminSettingsRow label="发布状态">
<AdminSegmented<PublishKey>
role="group"
ariaLabel="发布状态"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "published", label: "立即发布" },
{ key: "draft", label: "存为草稿" },
]}
/>
</AdminSettingsRow>
<AdminSettingsRow label="置顶" hintBelow="置顶后优先显示在首页右栏">
<AdminSwitch
checked={form.pinned}
onChange={() => patchForm((f) => ({ ...f, pinned: !f.pinned }))}
label="置顶公告"
/>
</AdminSettingsRow>
</AdminSettingsGroup>
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文"> <AdminSettingsGroup
<div className="flex items-center justify-between gap-2 mb-3"> className="is-fill"
<h2 className="j13-compose-section-title !mb-0">标题与正文</h2> title="标题与正文"
</div> footer={
<input <AdminSettingsActions
className="j13-compose-title" dirty={dirty}
saving={saving}
saveLabel={saveLabel}
onSave={() => void save()}
onDiscard={() => {
formRef.current = baseline;
setForm(baseline);
}}
/>
}
>
<AdminSettingsRow label="标题" htmlFor="anno-title" wide>
<AdminField
id="anno-title"
label="标题"
embed
maxLength={200} maxLength={200}
value={form.title} value={form.title}
onChange={(e) => patchForm({ title: e.target.value })} onChange={(title) => patchForm({ title })}
placeholder="请输入公告标题" placeholder="请输入公告标题"
aria-label="公告标题"
/> />
<div className="j13-compose-canvas"> </AdminSettingsRow>
<AdminSettingsRow className="admin-settings-md-editor" label="正文" htmlFor="anno-content" wide>
<div className="admin-settings-md">
<MarkdownEditor <MarkdownEditor
id="anno-content"
value={form.content} value={form.content}
onChange={(content) => patchForm({ content })} onChange={(content) => patchForm({ content })}
allowHide allowHide
@@ -203,33 +222,19 @@ export default function AnnouncementComposeClient({
onSubmitShortcut={() => void save()} onSubmitShortcut={() => void save()}
/> />
</div> </div>
</section> </AdminSettingsRow>
</div> </AdminSettingsGroup>
<div className="j13-compose-actions">
<div className="j13-compose-actions-end">
<Link href="/admin/announcements" className="btn btn-line">
取消
</Link>
<button
type="button"
className="btn btn-primary"
disabled={saving}
onClick={() => void save()}
>
{saving ? (
<>
<Loader2 size={15} className="animate-spin" /> 保存中…
</>
) : (
<>
<Save size={15} /> {isEdit ? "保存修改" : form.published ? "发布公告" : "保存草稿"}
</>
)}
</button>
</div>
</div>
</div> </div>
</div>
<ConfirmDialog
open={leaveOpen}
title="有未保存的修改"
message="离开后,尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={cancelLeave}
onConfirm={confirmLeave}
/>
</AdminSettingsPage>
); );
} }

View File

@@ -183,19 +183,24 @@ function PendingActions({
); );
} }
/** 策略项:标题与控件同一行,供横向策略条复用 */ /** 策略项:标题、控件与保存按钮成组换行,不拆散单项 */
function PolicyChip({ function PolicyChip({
title, title,
hint, hint,
control, control,
instant,
}: { }: {
title: string; title: string;
hint?: string; hint?: string;
control: ReactNode; control: ReactNode;
instant?: boolean;
}) { }) {
return ( return (
<div className="admin-content-policy-chip" data-tip={hint || undefined}> <div className="admin-content-policy-chip" data-tip={hint || undefined}>
<span className="admin-content-policy-chip-label">{title}</span> <span className="admin-content-policy-chip-copy">
<span className="admin-content-policy-chip-label">{title}</span>
{instant ? <span className="admin-content-policy-chip-how">立即生效</span> : null}
</span>
<div className="admin-content-policy-chip-ctrl shrink-0 flex items-center gap-1.5">{control}</div> <div className="admin-content-policy-chip-ctrl shrink-0 flex items-center gap-1.5">{control}</div>
</div> </div>
); );
@@ -904,16 +909,22 @@ export default function ContentAdmin({
<AdminPageHeader <AdminPageHeader
icon={FolderOpen} icon={FolderOpen}
title="内容管理" title="内容管理"
description="审核与管理帖子、评论;下方策略为全站共用" description="审核与管理帖子、评论。共用策略作用于全站发帖与评论,与下方列表筛选无关。"
/> />
{canConfigureSettings && ( {canConfigureSettings && (
<div className="admin-content-policy" role="group" aria-label="全站内容策略"> <div className="admin-content-policy" role="group" aria-label="全站内容策略">
<span className="admin-content-policy-title">共用策略</span> <div className="admin-content-policy-lead">
<span className="admin-content-policy-title">共用策略</span>
<span className="admin-content-policy-scope">
过审免审与外链开关改完立即写入;冷静期需点保存。失败时恢复为已确认值。
</span>
</div>
<div className="admin-content-policy-row"> <div className="admin-content-policy-row">
<PolicyChip <PolicyChip
title="过审免审" title="过审免审"
hint="帖子 / 评论:用户曾有过审内容后,后续发帖与评论直接公开,不再进待审。" instant
hint="帖子 / 评论:用户曾有过审内容后,后续发帖与评论直接公开,不再进待审。修改后立即生效。"
control={ control={
<AdminSwitch <AdminSwitch
checked={trustOn} checked={trustOn}
@@ -925,7 +936,7 @@ export default function ContentAdmin({
/> />
<PolicyChip <PolicyChip
title="新用户冷静期" title="新用户冷静期"
hint="帖子:新注册用户在冷静期内不可发帖(0=关闭,上限 168 小时)。评论不受此限制。" hint="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受此限制。需点保存后生效。"
control={ control={
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<AdminStepper <AdminStepper
@@ -943,14 +954,15 @@ export default function ContentAdmin({
disabled={savingPolicy} disabled={savingPolicy}
onClick={() => void saveCooldown()} onClick={() => void saveCooldown()}
> >
保存 {savingPolicy ? "保存中…" : "保存"}
</button> </button>
</div> </div>
} }
/> />
<PolicyChip <PolicyChip
title="外链新开" title="外链新开"
hint="帖子与评论中的外链在新标签打开" instant
hint="帖子与评论中的外链在新标签打开。修改后立即生效。"
control={ control={
<AdminSwitch <AdminSwitch
checked={postLinkNewTab} checked={postLinkNewTab}

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, FileText } from "lucide-react";
import { import {
apiAdminCreateSitePage, apiAdminCreateSitePage,
apiAdminUpdateSitePage, apiAdminUpdateSitePage,
@@ -12,7 +12,18 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor"; import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminSegmented, AdminSwitch } from "@/components/admin"; import {
AdminField,
AdminPageHeader,
AdminSegmented,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsPage,
AdminSettingsRow,
AdminSwitch,
} from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
type PublishKey = "published" | "draft"; type PublishKey = "published" | "draft";
@@ -38,6 +49,18 @@ function toForm(p?: SitePage | null): FormState {
}; };
} }
function sameForm(a: FormState, b: FormState): boolean {
return (
a.slug === b.slug &&
a.title === b.title &&
a.content === b.content &&
a.excerpt === b.excerpt &&
a.published === b.published &&
a.show_in_sidebar === b.show_in_sidebar &&
a.sort_order === b.sort_order
);
}
/** slug 仅允许小写字母数字与连字符 */ /** slug 仅允许小写字母数字与连字符 */
function normalizeSlug(raw: string): string { function normalizeSlug(raw: string): string {
return raw return raw
@@ -56,10 +79,13 @@ export default function PageComposeClient({
initial?: SitePage | null; initial?: SitePage | null;
}) { }) {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState<FormState>(() => toForm(initial)); const baseline = useMemo(() => toForm(initial), [initial]);
const [form, setForm] = useState<FormState>(baseline);
const formRef = useRef(form); const formRef = useRef(form);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const isEdit = mode === "edit" && initial != null; const isEdit = mode === "edit" && initial != null;
const dirty = !sameForm(form, baseline);
const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving);
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => { const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
setForm((prev) => { setForm((prev) => {
@@ -104,125 +130,114 @@ export default function PageComposeClient({
} }
} }
const saveLabel = isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿";
return ( return (
<div className="j13-compose-page w-full"> <AdminSettingsPage className="is-fill">
<div className="j13-compose-stage"> <AdminPageHeader
<header className="j13-compose-toolbar"> icon={FileText}
<h1 className="j13-compose-toolbar-title">{isEdit ? "编辑单页" : "新建单页"}</h1> title={isEdit ? "编辑单页" : "新建单页"}
<div className="j13-compose-toolbar-actions"> description="关于本站、隐私政策等 Markdown 单页;可展示在首页右栏。"
<Link href="/admin/pages" className="btn btn-line btn-sm"> actions={
<ArrowLeft size={14} /> 返回列表 <Link href="/admin/pages" className="btn btn-line btn-sm">
</Link> <ArrowLeft size={14} /> 返回列表
<button </Link>
type="button" }
className="btn btn-primary btn-sm min-w-[4.5rem]" />
disabled={saving}
onClick={() => void save()}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存
</button>
</div>
</header>
<div className="j13-compose-body"> <div className="admin-settings-main admin-settings-stack">
<section className="j13-compose-section" aria-label="页面设置"> <AdminSettingsGroup title="页面设置" description="slug 写入后即是前台地址 /p/…">
<h2 className="j13-compose-section-title">页面设置</h2> <AdminSettingsRow label="URL slug" htmlFor="page-slug" hint="/p/ 后的路径">
<div className="j13-compose-typepanel space-y-4"> <div className="admin-settings-control-text flex items-center gap-1.5 min-w-0">
<div> <span className="meta text-[13px] shrink-0">/p/</span>
<label className="field-label" htmlFor="page-slug"> <div className="admin-field-shell min-w-0 flex-1">
URL slug
</label>
<div className="flex items-center gap-1.5">
<span className="meta text-[13px]">/p/</span>
<input
id="page-slug"
className="j13-compose-field flex-1 font-mono text-[13px]"
maxLength={64}
value={form.slug}
onChange={(e) => patchForm({ slug: e.target.value })}
onBlur={() =>
patchForm((f) => ({ ...f, slug: normalizeSlug(f.slug) }))
}
placeholder="about"
/>
</div>
</div>
<div>
<label className="field-label" htmlFor="page-excerpt">
摘要(侧栏展示)
</label>
<textarea
id="page-excerpt"
className="j13-compose-field w-full"
rows={3}
maxLength={300}
value={form.excerpt}
onChange={(e) => patchForm({ excerpt: e.target.value })}
placeholder="一句话介绍本页…"
/>
</div>
<div>
<label className="field-label" htmlFor="page-sort">
排序(越小越靠前)
</label>
<input <input
id="page-sort" id="page-slug"
type="number" className="admin-field-input font-mono text-[13px]"
className="j13-compose-field w-28" maxLength={64}
value={form.sort_order} value={form.slug}
onChange={(e) => onChange={(e) => patchForm({ slug: e.target.value })}
patchForm({ sort_order: parseInt(e.target.value, 10) || 0 }) onBlur={() => patchForm((f) => ({ ...f, slug: normalizeSlug(f.slug) }))}
} placeholder="about"
/>
</div>
<div>
<p className="field-label">发布状态</p>
<AdminSegmented<PublishKey>
role="group"
ariaLabel="发布状态"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "published", label: "立即发布" },
{ key: "draft", label: "存为草稿" },
]}
/>
</div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-[14px] font-medium" style={{ color: "var(--ink)" }}>
显示在首页侧栏
</p>
<p className="meta text-[12px] mt-0.5">开启后出现在「关于本站」模块</p>
</div>
<AdminSwitch
checked={form.show_in_sidebar}
onChange={() =>
patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))
}
label="显示在首页侧栏"
/> />
</div> </div>
</div> </div>
</section> </AdminSettingsRow>
<AdminSettingsRow label="摘要" htmlFor="page-excerpt" hintBelow="侧栏「关于本站」使用这一句" wide>
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文"> <AdminField
<h2 className="j13-compose-section-title">标题与正文</h2> id="page-excerpt"
label="摘要"
embed
as="textarea"
className="admin-settings-control-long"
maxLength={300}
minHeight="4.5rem"
value={form.excerpt}
onChange={(excerpt) => patchForm({ excerpt })}
placeholder="一句话介绍本页…"
/>
</AdminSettingsRow>
<AdminSettingsRow label="排序" htmlFor="page-sort" hintBelow="数字越小越靠前">
<input <input
className="j13-compose-title" id="page-sort"
type="number"
className="j13-compose-field admin-settings-control-num"
value={form.sort_order}
onChange={(e) => patchForm({ sort_order: parseInt(e.target.value, 10) || 0 })}
/>
</AdminSettingsRow>
<AdminSettingsRow label="发布状态">
<AdminSegmented<PublishKey>
role="group"
ariaLabel="发布状态"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "published", label: "立即发布" },
{ key: "draft", label: "存为草稿" },
]}
/>
</AdminSettingsRow>
<AdminSettingsRow label="首页侧栏" hintBelow="开启后出现在「关于本站」模块">
<AdminSwitch
checked={form.show_in_sidebar}
onChange={() => patchForm((f) => ({ ...f, show_in_sidebar: !f.show_in_sidebar }))}
label="显示在首页侧栏"
/>
</AdminSettingsRow>
</AdminSettingsGroup>
<AdminSettingsGroup
className="is-fill"
title="标题与正文"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel={saveLabel}
onSave={() => void save()}
onDiscard={() => {
formRef.current = baseline;
setForm(baseline);
}}
/>
}
>
<AdminSettingsRow label="标题" htmlFor="page-title" wide>
<AdminField
id="page-title"
label="标题"
embed
maxLength={200} maxLength={200}
value={form.title} value={form.title}
onChange={(e) => patchForm({ title: e.target.value })} onChange={(title) => patchForm({ title })}
placeholder="页面标题" placeholder="页面标题"
aria-label="页面标题"
/> />
<div className="j13-compose-canvas"> </AdminSettingsRow>
<AdminSettingsRow className="admin-settings-md-editor" label="正文" htmlFor="page-content" wide>
<div className="admin-settings-md">
<MarkdownEditor <MarkdownEditor
id="page-content"
value={form.content} value={form.content}
onChange={(content) => patchForm({ content })} onChange={(content) => patchForm({ content })}
allowHide={false} allowHide={false}
@@ -231,33 +246,19 @@ export default function PageComposeClient({
onSubmitShortcut={() => void save()} onSubmitShortcut={() => void save()}
/> />
</div> </div>
</section> </AdminSettingsRow>
</div> </AdminSettingsGroup>
<div className="j13-compose-actions">
<div className="j13-compose-actions-end">
<Link href="/admin/pages" className="btn btn-line">
取消
</Link>
<button
type="button"
className="btn btn-primary"
disabled={saving}
onClick={() => void save()}
>
{saving ? (
<>
<Loader2 size={15} className="animate-spin" /> 保存中…
</>
) : (
<>
<Save size={15} /> {isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿"}
</>
)}
</button>
</div>
</div>
</div> </div>
</div>
<ConfirmDialog
open={leaveOpen}
title="有未保存的修改"
message="离开后,尚未写入的修改会丢失。确定离开?"
confirmLabel="离开"
danger
onCancel={cancelLeave}
onConfirm={confirmLeave}
/>
</AdminSettingsPage>
); );
} }

View File

@@ -22,6 +22,7 @@ import {
AdminIconButton, AdminIconButton,
AdminPageHeader, AdminPageHeader,
AdminSegmented, AdminSegmented,
AdminSettingsPage,
AdminStatusChip, AdminStatusChip,
} from "@/components/admin"; } from "@/components/admin";
@@ -64,7 +65,7 @@ export default function PagesAdmin({
} }
return ( return (
<div> <AdminSettingsPage>
<AdminPageHeader <AdminPageHeader
icon={FileText} icon={FileText}
title="单页管理" title="单页管理"
@@ -200,6 +201,6 @@ export default function PagesAdmin({
if (deleteTarget) void remove(deleteTarget); if (deleteTarget) void remove(deleteTarget);
}} }}
/> />
</div> </AdminSettingsPage>
); );
} }

View File

@@ -1,135 +1,278 @@
"use client"; "use client";
import { Loader2, Save } from "lucide-react"; import { X } from "lucide-react";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AdminField, AdminSection } from "@/components/admin"; import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { BRAND_LIMITS, siteDocumentTitle } from "@/lib/brand"; import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
export default function BasicIdentityPanel({ export default function BasicIdentityPanel({
initial, initial,
onSaved, onSaved,
onDirtyChange,
}: { }: {
initial: PublicSettings; initial: PublicSettings;
onSaved?: (next: PublicSettings) => void; onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const [name, setName] = useState(initial.site_name); const [name, setName] = useState(initial.site_name);
const [wordmark, setWordmark] = useState(initial.site_wordmark); const [wordmark, setWordmark] = useState(initial.site_wordmark);
const [slogan, setSlogan] = useState(initial.site_slogan); const [slogan, setSlogan] = useState(initial.site_slogan);
const [desc, setDesc] = useState(initial.site_description); const [desc, setDesc] = useState(initial.site_description);
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
const [kwInput, setKwInput] = useState("");
const [saved, setSaved] = useState({ const [saved, setSaved] = useState({
name: initial.site_name, name: initial.site_name,
wordmark: initial.site_wordmark, wordmark: initial.site_wordmark,
slogan: initial.site_slogan, slogan: initial.site_slogan,
desc: initial.site_description, desc: initial.site_description,
keywords: initial.site_keywords,
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [nameError, setNameError] = useState("");
const [saveError, setSaveError] = useState("");
const saveGen = useRef(0);
const dirty = const dirty =
name.trim() !== saved.name || name.trim() !== saved.name ||
wordmark.trim() !== saved.wordmark || wordmark.trim() !== saved.wordmark ||
slogan.trim() !== saved.slogan || slogan.trim() !== saved.slogan ||
desc.trim() !== saved.desc; desc.trim() !== saved.desc ||
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const preview = siteDocumentTitle(name, slogan); const preview = siteDocumentTitle(name, slogan);
const addKeyword = (raw: string) => {
const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return;
setSaveError("");
setKeywords((prev) => {
const next = [...prev];
const seen = new Set(next.map((k) => k.toLowerCase()));
for (const k of parts) {
if (runeCount(k) > BRAND_LIMITS.keywordRunes) {
toast(`关键词「${k}」超过 ${BRAND_LIMITS.keywordRunes} 字`);
continue;
}
if (seen.has(k.toLowerCase())) continue;
if (next.length >= BRAND_LIMITS.keywordCount) {
toast(`最多 ${BRAND_LIMITS.keywordCount} 个关键词`);
break;
}
seen.add(k.toLowerCase());
next.push(k);
}
return next;
});
setKwInput("");
};
const discard = () => {
setName(saved.name);
setWordmark(saved.wordmark);
setSlogan(saved.slogan);
setDesc(saved.desc);
setKeywords([...saved.keywords]);
setKwInput("");
setNameError("");
setSaveError("");
};
const save = async () => { const save = async () => {
if (saving) return; if (saving) return;
const nextName = name.trim(); const snap = {
if (!nextName) { name: name.trim(),
wordmark: wordmark.trim(),
slogan: slogan.trim(),
desc: desc.trim(),
keywords: [...keywords],
};
if (!snap.name) {
setNameError("显示名称不能为空");
toast("显示名称不能为空"); toast("显示名称不能为空");
return; return;
} }
setNameError("");
setSaveError("");
const ticket = ++saveGen.current;
setSaving(true); setSaving(true);
try { try {
const res = await apiUpdateSiteSettings({ const res = await apiUpdateSiteSettings({
site_name: nextName, site_name: snap.name,
site_wordmark: wordmark.trim(), site_wordmark: snap.wordmark,
site_slogan: slogan.trim(), site_slogan: snap.slogan,
site_description: desc.trim(), site_description: snap.desc,
site_keywords: snap.keywords,
}); });
setName(res.site_name); if (ticket !== saveGen.current) return;
setWordmark(res.site_wordmark);
setSlogan(res.site_slogan);
setDesc(res.site_description);
setSaved({ setSaved({
name: res.site_name, name: res.site_name,
wordmark: res.site_wordmark, wordmark: res.site_wordmark,
slogan: res.site_slogan, slogan: res.site_slogan,
desc: res.site_description, desc: res.site_description,
keywords: res.site_keywords,
}); });
setName((v) => (v.trim() === snap.name ? res.site_name : v));
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
setKeywords((prev) => (JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev));
onSaved?.(res); onSaved?.(res);
toast("基本信息已保存", "ok"); toast("基础资料已保存", "ok");
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : "保存失败"); if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
setSaveError(msg);
toast(msg);
} finally { } finally {
setSaving(false); if (ticket === saveGen.current) setSaving(false);
} }
}; };
return ( return (
<div className="admin-settings-grid"> <AdminSettingsGroup
<AdminSection title="名称与字标" description="显示名称用于版权与标题;无 Logo 时页眉改用字标"> title="基础资料"
<div className="flex flex-col gap-4"> description="用于页眉、浏览器标题、公开页摘要与搜索关键词。保存后对访客生效。"
<AdminField footer={
id="site-name" <AdminSettingsActions
label="显示名称" dirty={dirty}
maxLength={32} saving={saving}
value={name} saveLabel="保存基础资料"
onChange={setName} error={saveError}
placeholder="姜十三论坛" onSave={() => void save()}
/> onDiscard={discard}
<AdminField />
id="site-wordmark" }
label="字标" >
hint="留空则回退为显示名称,最多 16 字" <AdminSettingsRow label="显示名称" htmlFor="site-name" hintBelow="用于版权行与浏览器标题">
maxLength={BRAND_LIMITS.wordmark} <AdminField
value={wordmark} id="site-name"
onChange={setWordmark} label="显示名称"
placeholder="姜十三" embed
/> className="admin-settings-control-text"
</div> maxLength={32}
</AdminSection> value={name}
<AdminSection onChange={(v) => {
title="标语与简介" setName(v);
description="标语出现在浏览器标题和页眉副标题" if (nameError) setNameError("");
extra={ if (saveError) setSaveError("");
<button }}
type="button" placeholder="姜十三论坛"
className="btn btn-primary btn-sm" error={nameError}
disabled={!dirty || saving} />
onClick={() => void save()} </AdminSettingsRow>
> <AdminSettingsRow
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} label="字标"
保存 htmlFor="site-wordmark"
</button> hintBelow="无 Logo 或纯文字页眉时使用;留空则使用显示名称"
>
<AdminField
id="site-wordmark"
label="字标"
embed
className="admin-settings-control-text"
maxLength={BRAND_LIMITS.wordmark}
value={wordmark}
onChange={(v) => {
setWordmark(v);
if (saveError) setSaveError("");
}}
placeholder="姜十三"
/>
</AdminSettingsRow>
<AdminSettingsRow
label="标语"
htmlFor="site-slogan"
hintBelow={
<>
出现在浏览器标题和页眉副标题。预览:
<span style={{ color: "var(--ink)" }}>{preview}</span>
</>
} }
> >
<div className="flex flex-col gap-4"> <AdminField
<AdminField id="site-slogan"
id="site-slogan" label="标语"
label="标语" embed
maxLength={BRAND_LIMITS.slogan} className="admin-settings-control-text"
value={slogan} maxLength={BRAND_LIMITS.slogan}
onChange={setSlogan} value={slogan}
placeholder="技术分享与讨论" onChange={(v) => {
/> setSlogan(v);
<p className="meta text-[12.5px]"> if (saveError) setSaveError("");
标题预览:<span style={{ color: "var(--ink)" }}>{preview}</span> }}
</p> placeholder="技术分享与讨论"
<AdminField />
id="site-desc" </AdminSettingsRow>
label="简介" <AdminSettingsRow label="简介" htmlFor="site-desc" hintBelow="用于页面摘要与搜索描述" wide>
as="textarea" <AdminField
minHeight="96px" id="site-desc"
maxLength={160} label="简介"
value={desc} embed
onChange={setDesc} as="textarea"
placeholder="一句话介绍社区定位…" className="admin-settings-control-long"
/> minHeight="96px"
maxLength={160}
value={desc}
onChange={(v) => {
setDesc(v);
if (saveError) setSaveError("");
}}
placeholder="一句话介绍社区定位…"
/>
</AdminSettingsRow>
<AdminSettingsRow
label="SEO 关键词"
htmlFor="seo-keyword-input"
hintBelow={`用于搜索结果。最多 ${BRAND_LIMITS.keywordCount} 个,每个 ${BRAND_LIMITS.keywordRunes} 字。点击已添加的词可删除。`}
wide
>
<div className="admin-settings-control-long">
<div className="admin-settings-keywords">
{keywords.map((k) => (
<button
key={k}
type="button"
className="admin-settings-kw"
onClick={() => {
setKeywords((prev) => prev.filter((x) => x !== k));
if (saveError) setSaveError("");
}}
>
{k}
<X size={12} />
</button>
))}
{keywords.length === 0 ? <span className="meta text-[12.5px]">尚未添加</span> : null}
</div>
<div className="flex gap-2 items-center">
<div className="admin-field-shell flex-1 min-w-0">
<input
id="seo-keyword-input"
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "," || e.key === ",") {
e.preventDefault();
addKeyword(kwInput);
}
}}
placeholder="输入后回车添加"
className="admin-field-input"
aria-label="添加关键词"
/>
</div>
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => addKeyword(kwInput)}>
添加
</button>
</div>
</div> </div>
</AdminSection> </AdminSettingsRow>
</div> </AdminSettingsGroup>
); );
} }

View File

@@ -88,7 +88,7 @@ export default function BrandMediaPicker({
data-tip="用作草稿预览,点保存后才生效" data-tip="用作草稿预览,点保存后才生效"
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="" className="w-full h-full object-contain" loading="lazy" /> <img src={item.url} alt={`${SLOT_LABEL[slot]}候选图`} className="w-full h-full object-contain" loading="lazy" />
<span <span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100" className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }} style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}

View File

@@ -1,17 +1,20 @@
"use client"; "use client";
import { ImagePlus, Images, Loader2, Save, X } from "lucide-react"; import { ImagePlus, Images, X } from "lucide-react";
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { AdminSection, AdminSegmented } from "@/components/admin"; import {
AdminSegmented,
AdminSettingsActions,
AdminSettingsGroup,
AdminSettingsRow,
} from "@/components/admin";
import BrandLockup from "@/components/BrandLockup"; import BrandLockup from "@/components/BrandLockup";
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api"; import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
import { import {
BRAND_LIMITS,
BRAND_LOGO_FITS, BRAND_LOGO_FITS,
BRAND_LOGO_SIZES, BRAND_LOGO_SIZES,
brandAccept, brandAccept,
brandFileOk, brandFileOk,
runeCount,
type BrandLogoFit, type BrandLogoFit,
type BrandLogoSize, type BrandLogoSize,
type BrandMark, type BrandMark,
@@ -41,18 +44,20 @@ function slotDirty(d: SlotDraft, saved: string): boolean {
return d.committed !== saved; return d.committed !== saved;
} }
const SLOTS: { id: BrandSlot; label: string; hint: string }[] = [ const SLOTS: { id: BrandSlot; label: string; hint: string; swatch: "light" | "dark" | "neutral" }[] = [
{ id: "logo_light", label: "浅色 Logo", hint: "浅色主题。只传一套时,暗色共用这张。" }, { id: "logo_light", label: "浅色主题 Logo", hint: "浅色主题页眉。只传一套时,暗色共用这张。", swatch: "light" },
{ id: "logo_dark", label: "暗色 Logo", hint: "暗色主题。PNG / WebP / SVG,不超过 2MB。" }, { id: "logo_dark", label: "深色主题 Logo", hint: "深色主题页眉。PNG / WebP / SVG,不超过 2MB。", swatch: "dark" },
{ id: "favicon", label: "Favicon", hint: "标签图标。另支持 ICO,不超过 512KB。" }, { id: "favicon", label: "Favicon", hint: "浏览器标签图标。另支持 ICO,不超过 512KB。", swatch: "neutral" },
]; ];
export default function BrandSeoPanel({ export default function BrandSeoPanel({
initial, initial,
onSaved, onSaved,
onDirtyChange,
}: { }: {
initial: PublicSettings; initial: PublicSettings;
onSaved?: (next: PublicSettings) => void; onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const [light, setLight] = useState<SlotDraft>(() => emptyDraft(initial.logo_light_url)); const [light, setLight] = useState<SlotDraft>(() => emptyDraft(initial.logo_light_url));
const [dark, setDark] = useState<SlotDraft>(() => emptyDraft(initial.logo_dark_url)); const [dark, setDark] = useState<SlotDraft>(() => emptyDraft(initial.logo_dark_url));
@@ -61,19 +66,18 @@ export default function BrandSeoPanel({
light: initial.logo_light_url, light: initial.logo_light_url,
dark: initial.logo_dark_url, dark: initial.logo_dark_url,
favicon: initial.favicon_url, favicon: initial.favicon_url,
keywords: initial.site_keywords,
mark: initial.brand_mark, mark: initial.brand_mark,
size: initial.brand_logo_size, size: initial.brand_logo_size,
fit: initial.brand_logo_fit, fit: initial.brand_logo_fit,
}); });
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
const [mark, setMark] = useState<BrandMark>(initial.brand_mark); const [mark, setMark] = useState<BrandMark>(initial.brand_mark);
const [logoSize, setLogoSize] = useState<BrandLogoSize>(initial.brand_logo_size); const [logoSize, setLogoSize] = useState<BrandLogoSize>(initial.brand_logo_size);
const [logoFit, setLogoFit] = useState<BrandLogoFit>(initial.brand_logo_fit); const [logoFit, setLogoFit] = useState<BrandLogoFit>(initial.brand_logo_fit);
const [kwInput, setKwInput] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [picker, setPicker] = useState<BrandSlot | null>(null); const [picker, setPicker] = useState<BrandSlot | null>(null);
const blobs = useRef<string[]>([]); const blobs = useRef<string[]>([]);
const saveGen = useRef(0);
useEffect(() => { useEffect(() => {
const held = blobs; const held = blobs;
@@ -89,6 +93,7 @@ export default function BrandSeoPanel({
}; };
const replaceFile = (slot: BrandSlot, file: File | null) => { const replaceFile = (slot: BrandSlot, file: File | null) => {
setSaveError("");
const [, set] = drafts[slot]; const [, set] = drafts[slot];
set((prev) => { set((prev) => {
if (prev.blob) URL.revokeObjectURL(prev.blob); if (prev.blob) URL.revokeObjectURL(prev.blob);
@@ -98,62 +103,52 @@ export default function BrandSeoPanel({
}); });
}; };
const keywordsDirty = JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
const dirty = const dirty =
slotDirty(light, saved.light) || slotDirty(light, saved.light) ||
slotDirty(dark, saved.dark) || slotDirty(dark, saved.dark) ||
slotDirty(favicon, saved.favicon) || slotDirty(favicon, saved.favicon) ||
keywordsDirty ||
mark !== saved.mark || mark !== saved.mark ||
logoSize !== saved.size || logoSize !== saved.size ||
logoFit !== saved.fit; logoFit !== saved.fit;
const addKeyword = (raw: string) => { useEffect(() => {
const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean); onDirtyChange?.(dirty);
if (parts.length === 0) return; }, [dirty, onDirtyChange]);
setKeywords((prev) => {
const next = [...prev];
const seen = new Set(next.map((k) => k.toLowerCase()));
for (const k of parts) {
if (runeCount(k) > BRAND_LIMITS.keywordRunes) {
toast(`关键词「${k}」超过 ${BRAND_LIMITS.keywordRunes} 字`);
continue;
}
if (seen.has(k.toLowerCase())) continue;
if (next.length >= BRAND_LIMITS.keywordCount) {
toast(`最多 ${BRAND_LIMITS.keywordCount} 个关键词`);
break;
}
seen.add(k.toLowerCase());
next.push(k);
}
return next;
});
setKwInput("");
};
const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise<string> => { const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise<string> => {
if (draft.file) { if (draft.file) {
const url = await apiUploadBrand(slot, draft.file); const url = await apiUploadBrand(slot, draft.file);
const [, set] = drafts[slot];
set((prev) => {
if (prev.blob) URL.revokeObjectURL(prev.blob);
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
});
return url; return url;
} }
if (draft.mediaId != null) { if (draft.mediaId != null) {
const url = await apiBrandFromMedia(slot, draft.mediaId); return apiBrandFromMedia(slot, draft.mediaId);
const [, set] = drafts[slot];
set({ committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" });
return url;
} }
return draft.committed; return draft.committed;
}; };
const discard = () => {
[light, dark, favicon].forEach((d) => {
if (d.blob) URL.revokeObjectURL(d.blob);
});
setLight(emptyDraft(saved.light));
setDark(emptyDraft(saved.dark));
setFavicon(emptyDraft(saved.favicon));
setMark(saved.mark);
setLogoSize(saved.size);
setLogoFit(saved.fit);
setSaveError("");
};
const save = async () => { const save = async () => {
if (saving || !dirty) return; if (saving || !dirty) return;
const ticket = ++saveGen.current;
const snap = {
mark,
size: logoSize,
fit: logoFit,
};
setSaving(true); setSaving(true);
setSaveError("");
try { try {
const logoLight = await resolveSlot("logo_light", light); const logoLight = await resolveSlot("logo_light", light);
const logoDark = await resolveSlot("logo_dark", dark); const logoDark = await resolveSlot("logo_dark", dark);
@@ -162,53 +157,75 @@ export default function BrandSeoPanel({
logo_light_url: logoLight, logo_light_url: logoLight,
logo_dark_url: logoDark, logo_dark_url: logoDark,
favicon_url: fav, favicon_url: fav,
site_keywords: keywords,
brand_mark: mark, brand_mark: mark,
brand_logo_size: logoSize, brand_logo_size: logoSize,
brand_logo_fit: logoFit, brand_logo_fit: logoFit,
}); });
setLight(emptyDraft(res.logo_light_url)); if (ticket !== saveGen.current) return;
setDark(emptyDraft(res.logo_dark_url));
setFavicon(emptyDraft(res.favicon_url));
setKeywords(res.site_keywords);
setMark(res.brand_mark);
setLogoSize(res.brand_logo_size);
setLogoFit(res.brand_logo_fit);
setSaved({ setSaved({
light: res.logo_light_url, light: res.logo_light_url,
dark: res.logo_dark_url, dark: res.logo_dark_url,
favicon: res.favicon_url, favicon: res.favicon_url,
keywords: res.site_keywords,
mark: res.brand_mark, mark: res.brand_mark,
size: res.brand_logo_size, size: res.brand_logo_size,
fit: res.brand_logo_fit, fit: res.brand_logo_fit,
}); });
setMark((v) => (v === snap.mark ? res.brand_mark : v));
setLogoSize((v) => (v === snap.size ? res.brand_logo_size : v));
setLogoFit((v) => (v === snap.fit ? res.brand_logo_fit : v));
setLight((cur) => {
if (cur.file === light.file && cur.mediaId === light.mediaId && cur.committed === light.committed) {
if (cur.blob) URL.revokeObjectURL(cur.blob);
return emptyDraft(res.logo_light_url);
}
return cur;
});
setDark((cur) => {
if (cur.file === dark.file && cur.mediaId === dark.mediaId && cur.committed === dark.committed) {
if (cur.blob) URL.revokeObjectURL(cur.blob);
return emptyDraft(res.logo_dark_url);
}
return cur;
});
setFavicon((cur) => {
if (cur.file === favicon.file && cur.mediaId === favicon.mediaId && cur.committed === favicon.committed) {
if (cur.blob) URL.revokeObjectURL(cur.blob);
return emptyDraft(res.favicon_url);
}
return cur;
});
onSaved?.(res); onSaved?.(res);
toast("品牌已保存", "ok"); toast("品牌设置已保存", "ok");
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : "保存失败"); if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
setSaveError(msg);
toast(msg);
} finally { } finally {
setSaving(false); if (ticket === saveGen.current) setSaving(false);
} }
}; };
const renderSlot = (slot: BrandSlot, label: string, hint: string, draft: SlotDraft) => { const renderSlot = (slot: BrandSlot, label: string, hint: string, swatch: "light" | "dark" | "neutral", draft: SlotDraft) => {
const preview = previewOf(draft); const preview = previewOf(draft);
const swatchClass =
swatch === "light"
? "admin-brand-swatch-light"
: swatch === "dark"
? "admin-brand-swatch-dark"
: "admin-brand-swatch-neutral";
return ( return (
<div key={slot}> <div key={slot} className="admin-brand-slot-card">
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}> <p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{label} {label}
</p> </p>
<p className="meta text-[12px] mt-0.5 mb-2">{hint}</p> <p className="meta text-[12px] mt-0.5 mb-2">{hint}</p>
<div <div className={`admin-brand-swatch ${swatchClass}`}>
className="h-28 rounded-xl flex items-center justify-center px-3"
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
>
{preview ? ( {preview ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" className="max-h-20 max-w-full object-contain" /> <img src={preview} alt={`${label}预览`} />
) : ( ) : (
<span className="meta text-[12.5px]">未设置</span> <span className="text-[12.5px]">未设置</span>
)} )}
</div> </div>
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
@@ -218,6 +235,8 @@ export default function BrandSeoPanel({
type="file" type="file"
accept={brandAccept(slot)} accept={brandAccept(slot)}
className="hidden" className="hidden"
aria-label={`上传${label}`}
disabled={saving}
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
e.target.value = ""; e.target.value = "";
@@ -231,13 +250,13 @@ export default function BrandSeoPanel({
}} }}
/> />
</label> </label>
<button type="button" className="btn btn-line btn-sm" onClick={() => setPicker(slot)}> <button type="button" className="btn btn-line btn-sm" disabled={saving} onClick={() => setPicker(slot)}>
<Images size={14} /> 媒体库 <Images size={14} /> 媒体库
</button> </button>
<button <button
type="button" type="button"
className="btn btn-line btn-sm" className="btn btn-line btn-sm"
disabled={!preview} disabled={saving || !preview}
onClick={() => { onClick={() => {
if (draft.blob) URL.revokeObjectURL(draft.blob); if (draft.blob) URL.revokeObjectURL(draft.blob);
drafts[slot][1](emptyDraft("")); drafts[slot][1](emptyDraft(""));
@@ -251,34 +270,43 @@ export default function BrandSeoPanel({
}; };
return ( return (
<AdminSection <AdminSettingsGroup
title="品牌与 SEO" title="品牌与图标"
description="图加文字和纯图共用画幅与铺法。预览条即时变化,保存后访客才看到。" description="预览随编辑更新,保存后对访客生效。"
extra={ footer={
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}> <AdminSettingsActions
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} dirty={dirty}
保存品牌 saving={saving}
</button> saveLabel="保存品牌设置"
error={saveError}
onSave={() => void save()}
onDiscard={discard}
/>
} }
> >
<div className="flex flex-col gap-4 mb-5"> <AdminSettingsRow label="页眉形式" hintBelow="图加文字和纯图共用画幅与铺法">
<AdminSegmented <AdminSegmented
role="group" role="group"
size="sm" size="sm"
ariaLabel="页眉形式" ariaLabel="页眉形式"
value={mark} value={mark}
onChange={setMark} onChange={(v) => {
setMark(v);
if (saveError) setSaveError("");
}}
options={[ options={[
{ key: "image_text", label: "图加文字" }, { key: "image_text", label: "图加文字" },
{ key: "image", label: "纯图" }, { key: "image", label: "纯图" },
{ key: "text", label: "纯文字" }, { key: "text", label: "纯文字" },
]} ]}
/> />
<div </AdminSettingsRow>
className="rounded-xl overflow-hidden" <AdminSettingsRow
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }} label="品牌预览"
> hintBelow={dirty ? "页眉效果预览;站点仍显示已保存版本,保存后对访客生效" : "页眉效果预览,保存后对访客生效"}
<div className="h-[68px] px-4 flex items-center"> >
<div className="admin-brand-lockup-preview">
<div className="admin-brand-lockup-preview-bar">
<BrandLockup <BrandLockup
mode={mark} mode={mark}
size={logoSize} size={logoSize}
@@ -292,9 +320,11 @@ export default function BrandSeoPanel({
/> />
</div> </div>
</div> </div>
{mark !== "text" ? ( </AdminSettingsRow>
<> {mark !== "text" ? (
<div className="flex flex-wrap gap-2" role="group" aria-label="图槽画幅"> <>
<AdminSettingsRow label="Logo 比例">
<div className="flex flex-wrap gap-2" role="group" aria-label="Logo 比例">
{BRAND_LOGO_SIZES.map((item) => { {BRAND_LOGO_SIZES.map((item) => {
const on = logoSize === item.id; const on = logoSize === item.id;
const thumbH = 16; const thumbH = 16;
@@ -309,7 +339,10 @@ export default function BrandSeoPanel({
color: on ? "var(--accent)" : "var(--ink)", color: on ? "var(--accent)" : "var(--ink)",
boxShadow: on ? "inset 0 0 0 1px var(--accent)" : "inset 0 0 0 1px var(--line)", boxShadow: on ? "inset 0 0 0 1px var(--accent)" : "inset 0 0 0 1px var(--line)",
}} }}
onClick={() => setLogoSize(item.id)} onClick={() => {
setLogoSize(item.id);
if (saveError) setSaveError("");
}}
> >
<span <span
aria-hidden aria-hidden
@@ -322,76 +355,47 @@ export default function BrandSeoPanel({
/> />
<span> <span>
<span className="block text-[13px] font-medium">{item.label}</span> <span className="block text-[13px] font-medium">{item.label}</span>
<span className="meta text-[12px]">{item.w}×{item.h}</span> <span className="meta text-[12px]">
{item.w}×{item.h}
</span>
</span> </span>
</button> </button>
); );
})} })}
</div> </div>
</AdminSettingsRow>
<AdminSettingsRow label="适配方式" hintBelow="图片如何铺进所选画幅">
<AdminSegmented <AdminSegmented
role="group" role="group"
size="sm" size="sm"
ariaLabel="图槽铺法" ariaLabel="适配方式"
value={logoFit} value={logoFit}
onChange={setLogoFit} onChange={(v) => {
setLogoFit(v);
if (saveError) setSaveError("");
}}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))} options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
/> />
</> </AdminSettingsRow>
) : ( </>
<p className="meta text-[12.5px]">纯文字不显示图槽。画幅和铺法只作用于图加文字和纯图。</p> ) : (
)} <AdminSettingsRow label="画幅与铺法">
</div> <p className="meta text-[12.5px]">纯文字页眉不显示 Logo。画幅和铺法只作用于图加文字和纯图。</p>
<div className="admin-brand-grid"> </AdminSettingsRow>
{SLOTS.map((s) => )}
renderSlot( <AdminSettingsRow label="站点图标" hintBelow="设置不同主题的站点标识及浏览器图标" wide>
s.id, <div className="admin-brand-slots">
s.label, {SLOTS.map((s) =>
s.hint, renderSlot(
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light s.id,
) s.label,
)} s.hint,
<div className="admin-brand-keywords"> s.swatch,
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}> s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
SEO 关键词 )
</p> )}
<p className="meta text-[12px] mt-0.5 mb-2">最多 16 个,每个 20 字。点芯片可删除。</p>
<div className="flex flex-wrap gap-1.5 mb-3">
{keywords.map((k) => (
<button
key={k}
type="button"
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[12.5px]"
style={{ background: "var(--panel-2)", color: "var(--ink)", boxShadow: "inset 0 0 0 1px var(--line)" }}
onClick={() => setKeywords((prev) => prev.filter((x) => x !== k))}
>
{k}
<X size={12} />
</button>
))}
{keywords.length === 0 ? <span className="meta text-[12.5px]">尚未添加</span> : null}
</div>
<div className="flex gap-2 max-w-xl">
<input
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === "," || e.key === ",") {
e.preventDefault();
addKeyword(kwInput);
}
}}
placeholder="输入后回车添加"
className="flex-1 min-w-0 px-3 py-2 text-sm rounded-lg"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
aria-label="添加关键词"
/>
<button type="button" className="btn btn-line btn-sm" onClick={() => addKeyword(kwInput)}>
添加
</button>
</div>
<p className="meta text-[12.5px] mt-3">{dirty ? "未保存,访客仍看到旧 Logo" : "已与线上一致"}</p>
</div> </div>
</div> </AdminSettingsRow>
<BrandMediaPicker <BrandMediaPicker
open={picker != null} open={picker != null}
slot={picker || "logo_light"} slot={picker || "logo_light"}
@@ -403,6 +407,6 @@ export default function BrandSeoPanel({
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url }); set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
}} }}
/> />
</AdminSection> </AdminSettingsGroup>
); );
} }

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react"; import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { AdminField, AdminSection } from "@/components/admin"; import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { import {
BRAND_LIMITS, BRAND_LIMITS,
@@ -19,48 +19,72 @@ function clone(links: FooterLink[]): FooterLink[] {
export default function FooterLinksPanel({ export default function FooterLinksPanel({
initial, initial,
onSaved, onSaved,
onDirtyChange,
}: { }: {
initial: PublicSettings; initial: PublicSettings;
onSaved?: (next: PublicSettings) => void; onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const [links, setLinks] = useState<FooterLink[]>(() => clone(initial.footer_links)); const [links, setLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
const [saved, setSaved] = useState<FooterLink[]>(() => clone(initial.footer_links)); const [savedLinks, setSavedLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const saveGen = useRef(0);
const dirty = JSON.stringify(links) !== JSON.stringify(saved); const dirty = JSON.stringify(links) !== JSON.stringify(savedLinks);
useEffect(() => {
onDirtyChange?.(dirty);
}, [dirty, onDirtyChange]);
const update = (i: number, patch: Partial<FooterLink>) => { const update = (i: number, patch: Partial<FooterLink>) => {
setError("");
setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item))); setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item)));
}; };
const discard = () => {
setLinks(clone(savedLinks));
setError("");
};
const save = async () => { const save = async () => {
if (saving || !dirty) return; if (saving || !dirty) return;
for (const item of links) { for (const item of links) {
if (!item.label.trim()) { if (!item.label.trim()) {
setError("链接文案不能为空");
toast("链接文案不能为空"); toast("链接文案不能为空");
return; return;
} }
if (!footerURLAllowed(item.url)) { if (!footerURLAllowed(item.url)) {
setError(`地址无效:${item.url || "(空)"}`);
toast(`地址无效:${item.url || "(空)"}`); toast(`地址无效:${item.url || "(空)"}`);
return; return;
} }
} }
setError("");
const snapLinks = clone(links).map((item) => ({
label: item.label.trim(),
url: item.url.trim(),
new_tab: item.new_tab,
}));
const ticket = ++saveGen.current;
setSaving(true); setSaving(true);
try { try {
const payload = links.map((item) => ({ const res = await apiUpdateSiteSettings({
label: item.label.trim(), footer_links: snapLinks,
url: item.url.trim(), });
new_tab: item.new_tab, if (ticket !== saveGen.current) return;
})); setSavedLinks(clone(res.footer_links));
const res = await apiUpdateSiteSettings({ footer_links: payload }); setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev));
setLinks(clone(res.footer_links));
setSaved(clone(res.footer_links));
onSaved?.(res); onSaved?.(res);
toast("页脚链接已保存", "ok"); toast("页脚已保存", "ok");
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : "保存失败"); if (ticket !== saveGen.current) return;
const msg = e instanceof Error ? e.message : "保存失败";
setError(msg);
toast(msg);
} finally { } finally {
setSaving(false); if (ticket === saveGen.current) setSaving(false);
} }
}; };
@@ -76,20 +100,28 @@ export default function FooterLinksPanel({
}; };
return ( return (
<div className="admin-settings-grid"> <AdminSettingsGroup
<AdminSection title="页脚"
title="页脚与合规" description="备案、协议等链接。保存后出现在站点页脚右侧。"
description="用于 ICP 备案、用户协议等。最多 8 条。外链默认新标签打开。" footer={
extra={ <AdminSettingsActions
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}> dirty={dirty}
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} saving={saving}
保存 saveLabel="保存页脚"
</button> error={error}
} onSave={() => void save()}
onDiscard={discard}
/>
}
>
<AdminSettingsRow
label="页脚链接"
hintBelow="用于 ICP 备案、用户协议等,最多 8 条。外链默认新标签打开。"
wide
> >
<div className="flex flex-col gap-4"> <div className="admin-settings-control-long flex flex-col gap-3">
{links.map((item, i) => ( {links.map((item, i) => (
<div key={i} className="rounded-xl p-3 flex flex-col gap-3" style={{ boxShadow: "inset 0 0 0 1px var(--line)" }}> <div key={i} className="admin-footer-item">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="meta text-[12px]">第 {i + 1} 条</span> <span className="meta text-[12px]">第 {i + 1} 条</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -154,20 +186,30 @@ export default function FooterLinksPanel({
<Plus size={14} /> 新增链接 <Plus size={14} /> 新增链接
</button> </button>
</div> </div>
</AdminSection> </AdminSettingsRow>
<AdminSection title="页脚预览" description="保存前仅在此处预览,前台页脚不会跟着变">
<div className="rounded-xl px-4 py-3 flex flex-wrap items-center gap-x-4 gap-y-2" style={{ background: "var(--panel-2)" }}> <AdminSettingsRow label="页脚预览" hintBelow="当前编辑预览,保存后页脚才会更新">
<span className="meta text-[12px] whitespace-nowrap">© {new Date().getFullYear()} {initial.site_name}</span> <div className="admin-footer-preview">
{links.filter((l) => l.label.trim() && l.url.trim()).map((item) => ( <div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
<span key={`${item.label}-${item.url}`} className="text-[12px]" style={{ color: "var(--ink-2)" }}> <span className="meta text-[12px] whitespace-nowrap">
{item.label.trim()} © {new Date().getFullYear()} {initial.site_name}
{item.new_tab ? " ↗" : ""}
</span> </span>
))} <span className="meta text-[12px] whitespace-nowrap">版本:— · 页面:— · 模板:—</span>
<span className="meta text-[12px] ml-auto">性能指标</span> </div>
{links.filter((l) => l.label.trim() && l.url.trim()).length > 0 ? (
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
{links
.filter((l) => l.label.trim() && l.url.trim())
.map((item) => (
<span key={`${item.label}-${item.url}`} className="text-[12px] break-all" style={{ color: "var(--ink-2)" }}>
{item.label.trim()}
{item.new_tab ? " ↗" : ""}
</span>
))}
</div>
) : null}
</div> </div>
<p className="meta text-[12px] mt-3">外链保存后带 target=_blank 与 rel=noopener。站内路径留在本页。</p> </AdminSettingsRow>
</AdminSection> </AdminSettingsGroup>
</div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,9 @@ import {
SiteDocMasthead, SiteDocMasthead,
SiteDocBreadcrumb, SiteDocBreadcrumb,
} from "@/components/site-doc"; } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -35,6 +38,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
type: "article", type: "article",
publishedTime: data.announcement.created_at, publishedTime: data.announcement.created_at,
}, },
...(await requestCanonical(`/announcement/${data.announcement.id}`)),
}; };
} }
@@ -71,15 +75,15 @@ export default async function AnnouncementPage({ params }: PageProps) {
); );
}; };
const jsonLd = { const jsonLd = webPageJsonLd({
"@context": "https://schema.org", type: "Article",
"@type": "Article", name: a.title,
headline: a.title, path: `/announcement/${a.id}`,
datePublished: a.created_at, datePublished: a.created_at,
dateModified: a.updated_at, dateModified: a.updated_at,
articleSection: "站点公告", articleSection: "站点公告",
keywords: a.tag, keywords: a.tag,
}; });
const left = ( const left = (
<> <>
@@ -97,10 +101,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
return ( return (
<> <>
<script <JsonLd data={jsonLd} />
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<SiteDocShell <SiteDocShell
left={left} left={left}

View File

@@ -14,16 +14,23 @@ import {
SiteDocPageNav, SiteDocPageNav,
SiteDocBreadcrumb, SiteDocBreadcrumb,
} from "@/components/site-doc"; } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
export const metadata: Metadata = { import { canonicalPage } from "@/lib/siteUrl";
title: "站点公告",
description: "站点公告与通知列表",
};
interface PageProps { interface PageProps {
searchParams: Promise<{ page?: string }>; searchParams: Promise<{ page?: string }>;
} }
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
const sp = await searchParams;
const page = canonicalPage(sp.page);
return {
title: "站点公告",
description: "站点公告与通知列表",
...(await requestCanonical("/announcements", page ? { page } : undefined)),
};
}
/** 粗略去掉 Markdown,取摘要 */ /** 粗略去掉 Markdown,取摘要 */
function plainSummary(md: string, max = 120): string { function plainSummary(md: string, max = 120): string {
const text = md const text = md

View File

@@ -3127,28 +3127,10 @@ body:has([data-admin-viewport]) {
} }
} }
/* 后台公告/单页编辑:取消画布内边距,顶栏贴紧 admin-topbar 钉住,避免缝里透出下方内容 */
.admin-canvas:has(.j13-compose-page) {
padding: 0;
}
/* 用户管理吸顶:顶内边距挪到 .admin-users-page,sticky 才能贴紧顶栏而不提前定住 */ /* 用户管理吸顶:顶内边距挪到 .admin-users-page,sticky 才能贴紧顶栏而不提前定住 */
.admin-canvas:has(.admin-users-stick) { .admin-canvas:has(.admin-users-stick) {
padding-top: 0; padding-top: 0;
} }
.admin-canvas .j13-compose-page,
.admin-canvas .j13-compose-stage {
min-height: 100%;
}
.admin-canvas .j13-compose-stage {
border: 0;
border-radius: 0;
box-shadow: none;
}
.admin-canvas .j13-compose-toolbar {
top: 0;
border-radius: 0;
background: var(--panel);
}
/* 移动端导航抽屉 */ /* 移动端导航抽屉 */
.admin-drawer { .admin-drawer {
@@ -3590,79 +3572,569 @@ a.j13-mention-token:hover {
font-size: 12.5px; font-size: 12.5px;
} }
/* 站点设置:顶栏横向 tab,窄屏横向滚动 */ /* 站点设置:限宽左对齐,不写入后台公共主容器 */
.admin-settings-tabs { .admin-canvas:has(.admin-settings-page) {
display: flex; background: var(--bg);
align-items: stretch;
gap: 0.125rem;
overflow-x: auto;
margin: 0 0 1.25rem;
border-bottom: 1px solid var(--line);
scrollbar-width: thin;
} }
.admin-settings-tab { @media (min-width: 1920px) {
appearance: none; .admin-canvas:has(.admin-settings-page) {
flex-shrink: 0; padding-left: 3rem; /* 宽屏距主导航最多约 48px;1024+ 默认 2rem */
display: inline-flex; }
align-items: center;
gap: 0.35rem;
border: none;
background: transparent;
padding: 0.7rem 0.95rem;
margin-bottom: -1px;
font-family: inherit;
font-size: 13.5px;
font-weight: 500;
line-height: 1.3;
color: var(--ink-2);
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: color 0.15s ease, border-color 0.15s ease;
} }
.admin-settings-tab:hover:not([aria-selected="true"]) { .admin-settings-page {
color: var(--ink); width: 100%;
max-width: 1120px;
margin-inline: 0;
container-type: inline-size;
container-name: settings-page;
} }
.admin-settings-tab[aria-selected="true"] { .admin-settings-page > .mb-5 {
color: var(--accent); margin-bottom: 1.25rem;
font-weight: 600;
border-bottom-color: var(--accent);
} }
.admin-settings-tab:focus-visible { .admin-settings-work {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.admin-settings-tab-soon {
font-size: 10.5px;
font-weight: 500;
color: var(--ink-3);
}
.admin-settings-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 10rem minmax(0, 1fr);
gap: 1rem; gap: 1.5rem;
align-items: start; align-items: start;
} }
@media (min-width: 1024px) { .admin-settings-nav-col {
.admin-settings-grid { min-width: 0;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.admin-settings-nav {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.admin-settings-nav-link {
display: flex;
align-items: center;
width: 100%;
min-height: 2.25rem;
padding: 0.4rem 0.7rem;
border-radius: 0.65rem;
font-size: 13.5px;
font-weight: 500;
line-height: 1.35;
color: var(--ink-2);
text-align: left;
border: none;
background: transparent;
cursor: pointer;
font-family: inherit;
text-decoration: none;
}
.admin-settings-nav-link:hover {
background: var(--panel-2);
color: var(--ink);
}
.admin-settings-nav-link:visited {
color: var(--ink-2);
}
.admin-settings-nav-link[aria-current="page"],
.admin-settings-nav-link[aria-current="page"]:visited {
background: var(--nav-active-bg);
color: var(--nav-active-ink);
font-weight: 600;
}
.admin-settings-nav-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.admin-settings-nav-soon {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--line);
}
.admin-settings-nav-soon-summary {
display: flex;
align-items: center;
gap: 0.35rem;
width: 100%;
min-height: 2rem;
padding: 0.3rem 0.7rem;
border-radius: 0.5rem;
font-size: 12px;
font-weight: 550;
line-height: 1.35;
color: var(--ink-3);
cursor: pointer;
list-style: none;
font-family: inherit;
}
.admin-settings-nav-soon-summary::-webkit-details-marker,
.admin-settings-nav-soon-summary::marker {
display: none;
content: "";
}
.admin-settings-nav-soon-summary::before {
content: "";
width: 0.35rem;
height: 0.35rem;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(-45deg);
flex-shrink: 0;
}
.admin-settings-nav-soon[open] .admin-settings-nav-soon-summary::before {
transform: rotate(45deg);
}
.admin-settings-nav-soon-summary:hover {
background: var(--panel-2);
color: var(--ink-2);
}
.admin-settings-nav-soon-summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.admin-settings-nav-soon-list {
margin: 0.15rem 0 0;
padding: 0 0.7rem 0.2rem;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.admin-settings-nav-soon-list li {
font-size: 12.5px;
line-height: 1.45;
color: var(--ink-3);
pointer-events: none;
}
.admin-settings-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
container-type: inline-size;
container-name: settings-form;
}
.admin-settings-stack {
display: flex;
flex-direction: column;
gap: 1.25rem;
min-width: 0;
}
.admin-settings-group {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
overflow: hidden;
}
.admin-settings-group-head {
padding: 1.25rem 1.25rem 0.75rem;
}
.admin-settings-group-title {
margin: 0;
font-size: 15px;
font-weight: 650;
color: var(--ink);
}
.admin-settings-group-desc {
margin: 0.3rem 0 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--ink-3);
}
.admin-settings-group-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1.25rem 1.25rem;
}
.admin-settings-group-footer {
padding: 0 1.25rem 1.25rem;
}
.admin-settings-group:has(.admin-settings-group-footer) .admin-settings-group-body {
padding-bottom: 0.75rem;
}
.admin-settings-git-json-wrap {
min-width: 0;
display: flex;
flex-direction: column;
min-height: 12rem;
}
.admin-settings-git-json {
min-height: 12rem;
width: 100%;
resize: vertical;
}
.admin-settings-md {
min-width: 0;
min-height: 16rem;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.35rem 0.85rem 0.55rem;
background: var(--panel);
}
.admin-settings-git-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.85rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--line);
font-size: 12.5px;
line-height: 1.45;
color: var(--ink-3);
}
.admin-settings-git-summary-kicker {
font-weight: 600;
color: var(--ink-2);
}
.admin-settings-git-summary-src {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--ink-2);
}
.admin-settings-git-summary-src strong {
font-weight: 600;
color: var(--ink);
}
.admin-settings-row {
display: grid;
grid-template-columns: 10.75rem minmax(0, 1fr);
gap: 0.35rem 1.5rem;
align-items: start;
}
.admin-settings-row-label {
min-width: 0;
padding-top: 0.5rem;
}
.admin-settings-row-name {
display: block;
font-size: 14px;
font-weight: 550;
line-height: 1.4;
color: var(--ink);
overflow-wrap: anywhere;
}
.admin-settings-row-hint {
margin: 0.25rem 0 0;
font-size: 12.5px;
line-height: 1.45;
color: var(--ink-3);
}
.admin-settings-row-hint.is-below {
margin-top: 0.4rem;
}
.admin-settings-row-error {
margin: 0.35rem 0 0;
font-size: 12.5px;
line-height: 1.45;
color: var(--danger);
}
.admin-settings-row-control {
min-width: 0;
}
.admin-settings-control-text {
max-width: min(32.5rem, 100%);
}
.admin-settings-control-long {
max-width: min(40rem, 100%);
}
.admin-settings-control-select {
max-width: min(22.5rem, 100%);
}
.admin-settings-control-num {
max-width: min(11.25rem, 100%);
}
.admin-settings-actions {
display: grid;
grid-template-columns: 10.75rem minmax(0, 1fr);
gap: 0.35rem 1.5rem;
align-items: center;
}
.admin-settings-actions-inner {
grid-column: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
min-width: 0;
}
.admin-settings-actions-status {
margin: 0;
font-size: 12.5px;
line-height: 1.4;
color: var(--ink-3);
}
.admin-settings-actions-status.is-error {
color: var(--danger);
}
.admin-settings-actions-btns {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.admin-settings-actions .btn:disabled {
opacity: 0.72;
}
.admin-settings-instant {
font-size: 12.5px;
color: var(--ink-3);
}
.admin-settings-divider {
height: 1px;
background: var(--line);
margin: 0.1rem 0;
}
.admin-brand-lockup-preview {
max-width: min(32.5rem, 100%);
border-radius: 12px;
overflow: hidden;
background: var(--panel-2);
box-shadow: inset 0 0 0 1px var(--line);
}
.admin-brand-lockup-preview-bar {
min-height: 68px;
padding: 0 1rem;
display: flex;
align-items: center;
}
.admin-brand-slots {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.admin-brand-slot-card {
flex: 1 1 11.5rem;
max-width: 16rem;
min-width: 10.5rem;
}
.admin-brand-swatch {
height: 5.5rem;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
overflow: hidden;
}
.admin-brand-swatch img {
max-height: 4rem;
max-width: 100%;
object-fit: contain;
}
.admin-brand-swatch-light {
background: #f4f6f3;
color: #3d4a44;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
}
.admin-brand-swatch-dark {
background: #1a221e;
color: #c5d0ca;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.admin-brand-swatch-neutral {
background: var(--panel-2);
color: var(--ink-3);
box-shadow: inset 0 0 0 1px var(--line);
}
.admin-settings-keywords {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.65rem;
}
.admin-settings-kw {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
border-radius: 999px;
padding: 0.2rem 0.65rem;
font-size: 12.5px;
color: var(--ink);
background: var(--panel-2);
box-shadow: inset 0 0 0 1px var(--line);
overflow-wrap: anywhere;
}
.admin-footer-item {
border-radius: 12px;
padding: 0.85rem;
box-shadow: inset 0 0 0 1px var(--line);
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.admin-footer-preview {
max-width: min(40rem, 100%);
border-radius: 12px;
padding: 0.75rem 1rem;
background: var(--panel-2);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
}
@container settings-page (max-width: 1039px) {
.admin-settings-work {
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
}
.admin-settings-nav {
flex-direction: row;
flex-wrap: wrap;
gap: 0.35rem;
}
.admin-settings-nav-link {
width: auto;
min-height: 2rem;
padding: 0.3rem 0.7rem;
background: var(--panel-2);
}
.admin-settings-nav-soon {
flex: 1 1 100%;
margin-top: 0.15rem;
padding-top: 0.5rem;
}
.admin-settings-nav-soon-list {
flex-direction: row;
flex-wrap: wrap;
gap: 0.35rem 0.85rem;
padding: 0.15rem 0.15rem 0;
} }
} }
/* 基本信息里的品牌槽:窄屏单列,宽屏浅色 / 暗色 / Favicon 并排 */ @container settings-form (max-width: 719px) {
.admin-brand-grid { .admin-settings-row,
display: grid; .admin-settings-actions {
grid-template-columns: 1fr; grid-template-columns: minmax(0, 1fr);
gap: 1rem; gap: 0.35rem;
align-items: start; }
.admin-settings-row-label {
padding-top: 0;
}
.admin-settings-actions-inner {
grid-column: 1;
}
} }
@media (min-width: 900px) {
.admin-brand-grid { /* 填满可视区:提交导入 JSON / 公告与单页正文,底部操作钉在视口内 */
grid-template-columns: repeat(3, minmax(0, 1fr)); @media (min-width: 1024px) {
.admin-canvas:has(.admin-settings-page.is-fill) {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 1.25rem;
} }
.admin-brand-keywords { .admin-settings-page.is-fill {
grid-column: 1 / -1; flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
} }
.admin-settings-page.is-fill > .mb-5 {
flex-shrink: 0;
}
.admin-settings-page.is-fill .admin-settings-work {
flex: 1 1 auto;
min-height: 0;
align-items: stretch;
}
.admin-settings-page.is-fill .admin-settings-main,
.admin-settings-page.is-fill .admin-settings-stack {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.admin-settings-page.is-fill .admin-settings-stack {
overflow: auto;
}
.admin-settings-page.is-fill .admin-settings-group.is-fill {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.admin-settings-page.is-fill .admin-settings-group.is-fill .admin-settings-group-head,
.admin-settings-page.is-fill .admin-settings-group.is-fill .admin-settings-group-footer {
flex-shrink: 0;
}
.admin-settings-page.is-fill .admin-settings-group.is-fill .admin-settings-group-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.admin-settings-git-editor,
.admin-settings-md-editor {
flex: 1 1 auto;
min-height: 12rem;
align-items: stretch;
}
.admin-settings-git-editor .admin-settings-row-control,
.admin-settings-md-editor .admin-settings-row-control {
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.admin-settings-git-json-wrap,
.admin-settings-md {
flex: 1 1 auto;
min-height: 0;
max-width: none;
}
.admin-settings-md {
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.35rem 0.85rem 0.55rem;
background: var(--panel);
}
.admin-settings-md .j13-md-editor {
flex: 1 1 auto;
min-height: 0;
}
.admin-settings-md .j13-md-textarea,
.admin-settings-md .j13-md-preview {
min-height: 0;
}
.admin-settings-git-json {
flex: 1 1 auto;
min-height: 0;
height: 100%;
overflow: auto;
resize: none;
}
@container settings-form (max-width: 719px) {
.admin-settings-git-editor,
.admin-settings-md-editor {
grid-template-rows: auto minmax(0, 1fr);
}
}
}
.admin-field-shell.has-count .admin-field-input {
padding-right: 0.5rem;
}
.admin-field-count.is-infield {
flex-shrink: 0;
padding: 0 10px 0 4px;
}
.admin-field-shell.is-area.has-count {
position: relative;
}
.admin-field-shell.is-area .admin-field-count.is-infield {
position: absolute;
top: 8px;
right: 10px;
padding: 0;
z-index: 1;
}
.admin-field-shell.is-area.has-count .admin-field-input {
padding-right: 3.5rem;
padding-top: 10px;
} }
.j13-brand-slot { .j13-brand-slot {
@@ -3979,38 +4451,59 @@ a.j13-mention-token:hover {
/* 策略条:横向排布,压缩垂直占用 */ /* 策略条:横向排布,压缩垂直占用 */
.admin-content-policy { .admin-content-policy {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 0.65rem 0.85rem; gap: 0.65rem 0.85rem;
flex-wrap: wrap; flex-wrap: wrap;
flex-shrink: 0; flex-shrink: 0;
padding: 0.5rem 0.75rem; padding: 0.55rem 0.75rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
background: var(--panel); background: var(--panel);
} }
.admin-content-policy-lead {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 0 1 13.5rem;
min-width: 8.5rem;
padding-top: 0.2rem;
}
.admin-content-policy-title { .admin-content-policy-title {
font-size: 12px; font-size: 12px;
font-weight: 650; font-weight: 650;
color: var(--ink-3); color: var(--ink-3);
flex-shrink: 0; }
.admin-content-policy-scope {
font-size: 11.5px;
line-height: 1.4;
color: var(--ink-3);
} }
.admin-content-policy-row { .admin-content-policy-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem 0.5rem; gap: 0.35rem 0.5rem;
flex: 1 1 auto; flex: 1 1 18rem;
min-width: 0; min-width: 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.admin-content-policy-chip { .admin-content-policy-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex-wrap: nowrap;
gap: 0.5rem; gap: 0.5rem;
padding: 0.28rem 0.55rem; padding: 0.28rem 0.55rem;
border-radius: 10px; border-radius: 10px;
background: var(--panel-2); background: var(--panel-2);
border: 1px solid transparent; border: 1px solid transparent;
min-height: 2.25rem; min-height: 2.25rem;
max-width: 100%;
flex: 0 1 auto;
}
.admin-content-policy-chip-copy {
display: flex;
flex-direction: column;
min-width: 0;
gap: 0.05rem;
} }
.admin-content-policy-chip-label { .admin-content-policy-chip-label {
font-size: 12.5px; font-size: 12.5px;
@@ -4018,6 +4511,15 @@ a.j13-mention-token:hover {
color: var(--ink); color: var(--ink);
white-space: nowrap; white-space: nowrap;
} }
.admin-content-policy-chip-how {
font-size: 11px;
font-weight: 500;
color: var(--ink-3);
white-space: nowrap;
}
.admin-content-policy-chip-ctrl {
flex-wrap: nowrap;
}
/* 内容管理:PC 左中右三栏等高;仅列表区滚动 */ /* 内容管理:PC 左中右三栏等高;仅列表区滚动 */
.admin-content-triptych { .admin-content-triptych {

View File

@@ -22,6 +22,7 @@ import { accentStyleCSS } from "@/lib/theme";
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg"; import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
import { siteDocumentTitle } from "@/lib/brand"; import { siteDocumentTitle } from "@/lib/brand";
import { motionConfigFromSettings } from "@/lib/motion"; import { motionConfigFromSettings } from "@/lib/motion";
import { requestMetadataBase } from "@/lib/canonicalMeta";
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正, // 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动 // 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
@@ -52,7 +53,10 @@ export async function generateMetadata(): Promise<Metadata> {
const title = siteDocumentTitle(name, settings.site_slogan || ""); const title = siteDocumentTitle(name, settings.site_slogan || "");
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区"; const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined; const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined;
const metadataBase = await requestMetadataBase();
return { return {
// 只提供解析相对 URL 的基址;Canonical 由各公开页自行声明,避免子路由继承首页
...(metadataBase ? { metadataBase } : {}),
title: { title: {
default: title, default: title,
template: `%s · ${name}`, template: `%s · ${name}`,

View File

@@ -17,6 +17,9 @@ import {
SiteDocMasthead, SiteDocMasthead,
SiteDocBreadcrumb, SiteDocBreadcrumb,
} from "@/components/site-doc"; } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface PageProps { interface PageProps {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
@@ -36,6 +39,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
type: "article", type: "article",
publishedTime: p.created_at, publishedTime: p.created_at,
}, },
...(await requestCanonical(`/p/${p.slug}`)),
}; };
} }
@@ -70,14 +74,13 @@ export default async function SitePagePublic({ params }: PageProps) {
); );
}; };
const jsonLd = { const jsonLd = webPageJsonLd({
"@context": "https://schema.org",
"@type": "WebPage",
name: p.title, name: p.title,
description: p.excerpt || undefined, description: p.excerpt || undefined,
path: `/p/${p.slug}`,
datePublished: p.created_at, datePublished: p.created_at,
dateModified: p.updated_at, dateModified: p.updated_at,
}; });
const left = ( const left = (
<> <>
@@ -99,10 +102,7 @@ export default async function SitePagePublic({ params }: PageProps) {
return ( return (
<> <>
<script <JsonLd data={jsonLd} />
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<SiteDocShell <SiteDocShell
left={left} left={left}

View File

@@ -15,7 +15,7 @@ import { siteDocumentTitle } from "@/lib/brand";
import { isAdminOrAbove } from "@/lib/roles"; import { isAdminOrAbove } from "@/lib/roles";
import Pagination from "@/components/Pagination"; import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow"; import PostRow from "@/components/PostRow";
import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs"; import FeedTabs from "@/components/home/FeedTabs";
import FeedNewTip from "@/components/home/FeedNewTip"; import FeedNewTip from "@/components/home/FeedNewTip";
import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav"; import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
import { SiteDocBreadcrumb } from "@/components/site-doc"; import { SiteDocBreadcrumb } from "@/components/site-doc";
@@ -25,25 +25,24 @@ import {
BoardPanelsMobile, BoardPanelsMobile,
BoardMissingPanel, BoardMissingPanel,
} from "@/components/home/BoardPanels"; } from "@/components/home/BoardPanels";
import { requestCanonical } from "@/lib/canonicalMeta";
export async function generateMetadata(): Promise<Metadata> { import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
const settings = await getPublicSettingsCached(); import { listingJsonLd } from "@/lib/jsonLd";
return { import JsonLd from "@/components/JsonLd";
title: { absolute: siteDocumentTitle(settings.site_name, settings.site_slogan) },
description: settings.site_description,
};
}
interface HomePageProps { interface HomePageProps {
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>; searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
} }
function normalizeSort(v: string | undefined, allowComments: boolean): FeedSort { export async function generateMetadata({ searchParams }: HomePageProps): Promise<Metadata> {
if (v === "recommended") return "recommended"; const settings = await getPublicSettingsCached();
if (v === "new") return "new"; const sp = await searchParams;
if (!allowComments) return "new"; const allowComments = settings.allow_comments !== false;
if (v === "latest") return "latest"; return {
return "latest"; title: { absolute: siteDocumentTitle(settings.site_name, settings.site_slogan) },
description: settings.site_description,
...(await requestCanonical("/", homeCanonicalQuery(sp, allowComments))),
};
} }
// 空结果面板(无帖 / 无搜索结果) // 空结果面板(无帖 / 无搜索结果)
@@ -90,7 +89,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const cookie = authCookieHeader(await cookies()); const cookie = authCookieHeader(await cookies());
const publicSettings = await getPublicSettingsCached(); const publicSettings = await getPublicSettingsCached();
const allowComments = publicSettings.allow_comments !== false; const allowComments = publicSettings.allow_comments !== false;
const sort = normalizeSort(sp.sort, allowComments); const sort = normalizeFeedSort(sp.sort, allowComments);
// 与 layout 共享同一份 /me 请求(React cache 去重) // 与 layout 共享同一份 /me 请求(React cache 去重)
const mePromise = getMeCached(cookie || undefined); const mePromise = getMeCached(cookie || undefined);
const postsPromise = fetchPosts( const postsPromise = fetchPosts(
@@ -125,10 +124,31 @@ export default async function HomePage({ searchParams }: HomePageProps) {
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏 // boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
const boardPanelsMissing = showBoardPanels && !boardSidebar; const boardPanelsMissing = showBoardPanels && !boardSidebar;
const pageName = isSearch
? `搜索“${keyword}”`
: activeBoard
? activeBoard.name
: sort === "new"
? "最新发布"
: sort === "recommended"
? "推荐帖"
: "最新讨论";
const listingLd = listingJsonLd({
siteName: publicSettings.site_name || "姜十三论坛",
siteDescription: publicSettings.site_description,
pageName,
path: "/",
query: homeCanonicalQuery(sp, allowComments),
items: postsData.posts.map((p) => ({ id: p.id, name: p.title })),
page,
pageSize: 20,
});
// 搜索态:单栏占满,无 Tabs/侧栏 // 搜索态:单栏占满,无 Tabs/侧栏
if (isSearch) { if (isSearch) {
return ( return (
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<JsonLd data={listingLd} />
<div className="mb-5"> <div className="mb-5">
<h1 className="text-[24px] sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}> <h1 className="text-[24px] sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
搜索“<span style={{ color: "var(--accent)" }}>{keyword}</span>” 搜索“<span style={{ color: "var(--accent)" }}>{keyword}</span>”
@@ -149,27 +169,23 @@ export default async function HomePage({ searchParams }: HomePageProps) {
page={page} page={page}
size={20} size={20}
basePath="/" basePath="/"
query={{ q: keyword }} query={{
q: keyword,
...(sort !== defaultFeedSort(allowComments) ? { sort } : {}),
}}
/> />
</div> </div>
); );
} }
// 置顶帖直接按后端 pinned DESC 顺序并入统一信息流;标题仅供屏幕阅读器 // 置顶帖直接按后端 pinned DESC 顺序并入统一信息流;标题仅供屏幕阅读器
const heading = activeBoard
? activeBoard.name
: sort === "new"
? "最新发布"
: sort === "recommended"
? "推荐帖"
: "最新讨论";
const pageQuery: Record<string, string> = {}; const pageQuery: Record<string, string> = {};
if (boardId > 0) pageQuery.board = String(boardId); if (boardId > 0) pageQuery.board = String(boardId);
if (sort !== "latest") pageQuery.sort = sort; if (sort !== defaultFeedSort(allowComments)) pageQuery.sort = sort;
return ( return (
<div> <div>
<JsonLd data={listingLd} />
<WorkspaceNavMobile boards={boards} user={user} /> <WorkspaceNavMobile boards={boards} user={user} />
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}> <div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}>
@@ -177,7 +193,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */} {/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h1 className="sr-only">{heading}</h1> <h1 className="sr-only">{pageName}</h1>
<div className="panel overflow-hidden"> <div className="panel overflow-hidden">
{activeBoard ? ( {activeBoard ? (

View File

@@ -43,6 +43,10 @@ import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons"; import { IconComment, IconEye } from "@/components/Icons";
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta"; import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc"; import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
import { discussionJsonLd, questionJsonLd, type JsonLdVisibleComment } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -50,8 +54,9 @@ interface PageProps {
searchParams: Promise<{ page?: string }>; searchParams: Promise<{ page?: string }>;
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const sp = await searchParams;
try { try {
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在" // 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
const { post } = await fetchPostDetail(id, postViewerCookieHeader(await cookies())); const { post } = await fetchPostDetail(id, postViewerCookieHeader(await cookies()));
@@ -66,6 +71,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const desc = publicBody const desc = publicBody
? publicBody.slice(0, 160) ? publicBody.slice(0, 160)
: post.access_hint || "部分内容需满足条件后可见"; : post.access_hint || "部分内容需满足条件后可见";
const page = canonicalPage(sp.page);
return { return {
title: post.title, title: post.title,
description: desc, description: desc,
@@ -74,6 +80,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
description: desc, description: desc,
type: "article", type: "article",
}, },
...(await requestCanonical(`/post/${post.id}`, page ? { page } : undefined)),
}; };
} catch { } catch {
return { title: "帖子不存在" }; return { title: "帖子不存在" };
@@ -505,39 +512,63 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
} }
: null; : null;
const jsonLd = { const entityUrl = absoluteCanonical(`/post/${post.id}`);
"@context": "https://schema.org", const pageUrl =
"@type": "DiscussionForumPosting", absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) ||
headline: post.title, entityUrl;
// 仅公开片段进 JSON-LD,避免 SEO 泄露隐藏块原文 const authorName = post.user.nickname || post.user.username;
articleBody: publicBody || undefined, const visibleFloors: JsonLdVisibleComment[] = allowComments
datePublished: post.created_at, ? comments
dateModified: post.updated_at, .filter((c) => !c.deleted && (c.content || "").trim())
author: { "@type": "Person", name: post.user.nickname }, .map((c) => ({
...(allowComments ? { commentCount: post.comment_count } : {}), id: c.id,
interactionStatistic: { content: c.content,
"@type": "InteractionCounter", created_at: c.created_at,
interactionType: { "@type": "ReadAction" }, author: c.user
userInteractionCount: post.view_count, ? { id: c.user.id, name: c.user.nickname || c.user.username }
}, : undefined,
...(allowComments && }))
acceptedAnswer && : [];
!acceptedAnswer.deleted && const acceptedForLd: JsonLdVisibleComment | null =
!!publicBody && { allowComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
acceptedAnswer: { ? {
"@type": "Comment", id: acceptedAnswer.id,
text: acceptedAnswer.content, content: acceptedAnswer.content,
dateCreated: acceptedAnswer.created_at, created_at: acceptedAnswer.created_at,
author: acceptedAnswer.user author: acceptedAnswer.user
? { ? {
"@type": "Person", id: acceptedAnswer.user.id,
name: acceptedAnswer.user.nickname || acceptedAnswer.user.username, name: acceptedAnswer.user.nickname || acceptedAnswer.user.username,
} }
: undefined, : undefined,
url: `#comment-${acceptedAnswer.id}`, }
}, : null;
}), // 问答帖才是「一问多答」;悬赏/投票仍是讨论帖,不套 QAPage,也不把采纳标成 Question.acceptedAnswer
}; const jsonLd =
postType === "question"
? questionJsonLd({
title: post.title,
text: publicBody || undefined,
createdAt: post.created_at,
updatedAt: edited ? post.updated_at : undefined,
author: { id: post.user.id, name: authorName },
postId: post.id,
pageUrl,
accepted: acceptedForLd,
answers: visibleFloors,
})
: discussionJsonLd({
title: post.title,
text: publicBody || undefined,
createdAt: post.created_at,
updatedAt: edited ? post.updated_at : undefined,
author: { id: post.user.id, name: authorName },
postId: post.id,
pageUrl,
commentCount: allowComments ? post.comment_count : undefined,
viewCount: post.view_count,
comments: visibleFloors,
});
// 仅正文 Markdown 标题与 headingRenderer 按序对应(时间线日锚点另有 tl-* id) // 仅正文 Markdown 标题与 headingRenderer 按序对应(时间线日锚点另有 tl-* id)
let headingIndex = 0; let headingIndex = 0;
@@ -557,7 +588,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
return ( return (
<> <>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <JsonLd data={jsonLd} />
{/* 移动端浮动操作条:长文读到中途可点赞 / 跳评论框 */} {/* 移动端浮动操作条:长文读到中途可点赞 / 跳评论框 */}
<MobilePostBar <MobilePostBar
postId={id} postId={id}
@@ -678,8 +709,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</span> </span>
</div> </div>
<p className="meta w-full text-[12px] sm:w-auto sm:ml-auto"> <p className="meta w-full text-[12px] sm:w-auto sm:ml-auto">
发布于 {formatDateTime(post.created_at)} 发布于 <time dateTime={post.created_at}>{formatDateTime(post.created_at)}</time>
{edited && <> · 更新于 {formatDateTime(post.updated_at)}</>} {edited && (
<>
{" "}
· 更新于 <time dateTime={post.updated_at}>{formatDateTime(post.updated_at)}</time>
</>
)}
</p> </p>
</div> </div>
</header> </header>

View File

@@ -1,11 +1,15 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { connection } from "next/server";
import { resolveSiteOrigin } from "@/lib/siteUrl";
export default function robots(): MetadataRoute.Robots { export default async function robots(): Promise<MetadataRoute.Robots> {
await connection();
const origin = resolveSiteOrigin();
return { return {
rules: { rules: {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
}, },
sitemap: "http://localhost:3000/sitemap.xml", ...(origin ? { sitemap: `${origin}/sitemap.xml` } : {}),
}; };
} }

View File

@@ -1,22 +1,31 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { connection } from "next/server";
import { import {
fetchPosts, fetchPosts,
fetchAnnouncementsPage, fetchAnnouncementsPage,
fetchPublishedSitePages, fetchPublishedSitePages,
} from "@/lib/api"; } from "@/lib/api";
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "http://localhost:3000"; await connection();
const origin = resolveSiteOrigin();
if (!origin) {
console.error("[seo] SITE_URL 缺失或非法,已跳过 sitemap");
return [];
}
const url = (pathname: string) => `${origin}${canonicalPath(pathname)}`;
const entries: MetadataRoute.Sitemap = [ const entries: MetadataRoute.Sitemap = [
{ url: `${baseUrl}/`, lastModified: new Date(), changeFrequency: "daily", priority: 1 }, { url: url("/"), lastModified: new Date(), changeFrequency: "daily", priority: 1 },
{ {
url: `${baseUrl}/announcements`, url: url("/announcements"),
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.7, priority: 0.7,
}, },
{ {
url: `${baseUrl}/about`, url: url("/about"),
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.6, priority: 0.6,
@@ -27,7 +36,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { posts } = await fetchPosts(1, 100); const { posts } = await fetchPosts(1, 100);
for (const post of posts) { for (const post of posts) {
entries.push({ entries.push({
url: `${baseUrl}/post/${post.id}`, url: url(`/post/${post.id}`),
lastModified: new Date(post.created_at), lastModified: new Date(post.created_at),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.6, priority: 0.6,
@@ -41,7 +50,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { announcements } = await fetchAnnouncementsPage(1, 100); const { announcements } = await fetchAnnouncementsPage(1, 100);
for (const a of announcements) { for (const a of announcements) {
entries.push({ entries.push({
url: `${baseUrl}/announcement/${a.id}`, url: url(`/announcement/${a.id}`),
lastModified: new Date(a.updated_at || a.created_at), lastModified: new Date(a.updated_at || a.created_at),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.5, priority: 0.5,
@@ -55,7 +64,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { pages } = await fetchPublishedSitePages(); const { pages } = await fetchPublishedSitePages();
for (const p of pages) { for (const p of pages) {
entries.push({ entries.push({
url: `${baseUrl}/p/${p.slug}`, url: url(`/p/${p.slug}`),
lastModified: new Date(p.updated_at || p.created_at), lastModified: new Date(p.updated_at || p.created_at),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.4, priority: 0.4,

View File

@@ -20,6 +20,10 @@ import RoleBadge from "@/components/RoleBadge";
import { roleMeta } from "@/lib/roles"; import { roleMeta } from "@/lib/roles";
import StartDMButton from "./StartDMButton"; import StartDMButton from "./StartDMButton";
import StaffUserMenu from "@/components/StaffUserMenu"; import StaffUserMenu from "@/components/StaffUserMenu";
import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
import { profileJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface UserPageProps { interface UserPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -28,16 +32,33 @@ interface UserPageProps {
export type UserTab = "posts" | "comments" | "settings" | "security"; export type UserTab = "posts" | "comments" | "settings" | "security";
export async function generateMetadata({ params }: UserPageProps): Promise<Metadata> { export async function generateMetadata({ params, searchParams }: UserPageProps): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const sp = await searchParams;
const rawTab = sp.tab;
const tab: UserTab =
rawTab === "comments" ? "comments" :
rawTab === "settings" ? "settings" :
rawTab === "security" ? "security" : "posts";
try { try {
const data = await fetchUserProfile(id, 1); const data = await fetchUserProfile(id, 1);
const allowComments = (await getPublicSettingsCached()).allow_comments !== false; const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
const title = data.user.nickname || data.user.username;
const description = allowComments
? `查看 ${data.user.nickname} 的资料、发帖与评论`
: `查看 ${data.user.nickname} 的资料与发帖`;
// 资料/安全 tab 仅本人可见,不声明 Canonical,避免被当成公开资料页或继承首页
if (tab === "settings" || tab === "security") {
return { title, description };
}
const query: Record<string, string | number> = {};
if (allowComments && tab === "comments") query.tab = "comments";
const page = canonicalPage(sp.page);
if (page) query.page = page;
return { return {
title: data.user.nickname || data.user.username, title,
description: allowComments description,
? `查看 ${data.user.nickname} 的资料、发帖与评论` ...(await requestCanonical(`/u/${data.user.id}`, query)),
: `查看 ${data.user.nickname} 的资料与发帖`,
}; };
} catch { } catch {
return { title: "用户不存在" }; return { title: "用户不存在" };
@@ -135,11 +156,26 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const visibleNav = navItems.filter((t) => !t.ownerOnly || isOwner); const visibleNav = navItems.filter((t) => !t.ownerOnly || isOwner);
const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`); const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
const profileQuery: Record<string, string | number> = {};
if (allowComments && effectiveTab === "comments") profileQuery.tab = "comments";
if (page > 1) profileQuery.page = page;
const profileLd =
effectiveTab === "settings" || effectiveTab === "security"
? null
: profileJsonLd({
id: u.id,
name: u.nickname || u.username,
username: u.username,
signature: (u.signature || "").trim() || undefined,
pageUrl: absoluteCanonical(`/u/${u.id}`, profileQuery),
});
/** 本页卡片统一阴影;暗色随 color-mix 自动加深 */ /** 本页卡片统一阴影;暗色随 color-mix 自动加深 */
const cardShadow = "0 1px 3px color-mix(in srgb, #000 18%, transparent)"; const cardShadow = "0 1px 3px color-mix(in srgb, #000 18%, transparent)";
return ( return (
<div> <div>
<JsonLd data={profileLd} />
<nav className="meta -mt-4 sm:-mt-6 mb-1.5 flex items-center gap-1 text-[12.5px] leading-none"> <nav className="meta -mt-4 sm:-mt-6 mb-1.5 flex items-center gap-1 text-[12.5px] leading-none">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link> <Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} /> <ChevronRight size={13} />

View File

@@ -38,7 +38,7 @@ export default function Avatar({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={src} src={src}
alt="" alt={name.trim() || "用户头像"}
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={() => setImgFailed(true)} onError={() => setImgFailed(true)}
/> />

View File

@@ -327,7 +327,12 @@ export default function AvatarCropModal({
className="absolute inset-0 rounded-full overflow-hidden bg-[var(--panel-2)] transition-transform hover:scale-105 disabled:cursor-default disabled:hover:scale-100" className="absolute inset-0 rounded-full overflow-hidden bg-[var(--panel-2)] transition-transform hover:scale-105 disabled:cursor-default disabled:hover:scale-100"
> >
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="" className="w-full h-full object-cover" loading="lazy" /> <img
src={item.url}
alt={item.kind === "avatar" ? "历史头像" : "我的图片"}
className="w-full h-full object-cover"
loading="lazy"
/>
</button> </button>
{/* hover 指示:成品头像=直接使用,其他图片=裁剪;选用中显示 spinner */} {/* hover 指示:成品头像=直接使用,其他图片=裁剪;选用中显示 spinner */}

View File

@@ -45,18 +45,18 @@ export default function BrandLockup({
{src ? ( {src ? (
both ? ( both ? (
<> <>
<Logo src={logoLight} fit={fit} extra="j13-brand-logo-light" /> <Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" />
<Logo src={logoDark} fit={fit} extra="j13-brand-logo-dark" /> <Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" />
</> </>
) : ( ) : (
<Logo src={src} fit={fit} /> <Logo src={src} fit={fit} alt={label} decorative={showText} />
) )
) : ( ) : (
<Bubble h={box.h} /> <Bubble h={box.h} label={showText ? undefined : label} />
)} )}
</span> </span>
) : ( ) : (
<Bubble h={36} /> <Bubble h={36} label={showText ? undefined : label} />
) )
) : null} ) : null}
{showText ? ( {showText ? (
@@ -75,10 +75,26 @@ export default function BrandLockup({
); );
} }
function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?: string }) { function Logo({
src,
fit,
alt,
decorative = false,
extra = "",
}: {
src: string;
fit: BrandLogoFit;
alt: string;
/** 旁边已有站名时,读屏跳过,HTML 仍保留 alt 供检索 */
decorative?: boolean;
extra?: string;
}) {
if (fit === "repeat") { if (fit === "repeat") {
return ( return (
<span <span
role={decorative ? undefined : "img"}
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : alt}
className={`j13-brand-logo j13-brand-logo-tile ${extra}`} className={`j13-brand-logo j13-brand-logo-tile ${extra}`}
style={{ backgroundImage: `url("${src.replace(/["\\\n\r]/g, "")}")` }} style={{ backgroundImage: `url("${src.replace(/["\\\n\r]/g, "")}")` }}
/> />
@@ -86,17 +102,20 @@ function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?
} }
return ( return (
/* eslint-disable-next-line @next/next/no-img-element */ /* eslint-disable-next-line @next/next/no-img-element */
<img src={src} alt="" className={`j13-brand-logo ${extra}`} /> <img src={src} alt={alt} aria-hidden={decorative || undefined} className={`j13-brand-logo ${extra}`} />
); );
} }
function Bubble({ h }: { h: number }) { function Bubble({ h, label }: { h: number; label?: string }) {
return ( return (
<span <span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className="j13-brand-bubble rounded-2xl flex items-center justify-center shrink-0 transition-transform duration-300 group-hover:rotate-[-6deg]" className="j13-brand-bubble rounded-2xl flex items-center justify-center shrink-0 transition-transform duration-300 group-hover:rotate-[-6deg]"
style={{ width: h, height: h, background: "var(--accent)", color: "var(--accent-on)" }} style={{ width: h, height: h, background: "var(--accent)", color: "var(--accent-on)" }}
> >
<svg width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"> <svg aria-hidden="true" focusable="false" width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg> </svg>
</span> </span>

View File

@@ -429,7 +429,7 @@ export default function CommentSection({
const timeMeta = (c: CommentNode) => ( const timeMeta = (c: CommentNode) => (
<span className="meta text-[11.5px] inline-flex items-center gap-1.5 flex-wrap"> <span className="meta text-[11.5px] inline-flex items-center gap-1.5 flex-wrap">
<span>{new Date(c.created_at).toLocaleString("zh-CN")}</span> <time dateTime={c.created_at}>{new Date(c.created_at).toLocaleString("zh-CN")}</time>
{c.edited && <span title="此评论曾被编辑">已编辑</span>} {c.edited && <span title="此评论曾被编辑">已编辑</span>}
</span> </span>
); );

View File

@@ -10,7 +10,7 @@ function realVersion(pkgVersion: string): string {
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion; return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
} }
// 极简页脚:版权 + 合规链接 + 实时性能指标 // 极简页脚:左侧版权 / 版本 / 耗时,右侧备案等链接
export default function Footer({ export default function Footer({
siteName = "姜十三社区", siteName = "姜十三社区",
links = [], links = [],
@@ -20,6 +20,7 @@ export default function Footer({
}) { }) {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const brand = siteName.trim() || "姜十三社区"; const brand = siteName.trim() || "姜十三社区";
const visibleLinks = links.filter((item) => item.label.trim() && item.url.trim());
return ( return (
<footer <footer
@@ -29,10 +30,13 @@ export default function Footer({
}} }}
> >
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-x-4 gap-y-2 flex-wrap"> <div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-x-4 gap-y-2 flex-wrap">
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span> <div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
{links.length > 0 ? ( <span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
<nav aria-label="页脚链接" className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0"> <FooterTiming version={realVersion(pkg.version)} />
{links.map((item) => ( </div>
{visibleLinks.length > 0 ? (
<nav aria-label="页脚链接" className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
{visibleLinks.map((item) => (
<a <a
key={`${item.label}-${item.url}`} key={`${item.label}-${item.url}`}
href={item.url} href={item.url}
@@ -44,7 +48,6 @@ export default function Footer({
))} ))}
</nav> </nav>
) : null} ) : null}
<FooterTiming version={realVersion(pkg.version)} />
</div> </div>
</footer> </footer>
); );

View File

@@ -110,8 +110,12 @@ export default function FooterTiming({ version = "" }: { version?: string }) {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<span className="text-[12px] meta whitespace-nowrap"> <span className="inline-flex items-center gap-2 text-[12px] tabular-nums whitespace-nowrap" style={{ color: "var(--ink-3)" }}>
版本:{version || PLACEHOLDER} 版本:{version || PLACEHOLDER}
<span aria-hidden="true">·</span>
页面:{PLACEHOLDER}
<span aria-hidden="true">·</span>
模板:{PLACEHOLDER}
</span> </span>
} }
> >

View File

@@ -0,0 +1,12 @@
import { serializeJsonLd } from "@/lib/jsonLd";
/** 服务端输出单个 application/ld+json,不在客户端补脚本 */
export default function JsonLd({ data }: { data: Record<string, unknown> | null }) {
if (!data) return null;
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
}

View File

@@ -18,8 +18,10 @@ export default function Pagination({ total, page, size, basePath, query = {}, on
const buildHref = (p: number) => { const buildHref = (p: number) => {
const params = new URLSearchParams(query); const params = new URLSearchParams(query);
params.set("page", String(p)); if (p > 1) params.set("page", String(p));
return `${basePath}?${params.toString()}`; else params.delete("page");
const qs = params.toString();
return qs ? `${basePath}?${qs}` : basePath;
}; };
const handleClick = (p: number, e: React.MouseEvent) => { const handleClick = (p: number, e: React.MouseEvent) => {

View File

@@ -75,9 +75,9 @@ export default async function PostRow({
className="stretch-link min-w-0 shrink" className="stretch-link min-w-0 shrink"
aria-label={post.title} aria-label={post.title}
> >
<h3 className="text-[15px] font-normal leading-snug truncate text-[var(--ink)] transition-colors group-hover:text-[var(--accent)]"> <h2 className="text-[15px] font-normal leading-snug truncate text-[var(--ink)] transition-colors group-hover:text-[var(--accent)]">
{post.title} {post.title}
</h3> </h2>
</Link> </Link>
{/* 其余徽章紧跟标题右侧 */} {/* 其余徽章紧跟标题右侧 */}
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>} {post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}

View File

@@ -15,6 +15,9 @@ export default function AdminField({
disabled, disabled,
className = "", className = "",
minHeight, minHeight,
embed,
error,
describedBy,
}: { }: {
id: string; id: string;
label: ReactNode; label: ReactNode;
@@ -29,27 +32,40 @@ export default function AdminField({
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
minHeight?: string; minHeight?: string;
/** 标签由外层设置行提供,本组件只保留计数与输入 */
embed?: boolean;
error?: string;
describedBy?: string;
}) { }) {
const handle = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => const handle = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChange(e.target.value); onChange(e.target.value);
const invalid = Boolean(error);
const described = [describedBy, error ? `${id}-error` : null].filter(Boolean).join(" ") || undefined;
const count =
maxLength != null ? (
<span className={`admin-field-count${embed ? " is-infield" : ""}`} aria-hidden>
{value.length}/{maxLength}
</span>
) : null;
return ( return (
<div className={className}> <div className={className}>
<div className="admin-field-head"> {embed ? null : (
<label className="field-label" htmlFor={id}> <div className="admin-field-head">
{label} <label className="field-label" htmlFor={id}>
</label> {label}
{maxLength != null && ( </label>
<span className="admin-field-count" aria-hidden> {count}
{value.length}/{maxLength} </div>
</span> )}
)} {!embed && hint != null && hint !== "" && (
</div>
{hint != null && hint !== "" && (
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">{hint}</p> <p className="meta text-[12.5px] mb-2.5 leading-relaxed">{hint}</p>
)} )}
{as === "textarea" ? ( {as === "textarea" ? (
<div className="admin-field-shell is-area"> <div className={`admin-field-shell is-area${embed && count ? " has-count" : ""}${invalid ? " is-invalid" : ""}`}>
{embed ? count : null}
<textarea <textarea
id={id} id={id}
className="admin-field-input" className="admin-field-input"
@@ -59,10 +75,12 @@ export default function AdminField({
onChange={handle} onChange={handle}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={described}
/> />
</div> </div>
) : ( ) : (
<div className="admin-field-shell"> <div className={`admin-field-shell${embed && count ? " has-count" : ""}${invalid ? " is-invalid" : ""}`}>
<input <input
id={id} id={id}
type={type} type={type}
@@ -73,9 +91,17 @@ export default function AdminField({
placeholder={placeholder} placeholder={placeholder}
autoComplete={autoComplete} autoComplete={autoComplete}
disabled={disabled} disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={described}
/> />
{embed ? count : null}
</div> </div>
)} )}
{error ? (
<p className="admin-settings-row-error" id={`${id}-error`} role="alert">
{error}
</p>
) : null}
</div> </div>
); );
} }

View File

@@ -0,0 +1,160 @@
import { Loader2 } from "lucide-react";
import type { ReactNode } from "react";
/** 站点设置工作区:限宽、主内容区内左对齐,不写入后台公共主容器 */
export function AdminSettingsPage({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={`admin-settings-page${className ? ` ${className}` : ""}`}>{children}</div>;
}
/** 分类导航 + 主表单列 */
export function AdminSettingsWork({
nav,
children,
}: {
nav: ReactNode;
children: ReactNode;
}) {
return (
<div className="admin-settings-work">
<div className="admin-settings-nav-col">{nav}</div>
<div className="admin-settings-main">{children}</div>
</div>
);
}
/** 一组相关设置:标题、说明、字段、底部操作,减少逐项套卡片 */
export function AdminSettingsGroup({
title,
description,
children,
footer,
id,
className,
}: {
title: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
id?: string;
className?: string;
}) {
return (
<section className={`admin-settings-group${className ? ` ${className}` : ""}`} id={id}>
<header className="admin-settings-group-head">
<h2 className="admin-settings-group-title">{title}</h2>
{description != null && description !== "" ? (
<p className="admin-settings-group-desc">{description}</p>
) : null}
</header>
<div className="admin-settings-group-body">{children}</div>
{footer != null ? <div className="admin-settings-group-footer">{footer}</div> : null}
</section>
);
}
/** 标签列 + 控件列;窄表单自动改为上下堆叠 */
export function AdminSettingsRow({
label,
htmlFor,
hint,
hintBelow,
error,
errorId,
children,
wide,
className,
}: {
label: ReactNode;
htmlFor?: string;
/** 短说明,紧邻标签 */
hint?: ReactNode;
/** 长说明,放在控件下方 */
hintBelow?: ReactNode;
error?: ReactNode;
errorId?: string;
children: ReactNode;
/** 简介等较长控件 */
wide?: boolean;
className?: string;
}) {
const Tag = htmlFor ? "label" : "div";
return (
<div className={`admin-settings-row${wide ? " is-wide" : ""}${className ? ` ${className}` : ""}`}>
<div className="admin-settings-row-label">
<Tag className="admin-settings-row-name" {...(htmlFor ? { htmlFor } : {})}>
{label}
</Tag>
{hint != null && hint !== "" ? <p className="admin-settings-row-hint">{hint}</p> : null}
</div>
<div className="admin-settings-row-control">
{children}
{hintBelow != null && hintBelow !== "" ? (
<p className="admin-settings-row-hint is-below">{hintBelow}</p>
) : null}
{error != null && error !== "" ? (
<p className="admin-settings-row-error" id={errorId} role="alert">
{error}
</p>
) : null}
</div>
</div>
);
}
/** 保存单元底部操作:按钮与状态贴齐控件列左边缘 */
export function AdminSettingsActions({
dirty,
saving,
saveLabel,
onSave,
onDiscard,
saveDisabled,
error,
children,
}: {
dirty: boolean;
saving?: boolean;
saveLabel: string;
onSave: () => void;
onDiscard?: () => void;
saveDisabled?: boolean;
error?: string;
children?: ReactNode;
}) {
const status = saving ? "正在保存…" : error ? error : dirty ? "有未保存的修改" : "已保存";
return (
<div className="admin-settings-actions" aria-busy={saving || undefined}>
<div className="admin-settings-actions-inner">
<div className="admin-settings-actions-btns">
{children}
{onDiscard ? (
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={onDiscard}>
撤销修改
</button>
) : null}
<button
type="button"
className="btn btn-primary btn-sm"
disabled={saveDisabled ?? (!dirty || saving)}
onClick={onSave}
>
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
</div>
<p
className={`admin-settings-actions-status${error ? " is-error" : ""}`}
aria-live="polite"
>
{status}
</p>
</div>
</div>
);
}

View File

@@ -2,6 +2,13 @@ export { default as AdminPageHeader } from "./AdminPageHeader";
export { default as AdminSection } from "./AdminSection"; export { default as AdminSection } from "./AdminSection";
export { default as AdminSwitch } from "./AdminSwitch"; export { default as AdminSwitch } from "./AdminSwitch";
export { default as AdminSettingRow } from "./AdminSettingRow"; export { default as AdminSettingRow } from "./AdminSettingRow";
export {
AdminSettingsPage,
AdminSettingsWork,
AdminSettingsGroup,
AdminSettingsRow,
AdminSettingsActions,
} from "./AdminSettingsLayout";
export { default as AdminSegmented } from "./AdminSegmented"; export { default as AdminSegmented } from "./AdminSegmented";
export type { AdminSegmentOption } from "./AdminSegmented"; export type { AdminSegmentOption } from "./AdminSegmented";
export { default as AdminSearchField } from "./AdminSearchField"; export { default as AdminSearchField } from "./AdminSearchField";

View File

@@ -10,11 +10,12 @@ export type FeedSort = "latest" | "new" | "recommended";
const TAB_KEYS: FeedSort[] = ["latest", "new", "recommended"]; const TAB_KEYS: FeedSort[] = ["latest", "new", "recommended"];
// 在指定板块内切换排序时保留 ?board= // 在指定板块内切换排序时保留 ?board=;默认排序不写入 URL
function tabHref(key: FeedSort, boardId: number) { function tabHref(key: FeedSort, boardId: number, allowComments: boolean) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (boardId > 0) params.set("board", String(boardId)); if (boardId > 0) params.set("board", String(boardId));
if (key !== "latest") params.set("sort", key); const def = allowComments ? "latest" : "new";
if (key !== def) params.set("sort", key);
const qs = params.toString(); const qs = params.toString();
return qs ? `/?${qs}` : "/"; return qs ? `/?${qs}` : "/";
} }
@@ -31,7 +32,7 @@ export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort;
e.preventDefault(); e.preventDefault();
const next = (i + (e.key === "ArrowRight" ? 1 : keys.length - 1)) % keys.length; const next = (i + (e.key === "ArrowRight" ? 1 : keys.length - 1)) % keys.length;
tabRefs.current[next]?.focus(); tabRefs.current[next]?.focus();
router.push(tabHref(keys[next], boardId)); router.push(tabHref(keys[next], boardId, allowComments));
}; };
return ( return (
@@ -44,7 +45,7 @@ export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort;
ref={(el) => { ref={(el) => {
tabRefs.current[i] = el; tabRefs.current[i] = el;
}} }}
href={tabHref(key, boardId)} href={tabHref(key, boardId, allowComments)}
role="tab" role="tab"
className="j13-tab inline-flex items-center gap-1" className="j13-tab inline-flex items-center gap-1"
aria-selected={current === key} aria-selected={current === key}

View File

@@ -2,8 +2,9 @@
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { PenSquare, Home, LayoutGrid } from "lucide-react"; import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api"; import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台 // 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
function boardHref(id: number) { function boardHref(id: number) {
@@ -11,9 +12,10 @@ function boardHref(id: number) {
} }
// 桌面左栏:整块模块,与右栏 j13-module 对齐 // 桌面左栏:整块模块,与右栏 j13-module 对齐
export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User | null }) { export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
const params = useSearchParams(); const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0; const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role);
return ( return (
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程 // sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
@@ -68,6 +70,21 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
</Link> </Link>
))} ))}
</div> </div>
{showAdmin ? (
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" className="j13-bcard">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<ShieldCheck size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
</Link>
</div>
) : null}
</section> </section>
</nav> </nav>
); );

View File

@@ -376,7 +376,7 @@ export default function ImageLightbox({
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={current.src} src={current.src}
alt={current.alt || ""} alt={current.alt?.trim() || "图片"}
className={`md-lightbox-img${oddRot ? " is-odd-rot" : ""}${snap ? " is-snap" : ""}`} className={`md-lightbox-img${oddRot ? " is-odd-rot" : ""}${snap ? " is-snap" : ""}`}
draggable={false} draggable={false}
style={{ style={{

View File

@@ -13,14 +13,14 @@ function collectGallery(from: HTMLImageElement): { items: LightboxItem[]; index:
const items: LightboxItem[] = nodes const items: LightboxItem[] = nodes
.map((el) => ({ .map((el) => ({
src: el.currentSrc || el.getAttribute("src") || "", src: el.currentSrc || el.getAttribute("src") || "",
alt: el.getAttribute("alt") || undefined, alt: el.dataset.caption || undefined,
})) }))
.filter((it) => !!it.src); .filter((it) => !!it.src);
const selfSrc = from.currentSrc || from.getAttribute("src") || ""; const selfSrc = from.currentSrc || from.getAttribute("src") || "";
let index = items.findIndex((it) => it.src === selfSrc); let index = items.findIndex((it) => it.src === selfSrc);
if (index < 0) index = 0; if (index < 0) index = 0;
if (items.length === 0 && selfSrc) { if (items.length === 0 && selfSrc) {
return { items: [{ src: selfSrc, alt: from.alt || undefined }], index: 0 }; return { items: [{ src: selfSrc, alt: from.dataset.caption || undefined }], index: 0 };
} }
return { items, index }; return { items, index };
} }
@@ -39,10 +39,13 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
setLightbox(gallery); setLightbox(gallery);
}, []); }, []);
const provided = (typeof alt === "string" ? alt.trim() : "") || (typeof title === "string" ? title.trim() : "");
const imgAlt = provided || "图片";
if (!src || broken) { if (!src || broken) {
return ( return (
<span className="md-img-fallback" role="img" aria-label={alt || "图片加载失败"}> <span className="md-img-fallback" role="img" aria-label={provided || "图片加载失败"}>
{alt || "图片加载失败"} {provided || "图片加载失败"}
</span> </span>
); );
} }
@@ -53,7 +56,8 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
<img <img
{...rest} {...rest}
src={src} src={src}
alt={alt || ""} alt={imgAlt}
data-caption={provided || undefined}
title={title} title={title}
className={`md-content-img${className ? ` ${className}` : ""}`} className={`md-content-img${className ? ` ${className}` : ""}`}
loading="lazy" loading="lazy"

View File

@@ -0,0 +1,69 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
/**
* 有未保存修改时:关闭/刷新走 beforeunload;站内链接先确认再跳转。
* 未修改时不打扰。hash 分类切换由调用方自行拦截。
*/
export default function useUnsavedGuard(dirty: boolean) {
const router = useRouter();
const pathname = usePathname();
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
const [pendingHref, setPendingHref] = useState<string | null>(null);
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (!dirtyRef.current) return;
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!dirtyRef.current) return;
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
const target = e.target;
if (!(target instanceof Element)) return;
const a = target.closest("a[href]");
if (!a) return;
const href = a.getAttribute("href");
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("javascript:")) return;
let url: URL;
try {
url = new URL(href, window.location.href);
} catch {
return;
}
if (url.origin !== window.location.origin) return;
const next = `${url.pathname}${url.search}${url.hash}`;
const cur = `${pathname}${window.location.search}${window.location.hash}`;
if (next === cur) return;
e.preventDefault();
e.stopPropagation();
setPendingHref(next);
};
document.addEventListener("click", onClick, true);
return () => document.removeEventListener("click", onClick, true);
}, [pathname]);
const confirmLeave = () => {
if (!pendingHref) return;
const href = pendingHref;
setPendingHref(null);
router.push(href);
};
return {
leaveOpen: pendingHref != null,
confirmLeave,
cancelLeave: () => setPendingHref(null),
};
}

View File

@@ -0,0 +1,22 @@
import { headers } from "next/headers";
import { canonicalFields, metadataBaseURL, type QueryBag } from "@/lib/siteUrl";
import type { Metadata } from "next";
/**
* 请求时再读 SITE_URL,避免预渲染把构建环境的 origin 打进 HTML。
* 使用 headers() 而非 connection():后者会把 metadata 推迟到 </head> 之后,
* 插件只扫 head 时仍显示 Missing。
* 根布局只设 metadataBase,各公开页再调本函数写入自身 Canonical。
*/
export async function requestCanonical(
pathname: string,
query?: QueryBag
): Promise<Pick<Metadata, "alternates">> {
await headers();
return canonicalFields(pathname, query);
}
export async function requestMetadataBase(): Promise<URL | undefined> {
await headers();
return metadataBaseURL();
}

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import { discussionJsonLd, questionJsonLd, serializeJsonLd } from "./jsonLd.ts";
const prev = {
SITE_URL: process.env.SITE_URL,
NODE_ENV: process.env.NODE_ENV,
};
afterEach(() => {
if (prev.SITE_URL === undefined) delete process.env.SITE_URL;
else process.env.SITE_URL = prev.SITE_URL;
process.env.NODE_ENV = prev.NODE_ENV;
});
describe("serializeJsonLd", () => {
it("尖括号不会提前闭合 script", () => {
const raw = serializeJsonLd({ text: `a</script><script>alert(1)` });
assert.equal(raw.includes("</script>"), false);
assert.equal(raw.includes("\\u003c"), true);
const parsed = JSON.parse(raw);
assert.equal(parsed.text, "a</script><script>alert(1)");
});
});
describe("questionJsonLd", () => {
it("采纳答案不重复进 suggestedAnswer", () => {
process.env.SITE_URL = "https://bbs.example.com";
process.env.NODE_ENV = "production";
const data = questionJsonLd({
title: "怎么排序",
text: "正文",
createdAt: "2026-09-01T00:00:00Z",
author: { id: 3, name: "甲" },
postId: 9,
accepted: { id: 4, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
answers: [
{ id: 4, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
{ id: 6, content: "先 EXPLAIN", created_at: "2026-09-03T00:00:00Z" },
],
});
assert.ok(data);
const q = data.mainEntity as { acceptedAnswer: { text: string }; suggestedAnswer: { text: string }[] };
assert.equal(q.acceptedAnswer.text, "用索引");
assert.deepEqual(q.suggestedAnswer.map((a) => a.text), ["先 EXPLAIN"]);
assert.equal(data["@type"], "QAPage");
});
});
describe("discussionJsonLd", () => {
it("评论分页不改讨论实体地址", () => {
process.env.SITE_URL = "https://bbs.example.com";
process.env.NODE_ENV = "production";
const data = discussionJsonLd({
title: "闲聊",
createdAt: "2026-09-01T00:00:00Z",
author: { id: 1, name: "丙" },
postId: 8,
pageUrl: "https://bbs.example.com/post/8?page=2",
comments: [{ id: 2, content: "可见楼层", created_at: "2026-09-01T01:00:00Z" }],
});
assert.equal(data?.url, "https://bbs.example.com/post/8");
const page = data?.mainEntityOfPage as { url: string };
assert.equal(page.url, "https://bbs.example.com/post/8?page=2");
assert.equal((data?.comment as { text: string }[])[0].text, "可见楼层");
});
});

266
frontend/lib/jsonLd.ts Normal file
View File

@@ -0,0 +1,266 @@
import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts";
/** 防止用户正文里的 </script> 提前闭合 JSON-LD 脚本 */
export function serializeJsonLd(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
export type JsonLdPerson = {
id?: number;
name: string;
};
function personNode(person: JsonLdPerson) {
const url = person.id ? absoluteCanonical(`/u/${person.id}`) : undefined;
return {
"@type": "Person" as const,
name: person.name,
...(url ? { url } : {}),
};
}
function websiteId(): string | undefined {
const origin = resolveSiteOrigin();
return origin ? `${origin}/#website` : undefined;
}
export function websiteNode(siteName: string, description?: string) {
const origin = resolveSiteOrigin();
const id = websiteId();
if (!origin || !id) return null;
return {
"@type": "WebSite" as const,
"@id": id,
name: siteName,
url: `${origin}/`,
...(description ? { description } : {}),
inLanguage: "zh-CN",
};
}
export function listingJsonLd(input: {
siteName: string;
siteDescription?: string;
pageName: string;
path: string;
query?: QueryBag;
items: { id: number; name: string }[];
page: number;
pageSize: number;
}): Record<string, unknown> | null {
const site = websiteNode(input.siteName, input.siteDescription);
const pageUrl = absoluteCanonical(input.path, input.query);
const origin = resolveSiteOrigin();
if (!site || !pageUrl || !origin) return null;
return {
"@context": "https://schema.org",
"@graph": [
site,
{
"@type": "CollectionPage",
"@id": pageUrl,
url: pageUrl,
name: input.pageName,
isPartOf: { "@id": site["@id"] },
inLanguage: "zh-CN",
mainEntity: {
"@type": "ItemList",
numberOfItems: input.items.length,
itemListElement: input.items.map((item, i) => ({
"@type": "ListItem",
position: (input.page - 1) * input.pageSize + i + 1,
name: item.name,
url: `${origin}/post/${item.id}`,
})),
},
},
],
};
}
export type JsonLdVisibleComment = {
id: number;
content: string;
created_at: string;
author?: JsonLdPerson;
};
function commentUrl(entityUrl: string, id: number): string {
return `${entityUrl}#comment-${id}`;
}
/** 问答帖:一问多答。只收录当前页可见楼层,不把折叠回复或未展示评论写进去。 */
export function questionJsonLd(input: {
title: string;
text?: string;
createdAt: string;
updatedAt?: string;
author: JsonLdPerson;
postId: number;
pageUrl?: string;
accepted?: JsonLdVisibleComment | null;
answers: JsonLdVisibleComment[];
}): Record<string, unknown> | null {
const entityUrl = absoluteCanonical(`/post/${input.postId}`);
const origin = resolveSiteOrigin();
const site = websiteId();
if (!entityUrl || !origin || !site) return null;
const pageUrl = input.pageUrl || entityUrl;
const acceptedId = input.accepted?.id;
const suggested = input.answers.filter(
(a) => a.content.trim() && a.id !== acceptedId
);
const answerNode = (a: JsonLdVisibleComment) => ({
"@type": "Answer" as const,
text: a.content,
dateCreated: a.created_at,
url: commentUrl(entityUrl, a.id),
...(a.author ? { author: personNode(a.author) } : {}),
});
return {
"@context": "https://schema.org",
"@type": "QAPage",
"@id": pageUrl,
url: pageUrl,
inLanguage: "zh-CN",
isPartOf: { "@id": site },
mainEntity: {
"@type": "Question",
"@id": entityUrl,
name: input.title,
url: entityUrl,
...(input.text ? { text: input.text } : {}),
dateCreated: input.createdAt,
...(input.updatedAt ? { dateModified: input.updatedAt } : {}),
author: personNode(input.author),
...(input.accepted && input.accepted.content.trim()
? { acceptedAnswer: answerNode(input.accepted) }
: {}),
...(suggested.length ? { suggestedAnswer: suggested.map(answerNode) } : {}),
},
};
}
/** 普通讨论帖。讨论实体地址不含评论分页;分页页用 mainEntityOfPage 指向当前页。 */
export function discussionJsonLd(input: {
title: string;
text?: string;
createdAt: string;
updatedAt?: string;
author: JsonLdPerson;
postId: number;
pageUrl?: string;
commentCount?: number;
viewCount?: number;
comments: JsonLdVisibleComment[];
}): Record<string, unknown> | null {
const entityUrl = absoluteCanonical(`/post/${input.postId}`);
const site = websiteId();
if (!entityUrl || !site) return null;
const pageUrl = input.pageUrl || entityUrl;
const comments = input.comments.filter((c) => c.content.trim());
return {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
"@id": entityUrl,
headline: input.title,
url: entityUrl,
...(input.text ? { articleBody: input.text } : {}),
datePublished: input.createdAt,
...(input.updatedAt ? { dateModified: input.updatedAt } : {}),
inLanguage: "zh-CN",
author: personNode(input.author),
isPartOf: { "@id": site },
...(pageUrl !== entityUrl
? {
mainEntityOfPage: {
"@type": "WebPage",
"@id": pageUrl,
url: pageUrl,
},
}
: {}),
...(input.commentCount != null ? { commentCount: input.commentCount } : {}),
...(input.viewCount != null
? {
interactionStatistic: {
"@type": "InteractionCounter",
interactionType: { "@type": "ReadAction" },
userInteractionCount: input.viewCount,
},
}
: {}),
...(comments.length
? {
comment: comments.map((c) => ({
"@type": "Comment",
text: c.content,
dateCreated: c.created_at,
url: commentUrl(entityUrl, c.id),
...(c.author ? { author: personNode(c.author) } : {}),
})),
}
: {}),
};
}
export function profileJsonLd(input: {
id: number;
name: string;
username: string;
signature?: string;
pageUrl?: string;
}): Record<string, unknown> | null {
const personUrl = absoluteCanonical(`/u/${input.id}`);
if (!personUrl) return null;
const pageUrl = input.pageUrl || personUrl;
return {
"@context": "https://schema.org",
"@type": "ProfilePage",
"@id": pageUrl,
url: pageUrl,
name: input.name,
inLanguage: "zh-CN",
mainEntity: {
"@type": "Person",
name: input.name,
url: personUrl,
alternateName: input.username,
...(input.signature ? { description: input.signature } : {}),
},
};
}
export function webPageJsonLd(input: {
type?: "WebPage" | "Article";
name: string;
description?: string;
path: string;
datePublished?: string;
dateModified?: string;
articleSection?: string;
keywords?: string;
}): Record<string, unknown> | null {
const url = absoluteCanonical(input.path);
const site = websiteId();
if (!url || !site) return null;
return {
"@context": "https://schema.org",
"@type": input.type || "WebPage",
name: input.name,
...(input.type === "Article" ? { headline: input.name } : {}),
url,
...(input.description ? { description: input.description } : {}),
...(input.datePublished ? { datePublished: input.datePublished } : {}),
...(input.dateModified ? { dateModified: input.dateModified } : {}),
...(input.articleSection ? { articleSection: input.articleSection } : {}),
...(input.keywords ? { keywords: input.keywords } : {}),
inLanguage: "zh-CN",
isPartOf: { "@id": site },
};
}

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import {
absoluteCanonical,
canonicalPage,
canonicalPath,
homeCanonicalQuery,
isTrackingParam,
parseSiteOrigin,
resolveSiteOrigin,
} from "./siteUrl.ts";
const prev = {
SITE_URL: process.env.SITE_URL,
NODE_ENV: process.env.NODE_ENV,
};
afterEach(() => {
if (prev.SITE_URL === undefined) delete process.env.SITE_URL;
else process.env.SITE_URL = prev.SITE_URL;
process.env.NODE_ENV = prev.NODE_ENV;
});
describe("parseSiteOrigin", () => {
it("去掉尾斜杠并保留端口", () => {
assert.equal(parseSiteOrigin("https://bbs.example.com/"), "https://bbs.example.com");
assert.equal(parseSiteOrigin("http://localhost:3000/"), "http://localhost:3000");
});
it("拒绝 API / 容器名 / 带路径 / 非 http", () => {
assert.equal(parseSiteOrigin("http://api:3001"), null);
assert.equal(parseSiteOrigin("https://bbs.example.com/forum"), null);
assert.equal(parseSiteOrigin("ftp://bbs.example.com"), null);
assert.equal(parseSiteOrigin("not-a-url"), null);
assert.equal(parseSiteOrigin(""), null);
});
it("生产态把容器 API 地址视为非法", () => {
process.env.SITE_URL = "http://api:3001";
process.env.NODE_ENV = "production";
assert.equal(resolveSiteOrigin(), null);
});
});
describe("resolveSiteOrigin", () => {
it("开发态无配置回退 localhost:3000", () => {
delete process.env.SITE_URL;
process.env.NODE_ENV = "development";
assert.equal(resolveSiteOrigin(), "http://localhost:3000");
});
it("生产态缺失不回退 localhost", () => {
delete process.env.SITE_URL;
process.env.NODE_ENV = "production";
assert.equal(resolveSiteOrigin(), null);
});
it("生产态使用 SITE_URL", () => {
process.env.SITE_URL = "https://bbs.example.com/";
process.env.NODE_ENV = "production";
assert.equal(resolveSiteOrigin(), "https://bbs.example.com");
});
});
describe("canonicalPath", () => {
it("首页无尾斜杠参数", () => {
assert.equal(canonicalPath("/"), "/");
assert.equal(canonicalPath("/post/12/"), "/post/12");
});
it("省略空值并丢掉追踪参数", () => {
assert.equal(
canonicalPath("/post/12", { utm_source: "x", fbclid: "1", page: 2, q: "" }),
"/post/12?page=2"
);
});
it("保留内容参数", () => {
assert.equal(canonicalPath("/", { board: 3, sort: "new", page: 2 }), "/?board=3&page=2&sort=new");
});
});
describe("canonicalPage", () => {
it("第一页与非法页码省略", () => {
assert.equal(canonicalPage(undefined), undefined);
assert.equal(canonicalPage("1"), undefined);
assert.equal(canonicalPage("0"), undefined);
assert.equal(canonicalPage("foo"), undefined);
assert.equal(canonicalPage("2"), 2);
});
});
describe("isTrackingParam", () => {
it("识别 utm 与常见点击 id", () => {
assert.equal(isTrackingParam("utm_source"), true);
assert.equal(isTrackingParam("gclid"), true);
assert.equal(isTrackingParam("page"), false);
});
});
describe("absoluteCanonical", () => {
it("拼出绝对地址", () => {
process.env.SITE_URL = "https://bbs.example.com";
process.env.NODE_ENV = "production";
assert.equal(absoluteCanonical("/post/9", { page: 2 }), "https://bbs.example.com/post/9?page=2");
});
});
describe("homeCanonicalQuery", () => {
it("默认排序与第一页省略;搜索丢掉 board", () => {
assert.deepEqual(homeCanonicalQuery({}, true), {});
assert.deepEqual(homeCanonicalQuery({ sort: "latest", page: "1" }, true), {});
assert.deepEqual(homeCanonicalQuery({ sort: "new" }, false), {});
assert.deepEqual(
homeCanonicalQuery({ q: " go ", board: "3", page: "2" }, true),
{ q: "go", page: 2 }
);
assert.deepEqual(
homeCanonicalQuery({ board: "3", sort: "new", page: "2" }, true),
{ board: 3, page: 2, sort: "new" }
);
});
});

151
frontend/lib/siteUrl.ts Normal file
View File

@@ -0,0 +1,151 @@
import type { Metadata } from "next";
/**
* 公开站点 origin(访客浏览器地址栏),与 Go CORS 的 SITE_URL 同源。
* 不使用 BACKEND_URL / NEXT_PUBLIC_API_URL(那是 Go API,可能是容器内网)。
*/
const TRACKING_KEYS = new Set([
"gclid",
"gbraid",
"wbraid",
"dclid",
"fbclid",
"msclkid",
"twclid",
"ttclid",
"yclid",
"_ga",
"_gl",
"mc_cid",
"mc_eid",
]);
export type QueryValue = string | number | undefined | null;
export type QueryBag = Record<string, QueryValue>;
export type FeedSort = "latest" | "new" | "recommended";
function envOriginRaw(): string {
return (process.env.SITE_URL || "").trim();
}
/** 解析为无尾斜杠的 http(s) origin;路径/账号/内部服务名视为非法 */
export function parseSiteOrigin(raw: string | undefined | null): string | null {
const s = raw?.trim() ?? "";
if (!s) return null;
let u: URL;
try {
u = new URL(s);
} catch {
return null;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
if (u.username || u.password) return null;
const host = u.hostname.toLowerCase();
if (!host || host === "api" || host.endsWith(".internal")) return null;
const path = u.pathname.replace(/\/+$/, "");
if (path !== "") return null;
return `${u.protocol}//${u.host}`;
}
/**
* 运行时公开 origin。
* 开发态未配置时用 http://localhost:3000;生产态缺失或非法则返回 null(不回退 localhost)。
*/
export function resolveSiteOrigin(): string | null {
const parsed = parseSiteOrigin(envOriginRaw());
if (parsed) return parsed;
if (process.env.NODE_ENV !== "production") return "http://localhost:3000";
return null;
}
export function metadataBaseURL(): URL | undefined {
const origin = resolveSiteOrigin();
return origin ? new URL(`${origin}/`) : undefined;
}
export function isTrackingParam(key: string): boolean {
const k = key.toLowerCase();
return k.startsWith("utm_") || TRACKING_KEYS.has(k);
}
/** 路径规范化:保证以 / 开头,除首页外去掉尾斜杠 */
export function canonicalPath(pathname: string, query?: QueryBag): string {
let path = pathname.trim() || "/";
if (!path.startsWith("/")) path = `/${path}`;
if (path.length > 1) path = path.replace(/\/+$/, "");
const params = new URLSearchParams();
if (query) {
const keys = Object.keys(query).sort();
for (const key of keys) {
if (isTrackingParam(key)) continue;
const raw = query[key];
if (raw == null || raw === "") continue;
params.set(key, String(raw));
}
}
const qs = params.toString();
return qs ? `${path}?${qs}` : path;
}
export function absoluteCanonical(pathname: string, query?: QueryBag): string | undefined {
const origin = resolveSiteOrigin();
if (!origin) return undefined;
return `${origin}${canonicalPath(pathname, query)}`;
}
/** 写入 generateMetadata 的 alternates.canonical;origin 不可用时不加字段 */
export function canonicalFields(pathname: string, query?: QueryBag): Pick<Metadata, "alternates"> {
const href = absoluteCanonical(pathname, query);
if (!href) return {};
return { alternates: { canonical: href } };
}
/** 页码:非法或 ≤1 视为首页(省略 page) */
export function canonicalPage(raw: string | undefined): number | undefined {
const n = parseInt(raw || "1", 10);
const page = Number.isFinite(n) ? Math.max(1, n) : 1;
return page > 1 ? page : undefined;
}
/** 板块 id:≤0 或非法则省略 */
export function canonicalBoard(raw: string | undefined): number | undefined {
const n = parseInt(raw || "0", 10);
if (!Number.isFinite(n) || n <= 0) return undefined;
return n;
}
export function defaultFeedSort(allowComments: boolean): FeedSort {
return allowComments ? "latest" : "new";
}
/** 与首页 page.tsx 的排序解析一致 */
export function normalizeFeedSort(v: string | undefined, allowComments: boolean): FeedSort {
if (v === "recommended") return "recommended";
if (v === "new") return "new";
if (!allowComments) return "new";
if (v === "latest") return "latest";
return "latest";
}
/**
* 首页 query 归一化:保留 q / 非默认 sort / 有效 board / page>1;
* 搜索时 board 不参与列表查询,故省略。
*/
export function homeCanonicalQuery(
sp: { q?: string; page?: string; sort?: string; board?: string },
allowComments: boolean
): QueryBag {
const q = sp.q?.trim() || "";
const bag: QueryBag = {};
if (q) bag.q = q;
const page = canonicalPage(sp.page);
if (page) bag.page = page;
const sort = normalizeFeedSort(sp.sort, allowComments);
if (sort !== defaultFeedSort(allowComments)) bag.sort = sort;
if (!q) {
const board = canonicalBoard(sp.board);
if (board) bag.board = board;
}
return bag;
}

View File

@@ -6,7 +6,8 @@
"dev": "next dev -p 3000", "dev": "next dev -p 3000",
"build": "next build", "build": "next build",
"start": "next start -p 3000", "start": "next start -p 3000",
"lint": "next lint" "lint": "next lint",
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts"
}, },
"dependencies": { "dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6", "hast-util-to-jsx-runtime": "^2.3.6",

View File

@@ -13,6 +13,7 @@
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
@@ -36,6 +37,7 @@
".next/dev/types/**/*.ts" ".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules",
"**/*.test.ts"
] ]
} }