From 40bbad2c820e27957ab01a4e1309a7609e7330c9 Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 20 Sep 2026 00:11:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E5=BC=80=E9=A1=B5=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=87=AA=E5=BC=95=E7=94=A8=20Canonical=20=E4=B8=8E=E6=8C=89?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E7=9A=84=E7=BB=93=E6=9E=84=E5=8C=96=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=EF=BC=8C=E5=9B=BE=E7=89=87=E8=A1=A5=E4=B8=8A=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E6=96=87=E5=AD=97=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor --- README.md | 2 +- deploy/.env.example | 3 +- deploy/CONTRACT.md | 1 + deploy/docker-compose.yml | 1 + deploy/nginx.example.conf | 7 + frontend/.env.example | 5 + frontend/app/about/page.tsx | 12 +- .../admin/announcements/AnnouncementAdmin.tsx | 5 +- .../AnnouncementComposeClient.tsx | 251 ++-- frontend/app/admin/content/ContentAdmin.tsx | 28 +- .../app/admin/pages/PageComposeClient.tsx | 277 ++--- frontend/app/admin/pages/PagesAdmin.tsx | 5 +- .../app/admin/settings/BasicIdentityPanel.tsx | 297 +++-- .../app/admin/settings/BrandMediaPicker.tsx | 2 +- frontend/app/admin/settings/BrandSeoPanel.tsx | 294 ++--- .../app/admin/settings/FooterLinksPanel.tsx | 124 +- frontend/app/admin/settings/SettingsAdmin.tsx | 1020 ++++++++--------- frontend/app/announcement/[id]/page.tsx | 19 +- frontend/app/announcements/page.tsx | 17 +- frontend/app/globals.css | 666 +++++++++-- frontend/app/layout.tsx | 4 + frontend/app/p/[slug]/page.tsx | 16 +- frontend/app/page.tsx | 70 +- frontend/app/post/[id]/page.tsx | 100 +- frontend/app/robots.ts | 8 +- frontend/app/sitemap.ts | 23 +- frontend/app/u/[id]/page.tsx | 46 +- frontend/components/Avatar.tsx | 2 +- frontend/components/AvatarCropModal.tsx | 7 +- frontend/components/BrandLockup.tsx | 37 +- frontend/components/CommentSection.tsx | 2 +- frontend/components/Footer.tsx | 15 +- frontend/components/FooterTiming.tsx | 6 +- frontend/components/JsonLd.tsx | 12 + frontend/components/Pagination.tsx | 6 +- frontend/components/PostRow.tsx | 4 +- frontend/components/admin/AdminField.tsx | 52 +- .../components/admin/AdminSettingsLayout.tsx | 160 +++ frontend/components/admin/index.ts | 7 + frontend/components/home/FeedTabs.tsx | 11 +- frontend/components/home/WorkspaceNav.tsx | 21 +- .../components/markdown/ImageLightbox.tsx | 2 +- frontend/components/markdown/MdImage.tsx | 14 +- frontend/hooks/useUnsavedGuard.ts | 69 ++ frontend/lib/canonicalMeta.ts | 22 + frontend/lib/jsonLd.test.ts | 67 ++ frontend/lib/jsonLd.ts | 266 +++++ frontend/lib/siteUrl.test.ts | 117 ++ frontend/lib/siteUrl.ts | 151 +++ frontend/package.json | 3 +- frontend/tsconfig.json | 4 +- 51 files changed, 3042 insertions(+), 1318 deletions(-) create mode 100644 frontend/components/JsonLd.tsx create mode 100644 frontend/components/admin/AdminSettingsLayout.tsx create mode 100644 frontend/hooks/useUnsavedGuard.ts create mode 100644 frontend/lib/canonicalMeta.ts create mode 100644 frontend/lib/jsonLd.test.ts create mode 100644 frontend/lib/jsonLd.ts create mode 100644 frontend/lib/siteUrl.test.ts create mode 100644 frontend/lib/siteUrl.ts diff --git a/README.md b/README.md index b12b00f..c74efd0 100644 --- a/README.md +++ b/README.md @@ -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。 -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`。 ### WebSocket(开发 vs 生产) diff --git a/deploy/.env.example b/deploy/.env.example index 2475af4..a3f693d 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -8,7 +8,8 @@ JIANG13_VERSION=0.1.0 JIANG13_IMAGE_API=jiang13-bbs-api 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 # 发布到宿主机的地址/端口,供本机 Nginx 反代。默认只绑 loopback。 diff --git a/deploy/CONTRACT.md b/deploy/CONTRACT.md index 8af1743..c8dd507 100644 --- a/deploy/CONTRACT.md +++ b/deploy/CONTRACT.md @@ -52,6 +52,7 @@ | 变量 | 必需 | 说明 | |------|------|------| | `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` | | `NEXT_PUBLIC_APP_VERSION` | 构建期 | 与 `VERSION` 相同;**不要**设置 `NEXT_PUBLIC_API_URL=http://api:3001`(会泄漏到浏览器) | diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a54c5c8..5e2774c 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -61,6 +61,7 @@ services: condition: service_healthy environment: BACKEND_URL: http://api:3001 + SITE_URL: ${SITE_URL:-https://localhost} NODE_ENV: production PORT: "3000" HOSTNAME: 0.0.0.0 diff --git a/deploy/nginx.example.conf b/deploy/nginx.example.conf index 00d8a17..c2340ff 100644 --- a/deploy/nginx.example.conf +++ b/deploy/nginx.example.conf @@ -7,6 +7,13 @@ map $http_upgrade $connection_upgrade { '' close; } +# 明文 80 只做跳转,页面与 Cookie 都走 HTTPS +server { + listen 80; + server_name bbs.example.com; + return 301 https://$host$request_uri; +} + server { listen 443 ssl http2; server_name bbs.example.com; diff --git a/frontend/.env.example b/frontend/.env.example index febaadc..fe974e7 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -12,3 +12,8 @@ NEXT_PUBLIC_API_URL=http://localhost:3001 # 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。 # 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 diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx index aa67173..8a66033 100644 --- a/frontend/app/about/page.tsx +++ b/frontend/app/about/page.tsx @@ -18,11 +18,15 @@ import { SiteDocAnnoNav, SiteDocBreadcrumb, } from "@/components/site-doc"; +import { requestCanonical } from "@/lib/canonicalMeta"; -export const metadata: Metadata = { - title: "关于本站", - description: "站点说明、政策与帮助文档", -}; +export async function generateMetadata(): Promise { + return { + title: "关于本站", + description: "站点说明、政策与帮助文档", + ...(await requestCanonical("/about")), + }; +} function PageRow({ p, diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx index 805695f..22e44fe 100644 --- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -25,6 +25,7 @@ import { AdminIconButton, AdminPageHeader, AdminSegmented, + AdminSettingsPage, AdminStatusChip, } from "@/components/admin"; @@ -106,7 +107,7 @@ export default function AnnouncementAdmin({ } return ( -
+ -
+ ); } diff --git a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx index 97922c9..8326c73 100644 --- a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx +++ b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx @@ -1,9 +1,9 @@ "use client"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { ArrowLeft, Loader2, Save } from "lucide-react"; +import { ArrowLeft, Megaphone } from "lucide-react"; import { apiAdminCreateAnnouncement, apiAdminUpdateAnnouncement, @@ -13,7 +13,18 @@ import { } from "@/lib/api"; import { toast } from "@/lib/toast"; 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"; 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({ mode, initial, @@ -46,10 +68,13 @@ export default function AnnouncementComposeClient({ initial?: Announcement | null; }) { const router = useRouter(); - const [form, setForm] = useState(() => toForm(initial)); + const baseline = useMemo(() => toForm(initial), [initial]); + const [form, setForm] = useState(baseline); const formRef = useRef(form); const [saving, setSaving] = useState(false); const isEdit = mode === "edit" && initial != null; + const dirty = !sameForm(form, baseline); + const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving); const patchForm = (patch: Partial | ((prev: FormState) => FormState)) => { setForm((prev) => { @@ -92,108 +117,102 @@ export default function AnnouncementComposeClient({ } } + const saveLabel = isEdit ? "保存修改" : form.published ? "发布公告" : "保存草稿"; + return ( -
-
-
-

- {isEdit ? "编辑公告" : "新建公告"} -

-
- - 返回列表 - - -
-
+ + + 返回列表 + + } + /> -
-
-

