feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,59 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Save,
|
||||
X,
|
||||
ExternalLink,
|
||||
Megaphone,
|
||||
Pin,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminListAnnouncements,
|
||||
apiAdminCreateAnnouncement,
|
||||
apiAdminUpdateAnnouncement,
|
||||
apiAdminDeleteAnnouncement,
|
||||
apiAdminToggleAnnouncementPin,
|
||||
type Announcement,
|
||||
type AnnouncementColor,
|
||||
type AnnouncementInput,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminField,
|
||||
AdminIconButton,
|
||||
AdminPageHeader,
|
||||
AdminSegmented,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
|
||||
const COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||
{ value: "blue", label: "蓝色" },
|
||||
{ value: "green", label: "绿色" },
|
||||
{ value: "orange", label: "橙色" },
|
||||
{ value: "red", label: "红色" },
|
||||
{ value: "purple", label: "紫色" },
|
||||
{ value: "gray", label: "灰色" },
|
||||
];
|
||||
|
||||
type FormState = AnnouncementInput & { id: number | null };
|
||||
type PublishKey = "published" | "draft";
|
||||
type StatusFilter = "all" | "published" | "draft";
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
id: null,
|
||||
title: "",
|
||||
content: "",
|
||||
tag: "公告",
|
||||
tag_color: "blue",
|
||||
published: true,
|
||||
};
|
||||
|
||||
/** 粗略去掉 Markdown,取一行摘要 */
|
||||
function plainSummary(md: string, max = 96): string {
|
||||
const text = md
|
||||
@@ -66,144 +42,6 @@ function plainSummary(md: string, max = 96): string {
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
function AnnouncementForm({
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (f: FormState) => void;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel p-5">
|
||||
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
|
||||
{form.id == null ? "新建公告" : "编辑公告"}
|
||||
</p>
|
||||
|
||||
<AdminField
|
||||
id="anno-title"
|
||||
className="mb-4"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
onChange={(title) => setForm({ ...form, title })}
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
|
||||
<div className="flex items-end gap-4 mb-4 flex-wrap">
|
||||
<div className="w-36">
|
||||
<AdminField
|
||||
id="anno-tag"
|
||||
label="标签"
|
||||
maxLength={32}
|
||||
value={form.tag}
|
||||
onChange={(tag) => setForm({ ...form, tag })}
|
||||
placeholder="公告"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="field-label">标签颜色</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{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={() => setForm({ ...form, tag_color: c.value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="field-label">发布状态</p>
|
||||
<div className="mb-4">
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="发布状态"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => setForm({ ...form, published: key === "published" })}
|
||||
options={[
|
||||
{ key: "published", label: "立即发布" },
|
||||
{ key: "draft", label: "存为草稿" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminField
|
||||
id="anno-content"
|
||||
label="正文(支持 Markdown)"
|
||||
as="textarea"
|
||||
minHeight="260px"
|
||||
value={form.content}
|
||||
onChange={(content) => setForm({ ...form, content })}
|
||||
placeholder="请输入公告正文,支持 Markdown 语法…"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2.5 mt-4">
|
||||
<button type="button" onClick={onSave} disabled={saving} className="btn btn-primary flex-1 justify-center">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" /> 保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={15} /> {form.id == null ? "发布公告" : "保存修改"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="btn btn-line"
|
||||
aria-label="取消编辑"
|
||||
>
|
||||
<X size={15} /> 取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnnouncementPreview({ a }: { a: Announcement }) {
|
||||
return (
|
||||
<div className="panel p-5">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
||||
{a.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
<span className="meta text-[11.5px] tabular-nums">{formatRelative(a.updated_at || a.created_at)}</span>
|
||||
</div>
|
||||
<h2 className="text-[17px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
|
||||
{a.title}
|
||||
</h2>
|
||||
<p className="mt-3 text-[13.5px] leading-relaxed whitespace-pre-wrap" style={{ color: "var(--ink-2)" }}>
|
||||
{a.content}
|
||||
</p>
|
||||
{a.published ? (
|
||||
<a
|
||||
href={`/announcement/${a.id}`}
|
||||
className="btn btn-line btn-sm mt-4 inline-flex"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink size={14} /> 查看前台页
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnnouncementAdmin({
|
||||
initial,
|
||||
initError,
|
||||
@@ -213,10 +51,8 @@ export default function AnnouncementAdmin({
|
||||
}) {
|
||||
const [list, setList] = useState(initial);
|
||||
const [loadError, setLoadError] = useState(initError);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(initial[0]?.id ?? null);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pinningId, setPinningId] = useState<number | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
|
||||
|
||||
@@ -229,11 +65,6 @@ export default function AnnouncementAdmin({
|
||||
return list;
|
||||
}, [list, statusFilter]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => (selectedId == null ? null : list.find((a) => a.id === selectedId) ?? null),
|
||||
[list, selectedId]
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiAdminListAnnouncements();
|
||||
@@ -244,59 +75,19 @@ export default function AnnouncementAdmin({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCreate = () => {
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const startEdit = (a: Announcement) => {
|
||||
setSelectedId(a.id);
|
||||
setForm({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
content: a.content,
|
||||
tag: a.tag,
|
||||
tag_color: a.tag_color,
|
||||
published: a.published,
|
||||
});
|
||||
};
|
||||
|
||||
const selectRow = (a: Announcement) => {
|
||||
setSelectedId(a.id);
|
||||
if (form && form.id !== a.id) setForm(null);
|
||||
};
|
||||
|
||||
async function save() {
|
||||
if (!form) return;
|
||||
const payload: AnnouncementInput = {
|
||||
title: form.title.trim(),
|
||||
content: form.content.trim(),
|
||||
tag: form.tag.trim() || "公告",
|
||||
tag_color: form.tag_color,
|
||||
published: form.published,
|
||||
};
|
||||
if (!payload.title || !payload.content) {
|
||||
toast("标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
async function togglePin(a: Announcement) {
|
||||
if (pinningId != null) return;
|
||||
setPinningId(a.id);
|
||||
try {
|
||||
if (form.id == null) {
|
||||
const res = await apiAdminCreateAnnouncement(payload);
|
||||
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
|
||||
setForm(null);
|
||||
setSelectedId(res.announcement?.id ?? null);
|
||||
} else {
|
||||
await apiAdminUpdateAnnouncement(form.id, payload);
|
||||
toast("公告已更新", "ok");
|
||||
setForm(null);
|
||||
setSelectedId(form.id);
|
||||
}
|
||||
const res = await apiAdminToggleAnnouncementPin(a.id);
|
||||
setList((prev) => prev.map((x) => (x.id === a.id ? res.announcement : x)));
|
||||
toast(res.announcement.pinned ? "已置顶" : "已取消置顶", "ok");
|
||||
// 置顶后列表顺序可能变化,重新拉一次对齐后端排序
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
toast(e instanceof Error ? e.message : "置顶失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setPinningId(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,12 +95,7 @@ export default function AnnouncementAdmin({
|
||||
setDeletingId(a.id);
|
||||
try {
|
||||
await apiAdminDeleteAnnouncement(a.id);
|
||||
setList((l) => {
|
||||
const next = l.filter((x) => x.id !== a.id);
|
||||
if (selectedId === a.id) setSelectedId(next[0]?.id ?? null);
|
||||
return next;
|
||||
});
|
||||
if (form?.id === a.id) setForm(null);
|
||||
setList((l) => l.filter((x) => x.id !== a.id));
|
||||
toast("公告已删除", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
@@ -324,13 +110,11 @@ export default function AnnouncementAdmin({
|
||||
<AdminPageHeader
|
||||
icon={Megaphone}
|
||||
title="站点公告管理"
|
||||
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
|
||||
description="发布、编辑与下线右栏站点公告,支持置顶与自定义标签色"
|
||||
actions={
|
||||
form?.id == null && form != null ? undefined : (
|
||||
<button type="button" onClick={startCreate} className="btn btn-primary">
|
||||
<Plus size={15} /> 新建公告
|
||||
</button>
|
||||
)
|
||||
<Link href="/admin/announcements/new" className="btn btn-primary">
|
||||
<Plus size={15} /> 新建公告
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -348,146 +132,126 @@ export default function AnnouncementAdmin({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
{/* 左侧列表 */}
|
||||
<div className="panel overflow-hidden min-w-0">
|
||||
<div
|
||||
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
公告列表
|
||||
</span>
|
||||
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
|
||||
</div>
|
||||
<div className="panel overflow-hidden min-w-0">
|
||||
<div
|
||||
className="px-4 sm:px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
公告列表
|
||||
</span>
|
||||
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<p className="alert-error m-4">{loadError}</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<AdminEmpty
|
||||
icon={<Megaphone size={22} />}
|
||||
title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"}
|
||||
description={list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"}
|
||||
/>
|
||||
) : (
|
||||
filtered.map((a, idx) => {
|
||||
const active = selectedId === a.id || form?.id === a.id;
|
||||
const summary = plainSummary(a.content);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => selectRow(a)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectRow(a);
|
||||
}
|
||||
}}
|
||||
className="px-4 sm:px-5 py-3.5 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
style={{
|
||||
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
|
||||
background: active ? "var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<p
|
||||
className="text-[14px] font-medium truncate max-w-full"
|
||||
style={{ color: "var(--ink)" }}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</p>
|
||||
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
||||
{a.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
<span className="meta text-[11px] tabular-nums shrink-0">
|
||||
{formatRelative(a.updated_at || a.created_at)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{a.published ? (
|
||||
<AdminIconButton
|
||||
href={`/announcement/${a.id}`}
|
||||
tip="查看"
|
||||
ariaLabel="查看公告"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</AdminIconButton>
|
||||
) : null}
|
||||
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
|
||||
<Pencil size={14} />
|
||||
</AdminIconButton>
|
||||
<AdminIconButton
|
||||
tip="删除"
|
||||
ariaLabel="删除公告"
|
||||
tone="danger"
|
||||
disabled={deletingId === a.id}
|
||||
onClick={() => setDeleteTarget(a)}
|
||||
>
|
||||
{deletingId === a.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</AdminIconButton>
|
||||
</span>
|
||||
</div>
|
||||
{summary ? (
|
||||
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
|
||||
{loadError ? (
|
||||
<p className="alert-error m-4">{loadError}</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<AdminEmpty
|
||||
icon={<Megaphone size={22} />}
|
||||
title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"}
|
||||
description={
|
||||
list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
filtered.map((a, idx) => {
|
||||
const summary = plainSummary(a.content);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className="px-4 sm:px-5 py-3.5"
|
||||
style={{
|
||||
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<span
|
||||
className={`j13-anno-dot is-${a.tag_color} mt-1.5 shrink-0`}
|
||||
aria-hidden
|
||||
data-tip={a.tag}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{a.pinned ? (
|
||||
<span className="badge-pin shrink-0">置顶</span>
|
||||
) : null}
|
||||
<p
|
||||
className="text-[14px] font-medium truncate max-w-full"
|
||||
style={{ color: "var(--ink)" }}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</p>
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
|
||||
{a.published ? "已发布" : "草稿"}
|
||||
</AdminStatusChip>
|
||||
<span className="meta text-[11px] tabular-nums shrink-0">
|
||||
{formatRelative(a.updated_at || a.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{summary ? (
|
||||
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-0.5 shrink-0">
|
||||
<AdminIconButton
|
||||
tip={a.pinned ? "取消置顶" : "置顶"}
|
||||
ariaLabel={a.pinned ? "取消置顶" : "置顶公告"}
|
||||
disabled={pinningId === a.id}
|
||||
onClick={() => void togglePin(a)}
|
||||
>
|
||||
{pinningId === a.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Pin size={14} style={{ color: a.pinned ? "var(--accent)" : undefined }} />
|
||||
)}
|
||||
</AdminIconButton>
|
||||
{a.published ? (
|
||||
<AdminIconButton
|
||||
href={`/announcement/${a.id}`}
|
||||
tip="查看前台"
|
||||
ariaLabel="查看公告"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</AdminIconButton>
|
||||
) : null}
|
||||
<AdminIconButton
|
||||
href={`/admin/announcements/${a.id}/edit`}
|
||||
tip="编辑"
|
||||
ariaLabel="编辑公告"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</AdminIconButton>
|
||||
<AdminIconButton
|
||||
tip="删除"
|
||||
ariaLabel="删除公告"
|
||||
tone="danger"
|
||||
disabled={deletingId === a.id}
|
||||
onClick={() => setDeleteTarget(a)}
|
||||
>
|
||||
{deletingId === a.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</AdminIconButton>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{list.length === 0 && !loadError ? (
|
||||
<div className="px-4 sm:px-5 pb-5 -mt-4 flex justify-center">
|
||||
<button type="button" onClick={startCreate} className="btn btn-primary">
|
||||
<Plus size={15} /> 新建第一条公告
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 右侧:表单 / 预览 / 占位 */}
|
||||
<div className="min-w-0 lg:sticky lg:top-4">
|
||||
{form ? (
|
||||
<AnnouncementForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
saving={saving}
|
||||
onSave={() => void save()}
|
||||
onCancel={() => setForm(null)}
|
||||
/>
|
||||
) : selected ? (
|
||||
<AnnouncementPreview a={selected} />
|
||||
) : (
|
||||
<div className="panel p-8">
|
||||
<AdminEmpty
|
||||
icon={<Megaphone size={22} />}
|
||||
title="选择或新建公告"
|
||||
description="在左侧点选一条查看预览,或新建公告"
|
||||
/>
|
||||
<div className="flex justify-center -mt-4 pb-2">
|
||||
<button type="button" onClick={startCreate} className="btn btn-primary">
|
||||
<Plus size={15} /> 新建公告
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{list.length === 0 && !loadError ? (
|
||||
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
|
||||
<Link href="/admin/announcements/new" className="btn btn-primary">
|
||||
<Plus size={15} /> 新建第一条公告
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 移动端编辑时:若只在右侧显示表单,列表仍在上方可见,符合双栏降级 */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="删除公告"
|
||||
|
||||
235
frontend/app/admin/announcements/AnnouncementComposeClient.tsx
Normal file
235
frontend/app/admin/announcements/AnnouncementComposeClient.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2, Save } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateAnnouncement,
|
||||
apiAdminUpdateAnnouncement,
|
||||
type Announcement,
|
||||
type AnnouncementColor,
|
||||
type AnnouncementInput,
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||
import { AdminSegmented, AdminSwitch } from "@/components/admin";
|
||||
import { ANNOUNCEMENT_COLORS } from "./announcementColors";
|
||||
|
||||
type PublishKey = "published" | "draft";
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
content: string;
|
||||
tag: string;
|
||||
tag_color: AnnouncementColor;
|
||||
published: boolean;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
function toForm(a?: Announcement | null): FormState {
|
||||
return {
|
||||
title: a?.title ?? "",
|
||||
content: a?.content ?? "",
|
||||
tag: a?.tag ?? "公告",
|
||||
tag_color: a?.tag_color ?? "blue",
|
||||
published: a?.published ?? true,
|
||||
pinned: a?.pinned ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AnnouncementComposeClient({
|
||||
mode,
|
||||
initial,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
initial?: Announcement | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<FormState>(() => toForm(initial));
|
||||
const formRef = useRef(form);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isEdit = mode === "edit" && initial != null;
|
||||
|
||||
const patchForm = (patch: Partial<FormState> | ((prev: FormState) => FormState)) => {
|
||||
setForm((prev) => {
|
||||
const next = typeof patch === "function" ? patch(prev) : { ...prev, ...patch };
|
||||
formRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
const current = formRef.current;
|
||||
const payload: AnnouncementInput = {
|
||||
title: current.title.trim(),
|
||||
content: current.content.trim(),
|
||||
tag: current.tag.trim() || "公告",
|
||||
tag_color: current.tag_color,
|
||||
published: current.published,
|
||||
pinned: current.pinned,
|
||||
};
|
||||
if (!payload.title || !payload.content) {
|
||||
toast("标题和内容不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiAdminUpdateAnnouncement(initial.id, payload);
|
||||
toast("公告已更新", "ok");
|
||||
} else {
|
||||
await apiAdminCreateAnnouncement(payload);
|
||||
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
|
||||
}
|
||||
router.push("/admin/announcements");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
onChange={(e) => patchForm({ title: e.target.value })}
|
||||
placeholder="请输入公告标题"
|
||||
aria-label="公告标题"
|
||||
/>
|
||||
<div className="j13-compose-canvas">
|
||||
<MarkdownEditor
|
||||
value={form.content}
|
||||
onChange={(content) => patchForm({ content })}
|
||||
allowHide
|
||||
allowReplyHide={false}
|
||||
placeholder="请输入公告正文,支持 Markdown…"
|
||||
rows={18}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiAdminListAnnouncements, type Announcement } from "@/lib/api";
|
||||
import AnnouncementComposeClient from "../../AnnouncementComposeClient";
|
||||
|
||||
/** 无详情 API:从管理列表按 id 拉取后进入编辑器 */
|
||||
export default function AnnouncementEditLoader({ id }: { id: number }) {
|
||||
const [item, setItem] = useState<Announcement | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
apiAdminListAnnouncements()
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
const found = res.announcements.find((a) => a.id === id) ?? null;
|
||||
if (!found) {
|
||||
setError("公告不存在或已删除");
|
||||
setItem(null);
|
||||
} else {
|
||||
setItem(found);
|
||||
setError("");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : "加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="panel p-10 flex items-center justify-center gap-2 meta">
|
||||
<Loader2 size={18} className="animate-spin" /> 加载公告…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !item) {
|
||||
return <p className="alert-error">{error || "公告不存在"}</p>;
|
||||
}
|
||||
|
||||
return <AnnouncementComposeClient mode="edit" initial={item} />;
|
||||
}
|
||||
31
frontend/app/admin/announcements/[id]/edit/page.tsx
Normal file
31
frontend/app/admin/announcements/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../../../Forbidden";
|
||||
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑公告",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function AdminAnnouncementEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
const numId = parseInt(id, 10);
|
||||
if (!Number.isFinite(numId) || numId <= 0) {
|
||||
return <Forbidden text="无效的公告 ID。" />;
|
||||
}
|
||||
|
||||
return <AnnouncementEditLoader id={numId} />;
|
||||
}
|
||||
17
frontend/app/admin/announcements/announcementColors.ts
Normal file
17
frontend/app/admin/announcements/announcementColors.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AnnouncementColor } from "@/lib/api";
|
||||
|
||||
/** 标签色板:与后端 AnnouncementColor 白名单一致(12 色) */
|
||||
export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||
{ value: "blue", label: "蓝色" },
|
||||
{ value: "green", label: "绿色" },
|
||||
{ value: "orange", label: "橙色" },
|
||||
{ value: "red", label: "红色" },
|
||||
{ value: "purple", label: "紫色" },
|
||||
{ value: "gray", label: "灰色" },
|
||||
{ value: "teal", label: "青绿" },
|
||||
{ value: "cyan", label: "青色" },
|
||||
{ value: "pink", label: "粉色" },
|
||||
{ value: "amber", label: "琥珀" },
|
||||
{ value: "indigo", label: "靛蓝" },
|
||||
{ value: "rose", label: "玫红" },
|
||||
];
|
||||
21
frontend/app/admin/announcements/new/page.tsx
Normal file
21
frontend/app/admin/announcements/new/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../../Forbidden";
|
||||
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "新建公告",
|
||||
};
|
||||
|
||||
export default async function AdminAnnouncementNewPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
return <AnnouncementComposeClient mode="create" />;
|
||||
}
|
||||
Reference in New Issue
Block a user