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

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

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}