公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<Metadata> {
|
||||
return {
|
||||
title: "关于本站",
|
||||
description: "站点说明、政策与帮助文档",
|
||||
...(await requestCanonical("/about")),
|
||||
};
|
||||
}
|
||||
|
||||
function PageRow({
|
||||
p,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminSettingsPage,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
|
||||
@@ -106,7 +107,7 @@ export default function AnnouncementAdmin({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminSettingsPage>
|
||||
<AdminPageHeader
|
||||
icon={Megaphone}
|
||||
title="站点公告管理"
|
||||
@@ -264,6 +265,6 @@ export default function AnnouncementAdmin({
|
||||
if (deleteTarget) void remove(deleteTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<FormState>(() => toForm(initial));
|
||||
const baseline = useMemo(() => toForm(initial), [initial]);
|
||||
const [form, setForm] = useState<FormState>(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<FormState> | ((prev: FormState) => FormState)) => {
|
||||
setForm((prev) => {
|
||||
@@ -92,108 +117,102 @@ export default function AnnouncementComposeClient({
|
||||
}
|
||||
}
|
||||
|
||||
const saveLabel = isEdit ? "保存修改" : form.published ? "发布公告" : "保存草稿";
|
||||
|
||||
return (
|
||||
<div className="j13-compose-page w-full">
|
||||
<div className="j13-compose-stage">
|
||||
<header className="j13-compose-toolbar">
|
||||
<h1 className="j13-compose-toolbar-title">
|
||||
{isEdit ? "编辑公告" : "新建公告"}
|
||||
</h1>
|
||||
<div className="j13-compose-toolbar-actions">
|
||||
<Link href="/admin/announcements" className="btn btn-line btn-sm">
|
||||
<ArrowLeft size={14} /> 返回列表
|
||||
</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>
|
||||
<AdminSettingsPage className="is-fill">
|
||||
<AdminPageHeader
|
||||
icon={Megaphone}
|
||||
title={isEdit ? "编辑公告" : "新建公告"}
|
||||
description="标签与置顶会出现在首页右栏;正文支持 Markdown。"
|
||||
actions={
|
||||
<Link href="/admin/announcements" className="btn btn-line btn-sm">
|
||||
<ArrowLeft size={14} /> 返回列表
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="j13-compose-body">
|
||||
<section className="j13-compose-section" aria-label="发布设置">
|
||||
<h2 className="j13-compose-section-title">发布设置</h2>
|
||||
<div className="j13-compose-typepanel space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="anno-tag">
|
||||
标签
|
||||
</label>
|
||||
<input
|
||||
id="anno-tag"
|
||||
className="j13-compose-field w-full max-w-[12rem]"
|
||||
maxLength={32}
|
||||
value={form.tag}
|
||||
onChange={(e) => patchForm({ tag: e.target.value })}
|
||||
placeholder="公告"
|
||||
<div className="admin-settings-main admin-settings-stack">
|
||||
<AdminSettingsGroup title="发布设置" description="标签色与置顶只影响前台展示,不改正文。">
|
||||
<AdminSettingsRow label="标签" htmlFor="anno-tag">
|
||||
<AdminField
|
||||
id="anno-tag"
|
||||
label="标签"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
maxLength={32}
|
||||
value={form.tag}
|
||||
onChange={(tag) => patchForm({ tag })}
|
||||
placeholder="公告"
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<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>
|
||||
</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="标题与正文">
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<h2 className="j13-compose-section-title !mb-0">标题与正文</h2>
|
||||
</div>
|
||||
<input
|
||||
className="j13-compose-title"
|
||||
<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="anno-title" wide>
|
||||
<AdminField
|
||||
id="anno-title"
|
||||
label="标题"
|
||||
embed
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
onChange={(e) => patchForm({ title: e.target.value })}
|
||||
onChange={(title) => patchForm({ title })}
|
||||
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
|
||||
id="anno-content"
|
||||
value={form.content}
|
||||
onChange={(content) => patchForm({ content })}
|
||||
allowHide
|
||||
@@ -203,33 +222,19 @@ export default function AnnouncementComposeClient({
|
||||
onSubmitShortcut={() => void save()}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,尚未写入的修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={cancelLeave}
|
||||
onConfirm={confirmLeave}
|
||||
/>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,19 +183,24 @@ function PendingActions({
|
||||
);
|
||||
}
|
||||
|
||||
/** 策略项:标题与控件同一行,供横向策略条复用 */
|
||||
/** 策略项:标题、控件与保存按钮成组换行,不拆散单项 */
|
||||
function PolicyChip({
|
||||
title,
|
||||
hint,
|
||||
control,
|
||||
instant,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
control: ReactNode;
|
||||
instant?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
@@ -904,16 +909,22 @@ export default function ContentAdmin({
|
||||
<AdminPageHeader
|
||||
icon={FolderOpen}
|
||||
title="内容管理"
|
||||
description="审核与管理帖子、评论;下方策略为全站共用"
|
||||
description="审核与管理帖子、评论。共用策略作用于全站发帖与评论,与下方列表筛选无关。"
|
||||
/>
|
||||
|
||||
{canConfigureSettings && (
|
||||
<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">
|
||||
<PolicyChip
|
||||
title="过审免审"
|
||||
hint="帖子 / 评论:用户曾有过审内容后,后续发帖与评论直接公开,不再进待审。"
|
||||
instant
|
||||
hint="帖子 / 评论:用户曾有过审内容后,后续发帖与评论直接公开,不再进待审。修改后立即生效。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={trustOn}
|
||||
@@ -925,7 +936,7 @@ export default function ContentAdmin({
|
||||
/>
|
||||
<PolicyChip
|
||||
title="新用户冷静期"
|
||||
hint="帖子:新注册用户在冷静期内不可发帖(0=关闭,上限 168 小时)。评论不受此限制。"
|
||||
hint="仅限制新注册用户发帖(0=关闭,上限 168 小时)。评论与聊天不受此限制。需点保存后生效。"
|
||||
control={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AdminStepper
|
||||
@@ -943,14 +954,15 @@ export default function ContentAdmin({
|
||||
disabled={savingPolicy}
|
||||
onClick={() => void saveCooldown()}
|
||||
>
|
||||
保存
|
||||
{savingPolicy ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PolicyChip
|
||||
title="外链新开"
|
||||
hint="帖子与评论中的外链在新标签打开"
|
||||
instant
|
||||
hint="帖子与评论中的外链在新标签打开。修改后立即生效。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={postLinkNewTab}
|
||||
|
||||
@@ -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, FileText } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateSitePage,
|
||||
apiAdminUpdateSitePage,
|
||||
@@ -12,7 +12,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";
|
||||
|
||||
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 仅允许小写字母数字与连字符 */
|
||||
function normalizeSlug(raw: string): string {
|
||||
return raw
|
||||
@@ -56,10 +79,13 @@ export default function PageComposeClient({
|
||||
initial?: SitePage | null;
|
||||
}) {
|
||||
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 [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<FormState> | ((prev: FormState) => FormState)) => {
|
||||
setForm((prev) => {
|
||||
@@ -104,125 +130,114 @@ export default function PageComposeClient({
|
||||
}
|
||||
}
|
||||
|
||||
const saveLabel = isEdit ? "保存修改" : form.published ? "发布页面" : "保存草稿";
|
||||
|
||||
return (
|
||||
<div className="j13-compose-page w-full">
|
||||
<div className="j13-compose-stage">
|
||||
<header className="j13-compose-toolbar">
|
||||
<h1 className="j13-compose-toolbar-title">{isEdit ? "编辑单页" : "新建单页"}</h1>
|
||||
<div className="j13-compose-toolbar-actions">
|
||||
<Link href="/admin/pages" className="btn btn-line btn-sm">
|
||||
<ArrowLeft size={14} /> 返回列表
|
||||
</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>
|
||||
<AdminSettingsPage className="is-fill">
|
||||
<AdminPageHeader
|
||||
icon={FileText}
|
||||
title={isEdit ? "编辑单页" : "新建单页"}
|
||||
description="关于本站、隐私政策等 Markdown 单页;可展示在首页右栏。"
|
||||
actions={
|
||||
<Link href="/admin/pages" className="btn btn-line btn-sm">
|
||||
<ArrowLeft size={14} /> 返回列表
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="j13-compose-body">
|
||||
<section className="j13-compose-section" aria-label="页面设置">
|
||||
<h2 className="j13-compose-section-title">页面设置</h2>
|
||||
<div className="j13-compose-typepanel space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="page-slug">
|
||||
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>
|
||||
<div className="admin-settings-main admin-settings-stack">
|
||||
<AdminSettingsGroup title="页面设置" description="slug 写入后即是前台地址 /p/…">
|
||||
<AdminSettingsRow label="URL slug" htmlFor="page-slug" hint="/p/ 后的路径">
|
||||
<div className="admin-settings-control-text flex items-center gap-1.5 min-w-0">
|
||||
<span className="meta text-[13px] shrink-0">/p/</span>
|
||||
<div className="admin-field-shell min-w-0 flex-1">
|
||||
<input
|
||||
id="page-sort"
|
||||
type="number"
|
||||
className="j13-compose-field w-28"
|
||||
value={form.sort_order}
|
||||
onChange={(e) =>
|
||||
patchForm({ sort_order: parseInt(e.target.value, 10) || 0 })
|
||||
}
|
||||
/>
|
||||
</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="显示在首页侧栏"
|
||||
id="page-slug"
|
||||
className="admin-field-input 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>
|
||||
</section>
|
||||
|
||||
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文">
|
||||
<h2 className="j13-compose-section-title">标题与正文</h2>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow label="摘要" htmlFor="page-excerpt" hintBelow="侧栏「关于本站」使用这一句" wide>
|
||||
<AdminField
|
||||
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
|
||||
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}
|
||||
value={form.title}
|
||||
onChange={(e) => patchForm({ title: e.target.value })}
|
||||
onChange={(title) => patchForm({ title })}
|
||||
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
|
||||
id="page-content"
|
||||
value={form.content}
|
||||
onChange={(content) => patchForm({ content })}
|
||||
allowHide={false}
|
||||
@@ -231,33 +246,19 @@ export default function PageComposeClient({
|
||||
onSubmitShortcut={() => void save()}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={leaveOpen}
|
||||
title="有未保存的修改"
|
||||
message="离开后,尚未写入的修改会丢失。确定离开?"
|
||||
confirmLabel="离开"
|
||||
danger
|
||||
onCancel={cancelLeave}
|
||||
onConfirm={confirmLeave}
|
||||
/>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminSettingsPage,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
|
||||
@@ -64,7 +65,7 @@ export default function PagesAdmin({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminSettingsPage>
|
||||
<AdminPageHeader
|
||||
icon={FileText}
|
||||
title="单页管理"
|
||||
@@ -200,6 +201,6 @@ export default function PagesAdmin({
|
||||
if (deleteTarget) void remove(deleteTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AdminSettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,135 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AdminField, AdminSection } from "@/components/admin";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
|
||||
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";
|
||||
|
||||
export default function BasicIdentityPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial.site_name);
|
||||
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
||||
const [slogan, setSlogan] = useState(initial.site_slogan);
|
||||
const [desc, setDesc] = useState(initial.site_description);
|
||||
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
|
||||
const [kwInput, setKwInput] = useState("");
|
||||
const [saved, setSaved] = useState({
|
||||
name: initial.site_name,
|
||||
wordmark: initial.site_wordmark,
|
||||
slogan: initial.site_slogan,
|
||||
desc: initial.site_description,
|
||||
keywords: initial.site_keywords,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const saveGen = useRef(0);
|
||||
|
||||
const dirty =
|
||||
name.trim() !== saved.name ||
|
||||
wordmark.trim() !== saved.wordmark ||
|
||||
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 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 () => {
|
||||
if (saving) return;
|
||||
const nextName = name.trim();
|
||||
if (!nextName) {
|
||||
const snap = {
|
||||
name: name.trim(),
|
||||
wordmark: wordmark.trim(),
|
||||
slogan: slogan.trim(),
|
||||
desc: desc.trim(),
|
||||
keywords: [...keywords],
|
||||
};
|
||||
if (!snap.name) {
|
||||
setNameError("显示名称不能为空");
|
||||
toast("显示名称不能为空");
|
||||
return;
|
||||
}
|
||||
setNameError("");
|
||||
setSaveError("");
|
||||
const ticket = ++saveGen.current;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({
|
||||
site_name: nextName,
|
||||
site_wordmark: wordmark.trim(),
|
||||
site_slogan: slogan.trim(),
|
||||
site_description: desc.trim(),
|
||||
site_name: snap.name,
|
||||
site_wordmark: snap.wordmark,
|
||||
site_slogan: snap.slogan,
|
||||
site_description: snap.desc,
|
||||
site_keywords: snap.keywords,
|
||||
});
|
||||
setName(res.site_name);
|
||||
setWordmark(res.site_wordmark);
|
||||
setSlogan(res.site_slogan);
|
||||
setDesc(res.site_description);
|
||||
if (ticket !== saveGen.current) return;
|
||||
setSaved({
|
||||
name: res.site_name,
|
||||
wordmark: res.site_wordmark,
|
||||
slogan: res.site_slogan,
|
||||
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);
|
||||
toast("基本信息已保存", "ok");
|
||||
toast("基础资料已保存", "ok");
|
||||
} 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 {
|
||||
setSaving(false);
|
||||
if (ticket === saveGen.current) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-grid">
|
||||
<AdminSection title="名称与字标" description="显示名称用于版权与标题;无 Logo 时页眉改用字标">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
label="显示名称"
|
||||
maxLength={32}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="姜十三论坛"
|
||||
/>
|
||||
<AdminField
|
||||
id="site-wordmark"
|
||||
label="字标"
|
||||
hint="留空则回退为显示名称,最多 16 字"
|
||||
maxLength={BRAND_LIMITS.wordmark}
|
||||
value={wordmark}
|
||||
onChange={setWordmark}
|
||||
placeholder="姜十三"
|
||||
/>
|
||||
</div>
|
||||
</AdminSection>
|
||||
<AdminSection
|
||||
title="标语与简介"
|
||||
description="标语出现在浏览器标题和页眉副标题"
|
||||
extra={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存
|
||||
</button>
|
||||
<AdminSettingsGroup
|
||||
title="基础资料"
|
||||
description="用于页眉、浏览器标题、公开页摘要与搜索关键词。保存后对访客生效。"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveLabel="保存基础资料"
|
||||
error={saveError}
|
||||
onSave={() => void save()}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AdminSettingsRow label="显示名称" htmlFor="site-name" hintBelow="用于版权行与浏览器标题">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
label="显示名称"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
maxLength={32}
|
||||
value={name}
|
||||
onChange={(v) => {
|
||||
setName(v);
|
||||
if (nameError) setNameError("");
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
placeholder="姜十三论坛"
|
||||
error={nameError}
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="字标"
|
||||
htmlFor="site-wordmark"
|
||||
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
|
||||
id="site-slogan"
|
||||
label="标语"
|
||||
maxLength={BRAND_LIMITS.slogan}
|
||||
value={slogan}
|
||||
onChange={setSlogan}
|
||||
placeholder="技术分享与讨论"
|
||||
/>
|
||||
<p className="meta text-[12.5px]">
|
||||
标题预览:<span style={{ color: "var(--ink)" }}>{preview}</span>
|
||||
</p>
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
label="简介"
|
||||
as="textarea"
|
||||
minHeight="96px"
|
||||
maxLength={160}
|
||||
value={desc}
|
||||
onChange={setDesc}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
/>
|
||||
<AdminField
|
||||
id="site-slogan"
|
||||
label="标语"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
maxLength={BRAND_LIMITS.slogan}
|
||||
value={slogan}
|
||||
onChange={(v) => {
|
||||
setSlogan(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
placeholder="技术分享与讨论"
|
||||
/>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow label="简介" htmlFor="site-desc" hintBelow="用于页面摘要与搜索描述" wide>
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
label="简介"
|
||||
embed
|
||||
as="textarea"
|
||||
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>
|
||||
</AdminSection>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function BrandMediaPicker({
|
||||
data-tip="用作草稿预览,点保存后才生效"
|
||||
>
|
||||
{/* 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
|
||||
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" }}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"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 { AdminSection, AdminSegmented } from "@/components/admin";
|
||||
import {
|
||||
AdminSegmented,
|
||||
AdminSettingsActions,
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsRow,
|
||||
} from "@/components/admin";
|
||||
import BrandLockup from "@/components/BrandLockup";
|
||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||
import {
|
||||
BRAND_LIMITS,
|
||||
BRAND_LOGO_FITS,
|
||||
BRAND_LOGO_SIZES,
|
||||
brandAccept,
|
||||
brandFileOk,
|
||||
runeCount,
|
||||
type BrandLogoFit,
|
||||
type BrandLogoSize,
|
||||
type BrandMark,
|
||||
@@ -41,18 +44,20 @@ function slotDirty(d: SlotDraft, saved: string): boolean {
|
||||
return d.committed !== saved;
|
||||
}
|
||||
|
||||
const SLOTS: { id: BrandSlot; label: string; hint: string }[] = [
|
||||
{ id: "logo_light", label: "浅色 Logo", hint: "浅色主题。只传一套时,暗色共用这张。" },
|
||||
{ id: "logo_dark", label: "暗色 Logo", hint: "暗色主题。PNG / WebP / SVG,不超过 2MB。" },
|
||||
{ id: "favicon", label: "Favicon", hint: "标签图标。另支持 ICO,不超过 512KB。" },
|
||||
const SLOTS: { id: BrandSlot; label: string; hint: string; swatch: "light" | "dark" | "neutral" }[] = [
|
||||
{ id: "logo_light", label: "浅色主题 Logo", hint: "浅色主题页眉。只传一套时,暗色共用这张。", swatch: "light" },
|
||||
{ id: "logo_dark", label: "深色主题 Logo", hint: "深色主题页眉。PNG / WebP / SVG,不超过 2MB。", swatch: "dark" },
|
||||
{ id: "favicon", label: "Favicon", hint: "浏览器标签图标。另支持 ICO,不超过 512KB。", swatch: "neutral" },
|
||||
];
|
||||
|
||||
export default function BrandSeoPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [light, setLight] = useState<SlotDraft>(() => emptyDraft(initial.logo_light_url));
|
||||
const [dark, setDark] = useState<SlotDraft>(() => emptyDraft(initial.logo_dark_url));
|
||||
@@ -61,19 +66,18 @@ export default function BrandSeoPanel({
|
||||
light: initial.logo_light_url,
|
||||
dark: initial.logo_dark_url,
|
||||
favicon: initial.favicon_url,
|
||||
keywords: initial.site_keywords,
|
||||
mark: initial.brand_mark,
|
||||
size: initial.brand_logo_size,
|
||||
fit: initial.brand_logo_fit,
|
||||
});
|
||||
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
|
||||
const [mark, setMark] = useState<BrandMark>(initial.brand_mark);
|
||||
const [logoSize, setLogoSize] = useState<BrandLogoSize>(initial.brand_logo_size);
|
||||
const [logoFit, setLogoFit] = useState<BrandLogoFit>(initial.brand_logo_fit);
|
||||
const [kwInput, setKwInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||
const blobs = useRef<string[]>([]);
|
||||
const saveGen = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const held = blobs;
|
||||
@@ -89,6 +93,7 @@ export default function BrandSeoPanel({
|
||||
};
|
||||
|
||||
const replaceFile = (slot: BrandSlot, file: File | null) => {
|
||||
setSaveError("");
|
||||
const [, set] = drafts[slot];
|
||||
set((prev) => {
|
||||
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 =
|
||||
slotDirty(light, saved.light) ||
|
||||
slotDirty(dark, saved.dark) ||
|
||||
slotDirty(favicon, saved.favicon) ||
|
||||
keywordsDirty ||
|
||||
mark !== saved.mark ||
|
||||
logoSize !== saved.size ||
|
||||
logoFit !== saved.fit;
|
||||
|
||||
const addKeyword = (raw: string) => {
|
||||
const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length === 0) return;
|
||||
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("");
|
||||
};
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise<string> => {
|
||||
if (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;
|
||||
}
|
||||
if (draft.mediaId != null) {
|
||||
const url = await apiBrandFromMedia(slot, draft.mediaId);
|
||||
const [, set] = drafts[slot];
|
||||
set({ committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" });
|
||||
return url;
|
||||
return apiBrandFromMedia(slot, draft.mediaId);
|
||||
}
|
||||
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 () => {
|
||||
if (saving || !dirty) return;
|
||||
const ticket = ++saveGen.current;
|
||||
const snap = {
|
||||
mark,
|
||||
size: logoSize,
|
||||
fit: logoFit,
|
||||
};
|
||||
setSaving(true);
|
||||
setSaveError("");
|
||||
try {
|
||||
const logoLight = await resolveSlot("logo_light", light);
|
||||
const logoDark = await resolveSlot("logo_dark", dark);
|
||||
@@ -162,53 +157,75 @@ export default function BrandSeoPanel({
|
||||
logo_light_url: logoLight,
|
||||
logo_dark_url: logoDark,
|
||||
favicon_url: fav,
|
||||
site_keywords: keywords,
|
||||
brand_mark: mark,
|
||||
brand_logo_size: logoSize,
|
||||
brand_logo_fit: logoFit,
|
||||
});
|
||||
setLight(emptyDraft(res.logo_light_url));
|
||||
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);
|
||||
if (ticket !== saveGen.current) return;
|
||||
setSaved({
|
||||
light: res.logo_light_url,
|
||||
dark: res.logo_dark_url,
|
||||
favicon: res.favicon_url,
|
||||
keywords: res.site_keywords,
|
||||
mark: res.brand_mark,
|
||||
size: res.brand_logo_size,
|
||||
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);
|
||||
toast("品牌已保存", "ok");
|
||||
toast("品牌设置已保存", "ok");
|
||||
} 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 {
|
||||
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 swatchClass =
|
||||
swatch === "light"
|
||||
? "admin-brand-swatch-light"
|
||||
: swatch === "dark"
|
||||
? "admin-brand-swatch-dark"
|
||||
: "admin-brand-swatch-neutral";
|
||||
return (
|
||||
<div key={slot}>
|
||||
<div key={slot} className="admin-brand-slot-card">
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5 mb-2">{hint}</p>
|
||||
<div
|
||||
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)" }}
|
||||
>
|
||||
<div className={`admin-brand-swatch ${swatchClass}`}>
|
||||
{preview ? (
|
||||
// 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 className="flex flex-wrap gap-1.5 mt-2">
|
||||
@@ -218,6 +235,8 @@ export default function BrandSeoPanel({
|
||||
type="file"
|
||||
accept={brandAccept(slot)}
|
||||
className="hidden"
|
||||
aria-label={`上传${label}`}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
@@ -231,13 +250,13 @@ export default function BrandSeoPanel({
|
||||
}}
|
||||
/>
|
||||
</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} /> 媒体库
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={!preview}
|
||||
disabled={saving || !preview}
|
||||
onClick={() => {
|
||||
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
||||
drafts[slot][1](emptyDraft(""));
|
||||
@@ -251,34 +270,43 @@ export default function BrandSeoPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSection
|
||||
title="品牌与 SEO"
|
||||
description="图加文字和纯图共用画幅与铺法。预览条即时变化,保存后访客才看到。"
|
||||
extra={
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存品牌
|
||||
</button>
|
||||
<AdminSettingsGroup
|
||||
title="品牌与图标"
|
||||
description="预览随编辑更新,保存后对访客生效。"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveLabel="保存品牌设置"
|
||||
error={saveError}
|
||||
onSave={() => void save()}
|
||||
onDiscard={discard}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 mb-5">
|
||||
<AdminSettingsRow label="页眉形式" hintBelow="图加文字和纯图共用画幅与铺法">
|
||||
<AdminSegmented
|
||||
role="group"
|
||||
size="sm"
|
||||
ariaLabel="页眉形式"
|
||||
value={mark}
|
||||
onChange={setMark}
|
||||
onChange={(v) => {
|
||||
setMark(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
options={[
|
||||
{ key: "image_text", label: "图加文字" },
|
||||
{ key: "image", label: "纯图" },
|
||||
{ key: "text", label: "纯文字" },
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
className="rounded-xl overflow-hidden"
|
||||
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||||
>
|
||||
<div className="h-[68px] px-4 flex items-center">
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label="品牌预览"
|
||||
hintBelow={dirty ? "页眉效果预览;站点仍显示已保存版本,保存后对访客生效" : "页眉效果预览,保存后对访客生效"}
|
||||
>
|
||||
<div className="admin-brand-lockup-preview">
|
||||
<div className="admin-brand-lockup-preview-bar">
|
||||
<BrandLockup
|
||||
mode={mark}
|
||||
size={logoSize}
|
||||
@@ -292,9 +320,11 @@ export default function BrandSeoPanel({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{mark !== "text" ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="图槽画幅">
|
||||
</AdminSettingsRow>
|
||||
{mark !== "text" ? (
|
||||
<>
|
||||
<AdminSettingsRow label="Logo 比例">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Logo 比例">
|
||||
{BRAND_LOGO_SIZES.map((item) => {
|
||||
const on = logoSize === item.id;
|
||||
const thumbH = 16;
|
||||
@@ -309,7 +339,10 @@ export default function BrandSeoPanel({
|
||||
color: on ? "var(--accent)" : "var(--ink)",
|
||||
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
|
||||
aria-hidden
|
||||
@@ -322,76 +355,47 @@ export default function BrandSeoPanel({
|
||||
/>
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow label="适配方式" hintBelow="图片如何铺进所选画幅">
|
||||
<AdminSegmented
|
||||
role="group"
|
||||
size="sm"
|
||||
ariaLabel="图槽铺法"
|
||||
ariaLabel="适配方式"
|
||||
value={logoFit}
|
||||
onChange={setLogoFit}
|
||||
onChange={(v) => {
|
||||
setLogoFit(v);
|
||||
if (saveError) setSaveError("");
|
||||
}}
|
||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="meta text-[12.5px]">纯文字不显示图槽。画幅和铺法只作用于图加文字和纯图。</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-brand-grid">
|
||||
{SLOTS.map((s) =>
|
||||
renderSlot(
|
||||
s.id,
|
||||
s.label,
|
||||
s.hint,
|
||||
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
|
||||
)
|
||||
)}
|
||||
<div className="admin-brand-keywords">
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
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>
|
||||
</AdminSettingsRow>
|
||||
</>
|
||||
) : (
|
||||
<AdminSettingsRow label="画幅与铺法">
|
||||
<p className="meta text-[12.5px]">纯文字页眉不显示 Logo。画幅和铺法只作用于图加文字和纯图。</p>
|
||||
</AdminSettingsRow>
|
||||
)}
|
||||
<AdminSettingsRow label="站点图标" hintBelow="设置不同主题的站点标识及浏览器图标" wide>
|
||||
<div className="admin-brand-slots">
|
||||
{SLOTS.map((s) =>
|
||||
renderSlot(
|
||||
s.id,
|
||||
s.label,
|
||||
s.hint,
|
||||
s.swatch,
|
||||
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<BrandMediaPicker
|
||||
open={picker != null}
|
||||
slot={picker || "logo_light"}
|
||||
@@ -403,6 +407,6 @@ export default function BrandSeoPanel({
|
||||
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
|
||||
}}
|
||||
/>
|
||||
</AdminSection>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AdminField, AdminSection } from "@/components/admin";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSettingsRow } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import {
|
||||
BRAND_LIMITS,
|
||||
@@ -19,48 +19,72 @@ function clone(links: FooterLink[]): FooterLink[] {
|
||||
export default function FooterLinksPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
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 [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>) => {
|
||||
setError("");
|
||||
setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item)));
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setLinks(clone(savedLinks));
|
||||
setError("");
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving || !dirty) return;
|
||||
for (const item of links) {
|
||||
if (!item.label.trim()) {
|
||||
setError("链接文案不能为空");
|
||||
toast("链接文案不能为空");
|
||||
return;
|
||||
}
|
||||
if (!footerURLAllowed(item.url)) {
|
||||
setError(`地址无效:${item.url || "(空)"}`);
|
||||
toast(`地址无效:${item.url || "(空)"}`);
|
||||
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);
|
||||
try {
|
||||
const payload = links.map((item) => ({
|
||||
label: item.label.trim(),
|
||||
url: item.url.trim(),
|
||||
new_tab: item.new_tab,
|
||||
}));
|
||||
const res = await apiUpdateSiteSettings({ footer_links: payload });
|
||||
setLinks(clone(res.footer_links));
|
||||
setSaved(clone(res.footer_links));
|
||||
const res = await apiUpdateSiteSettings({
|
||||
footer_links: snapLinks,
|
||||
});
|
||||
if (ticket !== saveGen.current) return;
|
||||
setSavedLinks(clone(res.footer_links));
|
||||
setLinks((prev) => (JSON.stringify(prev) === JSON.stringify(links) ? clone(res.footer_links) : prev));
|
||||
onSaved?.(res);
|
||||
toast("页脚链接已保存", "ok");
|
||||
toast("页脚已保存", "ok");
|
||||
} 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 {
|
||||
setSaving(false);
|
||||
if (ticket === saveGen.current) setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,20 +100,28 @@ export default function FooterLinksPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-grid">
|
||||
<AdminSection
|
||||
title="页脚与合规"
|
||||
description="用于 ICP 备案、用户协议等。最多 8 条。外链默认新标签打开。"
|
||||
extra={
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存
|
||||
</button>
|
||||
}
|
||||
<AdminSettingsGroup
|
||||
title="页脚"
|
||||
description="备案、协议等链接。保存后出现在站点页脚右侧。"
|
||||
footer={
|
||||
<AdminSettingsActions
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveLabel="保存页脚"
|
||||
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) => (
|
||||
<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">
|
||||
<span className="meta text-[12px]">第 {i + 1} 条</span>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -154,20 +186,30 @@ export default function FooterLinksPanel({
|
||||
<Plus size={14} /> 新增链接
|
||||
</button>
|
||||
</div>
|
||||
</AdminSection>
|
||||
<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)" }}>
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {new Date().getFullYear()} {initial.site_name}</span>
|
||||
{links.filter((l) => l.label.trim() && l.url.trim()).map((item) => (
|
||||
<span key={`${item.label}-${item.url}`} className="text-[12px]" style={{ color: "var(--ink-2)" }}>
|
||||
{item.label.trim()}
|
||||
{item.new_tab ? " ↗" : ""}
|
||||
</AdminSettingsRow>
|
||||
|
||||
<AdminSettingsRow label="页脚预览" hintBelow="当前编辑预览,保存后页脚才会更新">
|
||||
<div className="admin-footer-preview">
|
||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
|
||||
<span className="meta text-[12px] whitespace-nowrap">
|
||||
© {new Date().getFullYear()} {initial.site_name}
|
||||
</span>
|
||||
))}
|
||||
<span className="meta text-[12px] ml-auto">性能指标</span>
|
||||
<span className="meta text-[12px] whitespace-nowrap">版本:— · 页面:— · 模板:—</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>
|
||||
<p className="meta text-[12px] mt-3">外链保存后带 target=_blank 与 rel=noopener。站内路径留在本页。</p>
|
||||
</AdminSection>
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,9 @@ import {
|
||||
SiteDocMasthead,
|
||||
SiteDocBreadcrumb,
|
||||
} from "@/components/site-doc";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { webPageJsonLd } from "@/lib/jsonLd";
|
||||
import JsonLd from "@/components/JsonLd";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -35,6 +38,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
type: "article",
|
||||
publishedTime: data.announcement.created_at,
|
||||
},
|
||||
...(await requestCanonical(`/announcement/${data.announcement.id}`)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,15 +75,15 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: a.title,
|
||||
const jsonLd = webPageJsonLd({
|
||||
type: "Article",
|
||||
name: a.title,
|
||||
path: `/announcement/${a.id}`,
|
||||
datePublished: a.created_at,
|
||||
dateModified: a.updated_at,
|
||||
articleSection: "站点公告",
|
||||
keywords: a.tag,
|
||||
};
|
||||
});
|
||||
|
||||
const left = (
|
||||
<>
|
||||
@@ -97,10 +101,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<JsonLd data={jsonLd} />
|
||||
|
||||
<SiteDocShell
|
||||
left={left}
|
||||
|
||||
@@ -14,16 +14,23 @@ import {
|
||||
SiteDocPageNav,
|
||||
SiteDocBreadcrumb,
|
||||
} from "@/components/site-doc";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "站点公告",
|
||||
description: "站点公告与通知列表",
|
||||
};
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { canonicalPage } from "@/lib/siteUrl";
|
||||
|
||||
interface PageProps {
|
||||
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,取摘要 */
|
||||
function plainSummary(md: string, max = 120): string {
|
||||
const text = md
|
||||
|
||||
@@ -3127,28 +3127,10 @@ body:has([data-admin-viewport]) {
|
||||
}
|
||||
}
|
||||
|
||||
/* 后台公告/单页编辑:取消画布内边距,顶栏贴紧 admin-topbar 钉住,避免缝里透出下方内容 */
|
||||
.admin-canvas:has(.j13-compose-page) {
|
||||
padding: 0;
|
||||
}
|
||||
/* 用户管理吸顶:顶内边距挪到 .admin-users-page,sticky 才能贴紧顶栏而不提前定住 */
|
||||
.admin-canvas:has(.admin-users-stick) {
|
||||
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 {
|
||||
@@ -3590,79 +3572,569 @@ a.j13-mention-token:hover {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 站点设置:顶栏横向 tab,窄屏横向滚动 */
|
||||
.admin-settings-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.125rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
scrollbar-width: thin;
|
||||
/* 站点设置:限宽左对齐,不写入后台公共主容器 */
|
||||
.admin-canvas:has(.admin-settings-page) {
|
||||
background: var(--bg);
|
||||
}
|
||||
.admin-settings-tab {
|
||||
appearance: none;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
@media (min-width: 1920px) {
|
||||
.admin-canvas:has(.admin-settings-page) {
|
||||
padding-left: 3rem; /* 宽屏距主导航最多约 48px;1024+ 默认 2rem */
|
||||
}
|
||||
}
|
||||
.admin-settings-tab:hover:not([aria-selected="true"]) {
|
||||
color: var(--ink);
|
||||
.admin-settings-page {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
margin-inline: 0;
|
||||
container-type: inline-size;
|
||||
container-name: settings-page;
|
||||
}
|
||||
.admin-settings-tab[aria-selected="true"] {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--accent);
|
||||
.admin-settings-page > .mb-5 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.admin-settings-tab:focus-visible {
|
||||
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 {
|
||||
.admin-settings-work {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 10rem minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.admin-settings-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
.admin-settings-nav-col {
|
||||
min-width: 0;
|
||||
}
|
||||
.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 并排 */
|
||||
.admin-brand-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
@container settings-form (max-width: 719px) {
|
||||
.admin-settings-row,
|
||||
.admin-settings-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.admin-settings-row-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
.admin-settings-actions-inner {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.admin-brand-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
/* 填满可视区:提交导入 JSON / 公告与单页正文,底部操作钉在视口内 */
|
||||
@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 {
|
||||
grid-column: 1 / -1;
|
||||
.admin-settings-page.is-fill {
|
||||
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 {
|
||||
@@ -3979,38 +4451,59 @@ a.j13-mention-token:hover {
|
||||
/* 策略条:横向排布,压缩垂直占用 */
|
||||
.admin-content-policy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
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 {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
flex: 1 1 18rem;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-content-policy-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid transparent;
|
||||
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 {
|
||||
font-size: 12.5px;
|
||||
@@ -4018,6 +4511,15 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink);
|
||||
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 左中右三栏等高;仅列表区滚动 */
|
||||
.admin-content-triptych {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { accentStyleCSS } from "@/lib/theme";
|
||||
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
||||
import { siteDocumentTitle } from "@/lib/brand";
|
||||
import { motionConfigFromSettings } from "@/lib/motion";
|
||||
import { requestMetadataBase } from "@/lib/canonicalMeta";
|
||||
|
||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
|
||||
@@ -52,7 +53,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
const title = siteDocumentTitle(name, settings.site_slogan || "");
|
||||
const desc = settings.site_description || "姜十三论坛 - 技术分享与讨论社区";
|
||||
const keywords = settings.site_keywords?.length ? settings.site_keywords : undefined;
|
||||
const metadataBase = await requestMetadataBase();
|
||||
return {
|
||||
// 只提供解析相对 URL 的基址;Canonical 由各公开页自行声明,避免子路由继承首页
|
||||
...(metadataBase ? { metadataBase } : {}),
|
||||
title: {
|
||||
default: title,
|
||||
template: `%s · ${name}`,
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
SiteDocMasthead,
|
||||
SiteDocBreadcrumb,
|
||||
} from "@/components/site-doc";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { webPageJsonLd } from "@/lib/jsonLd";
|
||||
import JsonLd from "@/components/JsonLd";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
@@ -36,6 +39,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
type: "article",
|
||||
publishedTime: p.created_at,
|
||||
},
|
||||
...(await requestCanonical(`/p/${p.slug}`)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,14 +74,13 @@ export default async function SitePagePublic({ params }: PageProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
const jsonLd = webPageJsonLd({
|
||||
name: p.title,
|
||||
description: p.excerpt || undefined,
|
||||
path: `/p/${p.slug}`,
|
||||
datePublished: p.created_at,
|
||||
dateModified: p.updated_at,
|
||||
};
|
||||
});
|
||||
|
||||
const left = (
|
||||
<>
|
||||
@@ -99,10 +102,7 @@ export default async function SitePagePublic({ params }: PageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<JsonLd data={jsonLd} />
|
||||
|
||||
<SiteDocShell
|
||||
left={left}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { siteDocumentTitle } from "@/lib/brand";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Pagination from "@/components/Pagination";
|
||||
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 { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
|
||||
import { SiteDocBreadcrumb } from "@/components/site-doc";
|
||||
@@ -25,25 +25,24 @@ import {
|
||||
BoardPanelsMobile,
|
||||
BoardMissingPanel,
|
||||
} from "@/components/home/BoardPanels";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getPublicSettingsCached();
|
||||
return {
|
||||
title: { absolute: siteDocumentTitle(settings.site_name, settings.site_slogan) },
|
||||
description: settings.site_description,
|
||||
};
|
||||
}
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
|
||||
import { listingJsonLd } from "@/lib/jsonLd";
|
||||
import JsonLd from "@/components/JsonLd";
|
||||
|
||||
interface HomePageProps {
|
||||
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
|
||||
}
|
||||
|
||||
function normalizeSort(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";
|
||||
export async function generateMetadata({ searchParams }: HomePageProps): Promise<Metadata> {
|
||||
const settings = await getPublicSettingsCached();
|
||||
const sp = await searchParams;
|
||||
const allowComments = settings.allow_comments !== false;
|
||||
return {
|
||||
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 publicSettings = await getPublicSettingsCached();
|
||||
const allowComments = publicSettings.allow_comments !== false;
|
||||
const sort = normalizeSort(sp.sort, allowComments);
|
||||
const sort = normalizeFeedSort(sp.sort, allowComments);
|
||||
// 与 layout 共享同一份 /me 请求(React cache 去重)
|
||||
const mePromise = getMeCached(cookie || undefined);
|
||||
const postsPromise = fetchPosts(
|
||||
@@ -125,10 +124,31 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
|
||||
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/侧栏
|
||||
if (isSearch) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<JsonLd data={listingLd} />
|
||||
<div className="mb-5">
|
||||
<h1 className="text-[24px] sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
搜索“<span style={{ color: "var(--accent)" }}>{keyword}</span>”
|
||||
@@ -149,27 +169,23 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
page={page}
|
||||
size={20}
|
||||
basePath="/"
|
||||
query={{ q: keyword }}
|
||||
query={{
|
||||
q: keyword,
|
||||
...(sort !== defaultFeedSort(allowComments) ? { sort } : {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 置顶帖直接按后端 pinned DESC 顺序并入统一信息流;标题仅供屏幕阅读器
|
||||
const heading = activeBoard
|
||||
? activeBoard.name
|
||||
: sort === "new"
|
||||
? "最新发布"
|
||||
: sort === "recommended"
|
||||
? "推荐帖"
|
||||
: "最新讨论";
|
||||
|
||||
const pageQuery: Record<string, string> = {};
|
||||
if (boardId > 0) pageQuery.board = String(boardId);
|
||||
if (sort !== "latest") pageQuery.sort = sort;
|
||||
if (sort !== defaultFeedSort(allowComments)) pageQuery.sort = sort;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<JsonLd data={listingLd} />
|
||||
<WorkspaceNavMobile boards={boards} user={user} />
|
||||
|
||||
<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 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="sr-only">{heading}</h1>
|
||||
<h1 className="sr-only">{pageName}</h1>
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
{activeBoard ? (
|
||||
|
||||
@@ -43,6 +43,10 @@ import { canModerateBoard } from "@/lib/roles";
|
||||
import { IconComment, IconEye } from "@/components/Icons";
|
||||
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
||||
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 {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -50,8 +54,9 @@ interface PageProps {
|
||||
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 sp = await searchParams;
|
||||
try {
|
||||
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
|
||||
const { post } = await fetchPostDetail(id, postViewerCookieHeader(await cookies()));
|
||||
@@ -66,6 +71,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
const desc = publicBody
|
||||
? publicBody.slice(0, 160)
|
||||
: post.access_hint || "部分内容需满足条件后可见";
|
||||
const page = canonicalPage(sp.page);
|
||||
return {
|
||||
title: post.title,
|
||||
description: desc,
|
||||
@@ -74,6 +80,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
description: desc,
|
||||
type: "article",
|
||||
},
|
||||
...(await requestCanonical(`/post/${post.id}`, page ? { page } : undefined)),
|
||||
};
|
||||
} catch {
|
||||
return { title: "帖子不存在" };
|
||||
@@ -505,39 +512,63 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
}
|
||||
: null;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DiscussionForumPosting",
|
||||
headline: post.title,
|
||||
// 仅公开片段进 JSON-LD,避免 SEO 泄露隐藏块原文
|
||||
articleBody: publicBody || undefined,
|
||||
datePublished: post.created_at,
|
||||
dateModified: post.updated_at,
|
||||
author: { "@type": "Person", name: post.user.nickname },
|
||||
...(allowComments ? { commentCount: post.comment_count } : {}),
|
||||
interactionStatistic: {
|
||||
"@type": "InteractionCounter",
|
||||
interactionType: { "@type": "ReadAction" },
|
||||
userInteractionCount: post.view_count,
|
||||
},
|
||||
...(allowComments &&
|
||||
acceptedAnswer &&
|
||||
!acceptedAnswer.deleted &&
|
||||
!!publicBody && {
|
||||
acceptedAnswer: {
|
||||
"@type": "Comment",
|
||||
text: acceptedAnswer.content,
|
||||
dateCreated: acceptedAnswer.created_at,
|
||||
const entityUrl = absoluteCanonical(`/post/${post.id}`);
|
||||
const pageUrl =
|
||||
absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) ||
|
||||
entityUrl;
|
||||
const authorName = post.user.nickname || post.user.username;
|
||||
const visibleFloors: JsonLdVisibleComment[] = allowComments
|
||||
? comments
|
||||
.filter((c) => !c.deleted && (c.content || "").trim())
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
content: c.content,
|
||||
created_at: c.created_at,
|
||||
author: c.user
|
||||
? { id: c.user.id, name: c.user.nickname || c.user.username }
|
||||
: undefined,
|
||||
}))
|
||||
: [];
|
||||
const acceptedForLd: JsonLdVisibleComment | null =
|
||||
allowComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
|
||||
? {
|
||||
id: acceptedAnswer.id,
|
||||
content: acceptedAnswer.content,
|
||||
created_at: acceptedAnswer.created_at,
|
||||
author: acceptedAnswer.user
|
||||
? {
|
||||
"@type": "Person",
|
||||
id: acceptedAnswer.user.id,
|
||||
name: acceptedAnswer.user.nickname || acceptedAnswer.user.username,
|
||||
}
|
||||
: 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)
|
||||
let headingIndex = 0;
|
||||
@@ -557,7 +588,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<JsonLd data={jsonLd} />
|
||||
{/* 移动端浮动操作条:长文读到中途可点赞 / 跳评论框 */}
|
||||
<MobilePostBar
|
||||
postId={id}
|
||||
@@ -678,8 +709,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
</span>
|
||||
</div>
|
||||
<p className="meta w-full text-[12px] sm:w-auto sm:ml-auto">
|
||||
发布于 {formatDateTime(post.created_at)}
|
||||
{edited && <> · 更新于 {formatDateTime(post.updated_at)}</>}
|
||||
发布于 <time dateTime={post.created_at}>{formatDateTime(post.created_at)}</time>
|
||||
{edited && (
|
||||
<>
|
||||
{" "}
|
||||
· 更新于 <time dateTime={post.updated_at}>{formatDateTime(post.updated_at)}</time>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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 {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: "http://localhost:3000/sitemap.xml",
|
||||
...(origin ? { sitemap: `${origin}/sitemap.xml` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { connection } from "next/server";
|
||||
import {
|
||||
fetchPosts,
|
||||
fetchAnnouncementsPage,
|
||||
fetchPublishedSitePages,
|
||||
} from "@/lib/api";
|
||||
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
|
||||
|
||||
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 = [
|
||||
{ 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(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/about`,
|
||||
url: url("/about"),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
@@ -27,7 +36,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const { posts } = await fetchPosts(1, 100);
|
||||
for (const post of posts) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/post/${post.id}`,
|
||||
url: url(`/post/${post.id}`),
|
||||
lastModified: new Date(post.created_at),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.6,
|
||||
@@ -41,7 +50,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const { announcements } = await fetchAnnouncementsPage(1, 100);
|
||||
for (const a of announcements) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/announcement/${a.id}`,
|
||||
url: url(`/announcement/${a.id}`),
|
||||
lastModified: new Date(a.updated_at || a.created_at),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.5,
|
||||
@@ -55,7 +64,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const { pages } = await fetchPublishedSitePages();
|
||||
for (const p of pages) {
|
||||
entries.push({
|
||||
url: `${baseUrl}/p/${p.slug}`,
|
||||
url: url(`/p/${p.slug}`),
|
||||
lastModified: new Date(p.updated_at || p.created_at),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.4,
|
||||
|
||||
@@ -20,6 +20,10 @@ import RoleBadge from "@/components/RoleBadge";
|
||||
import { roleMeta } from "@/lib/roles";
|
||||
import StartDMButton from "./StartDMButton";
|
||||
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 {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -28,16 +32,33 @@ interface UserPageProps {
|
||||
|
||||
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 sp = await searchParams;
|
||||
const rawTab = sp.tab;
|
||||
const tab: UserTab =
|
||||
rawTab === "comments" ? "comments" :
|
||||
rawTab === "settings" ? "settings" :
|
||||
rawTab === "security" ? "security" : "posts";
|
||||
try {
|
||||
const data = await fetchUserProfile(id, 1);
|
||||
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 {
|
||||
title: data.user.nickname || data.user.username,
|
||||
description: allowComments
|
||||
? `查看 ${data.user.nickname} 的资料、发帖与评论`
|
||||
: `查看 ${data.user.nickname} 的资料与发帖`,
|
||||
title,
|
||||
description,
|
||||
...(await requestCanonical(`/u/${data.user.id}`, query)),
|
||||
};
|
||||
} catch {
|
||||
return { title: "用户不存在" };
|
||||
@@ -135,11 +156,26 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
const visibleNav = navItems.filter((t) => !t.ownerOnly || isOwner);
|
||||
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 自动加深 */
|
||||
const cardShadow = "0 1px 3px color-mix(in srgb, #000 18%, transparent)";
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||
<ChevronRight size={13} />
|
||||
|
||||
Reference in New Issue
Block a user