发布设置

-
-
- - patchForm({ tag: e.target.value })} - placeholder="公告" +
+ + + patchForm({ tag })} + placeholder="公告" + /> + + +
+ {ANNOUNCEMENT_COLORS.map((c) => ( +
- -
-

标签颜色

-
- {ANNOUNCEMENT_COLORS.map((c) => ( -
-
- -
-

发布状态

- - role="group" - ariaLabel="发布状态" - value={form.published ? "published" : "draft"} - onChange={(key) => patchForm({ published: key === "published" })} - options={[ - { key: "published", label: "立即发布" }, - { key: "draft", label: "存为草稿" }, - ]} - /> -
- -
-
-

- 置顶公告 -

-

置顶后优先显示在首页右栏

-
- patchForm((f) => ({ ...f, pinned: !f.pinned }))} - label="置顶公告" - /> -
+ ))}
-
+ + + + role="group" + ariaLabel="发布状态" + value={form.published ? "published" : "draft"} + onChange={(key) => patchForm({ published: key === "published" })} + options={[ + { key: "published", label: "立即发布" }, + { key: "draft", label: "存为草稿" }, + ]} + /> + + + patchForm((f) => ({ ...f, pinned: !f.pinned }))} + label="置顶公告" + /> + + -
-
-

标题与正文

-
- void save()} + onDiscard={() => { + formRef.current = baseline; + setForm(baseline); + }} + /> + } + > + + patchForm({ title: e.target.value })} + onChange={(title) => patchForm({ title })} placeholder="请输入公告标题" - aria-label="公告标题" /> -
+ + +
patchForm({ content })} allowHide @@ -203,33 +222,19 @@ export default function AnnouncementComposeClient({ onSubmitShortcut={() => void save()} />
-
-
- -
-
- - 取消 - - -
-
+ +
-
+ + + ); } diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index 8c1bec9..3b72d4a 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -183,19 +183,24 @@ function PendingActions({ ); } -/** 策略项:标题与控件同一行,供横向策略条复用 */ +/** 策略项:标题、控件与保存按钮成组换行,不拆散单项 */ function PolicyChip({ title, hint, control, + instant, }: { title: string; hint?: string; control: ReactNode; + instant?: boolean; }) { return (
- {title} + + {title} + {instant ? 立即生效 : null} +
{control}
); @@ -904,16 +909,22 @@ export default function ContentAdmin({ {canConfigureSettings && (
- 共用策略 +
+ 共用策略 + + 过审免审与外链开关改完立即写入;冷静期需点保存。失败时恢复为已确认值。 + +
void saveCooldown()} > - 保存 + {savingPolicy ? "保存中…" : "保存"}
} /> (() => toForm(initial)); + const baseline = useMemo(() => toForm(initial), [initial]); + const [form, setForm] = useState(baseline); const formRef = useRef(form); const [saving, setSaving] = useState(false); const isEdit = mode === "edit" && initial != null; + const dirty = !sameForm(form, baseline); + const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving); const patchForm = (patch: Partial | ((prev: FormState) => FormState)) => { setForm((prev) => { @@ -104,125 +130,114 @@ export default function PageComposeClient({ } } + const saveLabel = isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿"; + return ( -
-
-
-

{isEdit ? "编辑单页" : "新建单页"}

-
- - 返回列表 - - -
-
+ + + 返回列表 + + } + /> -
-
-

页面设置

-
-
- -
- /p/ - patchForm({ slug: e.target.value })} - onBlur={() => - patchForm((f) => ({ ...f, slug: normalizeSlug(f.slug) })) - } - placeholder="about" - /> -
-
- -
- -