后台单页和公告改为正文优先编辑,保存与发布分开。
列表按列对照真实路径和状态,避免把 /p/ 前缀和发布操作混在一起。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Megaphone } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateAnnouncement,
|
||||
apiAdminUpdateAnnouncement,
|
||||
@@ -13,21 +13,13 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import {
|
||||
AdminField,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminSettingsActions,
|
||||
AdminSettingsGroup,
|
||||
AdminSettingsPage,
|
||||
AdminSettingsRow,
|
||||
AdminSwitch,
|
||||
} from "@/components/admin";
|
||||
import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||
import { ANNOUNCEMENT_COLORS } from "./announcementColors";
|
||||
import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColors";
|
||||
|
||||
type PublishKey = "published" | "draft";
|
||||
type StatusFilter = "all" | "published" | "draft";
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
@@ -38,6 +30,14 @@ type FormState = {
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
type FieldErrors = {
|
||||
title?: string;
|
||||
content?: string;
|
||||
tag?: string;
|
||||
tag_color?: string;
|
||||
form?: string;
|
||||
};
|
||||
|
||||
function toForm(a?: Announcement | null): FormState {
|
||||
return {
|
||||
title: a?.title ?? "",
|
||||
@@ -60,6 +60,51 @@ function sameForm(a: FormState, b: FormState): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function runeCount(s: string): number {
|
||||
return Array.from(s).length;
|
||||
}
|
||||
|
||||
function validate(form: FormState): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
const title = form.title.trim();
|
||||
if (!title) errors.title = "标题不能为空";
|
||||
else if (runeCount(title) > 200) errors.title = "标题不能超过 200 字";
|
||||
|
||||
if (!form.content.trim()) errors.content = "内容不能为空";
|
||||
|
||||
const tag = form.tag.trim();
|
||||
if (tag && runeCount(tag) > 32) errors.tag = "标签不能超过 32 字";
|
||||
return errors;
|
||||
}
|
||||
|
||||
function errorsFromServer(msg: string): FieldErrors {
|
||||
if (msg.includes("标题")) return { title: msg };
|
||||
if (msg.includes("颜色")) return { tag_color: msg };
|
||||
if (msg.includes("标签")) return { tag: msg };
|
||||
if (msg.includes("内容") || msg.includes("隐藏") || msg.includes("回复")) return { content: msg };
|
||||
return { form: msg };
|
||||
}
|
||||
|
||||
function hasErrors(errors: FieldErrors): boolean {
|
||||
return Object.values(errors).some(Boolean);
|
||||
}
|
||||
|
||||
function readStatus(raw: string | null): StatusFilter {
|
||||
if (raw === "published" || raw === "draft") return raw;
|
||||
return "all";
|
||||
}
|
||||
|
||||
function listHref(status: StatusFilter): string {
|
||||
return status === "all" ? "/admin/announcements" : `/admin/announcements?status=${status}`;
|
||||
}
|
||||
|
||||
function primaryLabel(savedPublished: boolean | null, targetPublished: boolean): string {
|
||||
if (savedPublished !== true && !targetPublished) return "保存草稿";
|
||||
if (savedPublished !== true && targetPublished) return "发布公告";
|
||||
if (savedPublished && targetPublished) return "保存并更新";
|
||||
return "转为草稿";
|
||||
}
|
||||
|
||||
export default function AnnouncementComposeClient({
|
||||
mode,
|
||||
initial,
|
||||
@@ -68,13 +113,26 @@ export default function AnnouncementComposeClient({
|
||||
initial?: Announcement | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const baseline = useMemo(() => toForm(initial), [initial]);
|
||||
const [form, setForm] = useState<FormState>(baseline);
|
||||
const searchParams = useSearchParams();
|
||||
const status = readStatus(searchParams.get("status"));
|
||||
const backHref = listHref(status);
|
||||
const loaded = useMemo(() => toForm(initial), [initial]);
|
||||
const [baseline, setBaseline] = useState<FormState>(loaded);
|
||||
const [saved, setSaved] = useState<Announcement | null>(initial ?? null);
|
||||
const [form, setForm] = useState<FormState>(loaded);
|
||||
const formRef = useRef(form);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isEdit = mode === "edit" && initial != null;
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [origin, setOrigin] = useState("");
|
||||
const [confirmUnpublish, setConfirmUnpublish] = useState(false);
|
||||
const isEdit = mode === "edit" && saved != null;
|
||||
const dirty = !sameForm(form, baseline);
|
||||
const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !saving);
|
||||
const savedPublished = isEdit ? baseline.published : null;
|
||||
|
||||
useEffect(() => {
|
||||
setOrigin(window.location.origin);
|
||||
}, []);
|
||||
|
||||
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
|
||||
setForm((prev) => {
|
||||
@@ -84,7 +142,10 @@ export default function AnnouncementComposeClient({
|
||||
});
|
||||
};
|
||||
|
||||
async function save() {
|
||||
const canViewOnline = saved?.published === true && saved.id > 0;
|
||||
const publicPath = saved ? announcementPublicPath(saved.id) : "";
|
||||
|
||||
async function commit() {
|
||||
if (saving) return;
|
||||
const current = formRef.current;
|
||||
const payload: AnnouncementInput = {
|
||||
@@ -95,135 +156,275 @@ export default function AnnouncementComposeClient({
|
||||
published: current.published,
|
||||
pinned: current.pinned,
|
||||
};
|
||||
if (!payload.title || !payload.content) {
|
||||
toast("标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setErrors({});
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiAdminUpdateAnnouncement(initial.id, payload);
|
||||
toast("公告已更新", "ok");
|
||||
if (isEdit && saved) {
|
||||
const res = await apiAdminUpdateAnnouncement(saved.id, payload);
|
||||
const nextItem = res.announcement;
|
||||
const next = toForm(nextItem);
|
||||
const wasPublished = baseline.published;
|
||||
formRef.current = next;
|
||||
setForm(next);
|
||||
setBaseline(next);
|
||||
setSaved(nextItem);
|
||||
const nowPublished = nextItem.published;
|
||||
const msg = !wasPublished && nowPublished
|
||||
? "公告已发布"
|
||||
: wasPublished && !nowPublished
|
||||
? "已转为草稿"
|
||||
: wasPublished
|
||||
? "公告已更新"
|
||||
: "草稿已保存";
|
||||
toast(msg, "ok");
|
||||
router.refresh();
|
||||
} else {
|
||||
await apiAdminCreateAnnouncement(payload);
|
||||
const res = await apiAdminCreateAnnouncement(payload);
|
||||
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
|
||||
const id = res.announcement?.id;
|
||||
if (id) {
|
||||
const q = status === "all" ? "" : `?status=${status}`;
|
||||
router.replace(`/admin/announcements/${id}/edit${q}`);
|
||||
} else {
|
||||
router.push(backHref);
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
router.push("/admin/announcements");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
const msg = e instanceof Error ? e.message : "保存失败";
|
||||
setErrors(errorsFromServer(msg));
|
||||
toast(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setConfirmUnpublish(false);
|
||||
}
|
||||
}
|
||||
|
||||
const saveLabel = isEdit ? "保存修改" : form.published ? "发布公告" : "保存草稿";
|
||||
function requestSave() {
|
||||
if (saving) return;
|
||||
const current = formRef.current;
|
||||
const nextErrors = validate(current);
|
||||
if (hasErrors(nextErrors)) {
|
||||
setErrors(nextErrors);
|
||||
toast("请先修正标出的字段");
|
||||
return;
|
||||
}
|
||||
if (isEdit && baseline.published && !current.published) {
|
||||
setConfirmUnpublish(true);
|
||||
return;
|
||||
}
|
||||
void commit();
|
||||
}
|
||||
|
||||
function discard() {
|
||||
formRef.current = baseline;
|
||||
setForm(baseline);
|
||||
setErrors({});
|
||||
}
|
||||
|
||||
async function copyAddress() {
|
||||
if (!saved) return;
|
||||
const url = `${(origin || window.location.origin).replace(/\/$/, "")}${announcementPublicPath(saved.id)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast("已复制访问地址", "ok");
|
||||
} catch {
|
||||
toast("复制失败");
|
||||
}
|
||||
}
|
||||
|
||||
const saveLabel = primaryLabel(savedPublished, form.published);
|
||||
const statusText = saving
|
||||
? "正在保存…"
|
||||
: errors.form
|
||||
? errors.form
|
||||
: dirty
|
||||
? "有未保存的修改"
|
||||
: isEdit
|
||||
? "已保存"
|
||||
: "尚未保存";
|
||||
|
||||
return (
|
||||
<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} /> 返回列表
|
||||
<div className="admin-doc-page admin-doc-editor">
|
||||
<header className="admin-doc-head">
|
||||
<div className="min-w-0">
|
||||
<Link href={backHref} className="admin-doc-back">
|
||||
<ArrowLeft size={14} aria-hidden /> 返回列表
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="admin-doc-head-title">
|
||||
<h1>{isEdit ? "编辑公告" : "新建公告"}</h1>
|
||||
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
|
||||
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
|
||||
</AdminStatusChip>
|
||||
</div>
|
||||
</div>
|
||||
{canViewOnline ? (
|
||||
<a
|
||||
href={publicPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-line"
|
||||
title="打开已发布内容。未保存的修改不会出现在这里。"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden /> 查看线上页面
|
||||
</a>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="admin-settings-main admin-settings-stack">
|
||||
<AdminSettingsGroup title="发布设置" description="标签色与置顶只影响前台展示,不改正文。">
|
||||
<AdminSettingsRow label="标签" htmlFor="anno-tag">
|
||||
<div className="admin-doc-split">
|
||||
<div className="admin-doc-main">
|
||||
<AdminField
|
||||
id="anno-title"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
error={errors.title}
|
||||
onChange={(title) => {
|
||||
setErrors((prev) => ({ ...prev, title: undefined }));
|
||||
patchForm({ title });
|
||||
}}
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
<div className="admin-doc-md">
|
||||
<label className="admin-doc-label" htmlFor="anno-content">
|
||||
正文
|
||||
</label>
|
||||
<MarkdownEditor
|
||||
id="anno-content"
|
||||
value={form.content}
|
||||
onChange={(content) => {
|
||||
setErrors((prev) => ({ ...prev, content: undefined }));
|
||||
patchForm({ content });
|
||||
}}
|
||||
allowHide
|
||||
allowReplyHide={false}
|
||||
placeholder="请输入公告正文,支持 Markdown…"
|
||||
rows={16}
|
||||
onSubmitShortcut={requestSave}
|
||||
/>
|
||||
{errors.content ? (
|
||||
<p className="admin-settings-row-error" role="alert">
|
||||
{errors.content}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details className="admin-doc-rail" open>
|
||||
<summary>公告设置</summary>
|
||||
<div className="admin-doc-rail-body">
|
||||
<AdminField
|
||||
id="anno-tag"
|
||||
label="标签"
|
||||
embed
|
||||
className="admin-settings-control-text"
|
||||
hint="显示在首页右栏和公告列表的标题旁。留空保存时会写成「公告」。"
|
||||
maxLength={32}
|
||||
value={form.tag}
|
||||
onChange={(tag) => patchForm({ tag })}
|
||||
error={errors.tag}
|
||||
onChange={(tag) => {
|
||||
setErrors((prev) => ({ ...prev, tag: undefined }));
|
||||
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>
|
||||
</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>
|
||||
|
||||
<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={(title) => patchForm({ title })}
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
</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
|
||||
allowReplyHide={false}
|
||||
placeholder="请输入公告正文,支持 Markdown…"
|
||||
rows={18}
|
||||
onSubmitShortcut={() => void save()}
|
||||
/>
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">标签颜色</p>
|
||||
<div className="admin-doc-colors" role="radiogroup" aria-label="标签颜色">
|
||||
{ANNOUNCEMENT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={form.tag_color === c.value}
|
||||
data-tip={c.label}
|
||||
aria-label={c.label}
|
||||
className={`j13-anno-dot is-${c.value}`}
|
||||
onClick={() => patchForm({ tag_color: c.value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="admin-doc-hint">只影响标签颜色,不改正文。</p>
|
||||
{errors.tag_color ? (
|
||||
<p className="admin-settings-row-error" role="alert">
|
||||
{errors.tag_color}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">置顶</p>
|
||||
<div className="admin-doc-switchline">
|
||||
<AdminSwitch
|
||||
checked={form.pinned}
|
||||
onChange={() => patchForm((f) => ({ ...f, pinned: !f.pinned }))}
|
||||
label="置顶公告"
|
||||
/>
|
||||
<span>置顶公告</span>
|
||||
</div>
|
||||
<p className="admin-doc-hint">
|
||||
已发布且置顶的公告排在首页右栏前面。草稿即使勾选也不会出现在前台。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEdit && saved ? (
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">访问地址</p>
|
||||
<p className="admin-doc-hint">
|
||||
{publicPath}
|
||||
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
|
||||
</p>
|
||||
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
|
||||
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
|
||||
<Copy size={14} aria-hidden /> 复制地址
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="admin-doc-hint">保存后才会有公开地址 /announcement/编号。草稿没有公开入口。</p>
|
||||
)}
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">保存后状态</p>
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "已发布" },
|
||||
]}
|
||||
/>
|
||||
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="admin-doc-actions" aria-busy={saving || undefined}>
|
||||
<div className="admin-doc-actions-btns">
|
||||
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
>
|
||||
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
<p className="admin-doc-hint">一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
|
||||
{canViewOnline ? (
|
||||
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
|
||||
) : (
|
||||
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -235,6 +436,18 @@ export default function AnnouncementComposeClient({
|
||||
onCancel={cancelLeave}
|
||||
onConfirm={confirmLeave}
|
||||
/>
|
||||
</AdminSettingsPage>
|
||||
<ConfirmDialog
|
||||
open={confirmUnpublish}
|
||||
title="转为草稿"
|
||||
message="转为草稿后,前台将无法打开此公告。这里没有单独的修订版本,线上内容会一起下线,不会保留旧版。"
|
||||
confirmLabel="转为草稿"
|
||||
danger
|
||||
busy={saving}
|
||||
onCancel={() => {
|
||||
if (!saving) setConfirmUnpublish(false);
|
||||
}}
|
||||
onConfirm={() => void commit()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user