feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
Plus,
Pencil,
@@ -20,7 +20,7 @@ import {
type AnnouncementColor,
type AnnouncementInput,
} from "@/lib/api";
import { formatDate } from "@/lib/format";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
@@ -29,6 +29,7 @@ import {
AdminIconButton,
AdminPageHeader,
AdminSegmented,
AdminStatusChip,
} from "@/components/admin";
const COLORS: { value: AnnouncementColor; label: string }[] = [
@@ -42,6 +43,7 @@ const COLORS: { value: AnnouncementColor; label: string }[] = [
type FormState = AnnouncementInput & { id: number | null };
type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft";
const EMPTY_FORM: FormState = {
id: null,
@@ -52,6 +54,156 @@ const EMPTY_FORM: FormState = {
published: true,
};
/** 粗略去掉 Markdown,取一行摘要 */
function plainSummary(md: string, max = 96): string {
const text = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_`~\-|=]/g, "")
.replace(/\s+/g, " ")
.trim();
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,
@@ -62,10 +214,26 @@ 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 [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
const publishedCount = useMemo(() => list.filter((a) => a.published).length, [list]);
const draftCount = useMemo(() => list.filter((a) => !a.published).length, [list]);
const filtered = useMemo(() => {
if (statusFilter === "published") return list.filter((a) => a.published);
if (statusFilter === "draft") return list.filter((a) => !a.published);
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();
@@ -76,8 +244,13 @@ export default function AnnouncementAdmin({
}
}, []);
const startCreate = () => setForm({ ...EMPTY_FORM });
const startEdit = (a: Announcement) =>
const startCreate = () => {
setForm({ ...EMPTY_FORM });
setSelectedId(null);
};
const startEdit = (a: Announcement) => {
setSelectedId(a.id);
setForm({
id: a.id,
title: a.title,
@@ -86,6 +259,12 @@ export default function AnnouncementAdmin({
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;
@@ -103,13 +282,16 @@ export default function AnnouncementAdmin({
setSaving(true);
try {
if (form.id == null) {
await apiAdminCreateAnnouncement(payload);
toast("公告已发布", "ok");
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);
}
setForm(null);
await load();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
@@ -122,7 +304,11 @@ export default function AnnouncementAdmin({
setDeletingId(a.id);
try {
await apiAdminDeleteAnnouncement(a.id);
setList((l) => l.filter((x) => x.id !== 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);
toast("公告已删除", "ok");
} catch (e) {
@@ -140,187 +326,189 @@ export default function AnnouncementAdmin({
title="站点公告管理"
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
actions={
form?.id == null ? (
form?.id == null && form != null ? undefined : (
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
) : undefined
)
}
/>
<div className="grid grid-cols-1 gap-6 items-start">
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */}
{form && (
<div className="panel p-5">
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
{form.id == null ? "新建公告" : "编辑公告"}
</p>
{/* 计数条 */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div
className="inline-flex items-center gap-4 rounded-lg px-3.5 py-2 text-[13px]"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
<span>
草稿{" "}
<strong className="tabular-nums" style={{ color: "var(--ink)" }}>
{draftCount}
</strong>
</span>
<span aria-hidden style={{ color: "var(--line)" }}>
|
</span>
<span>
已发布{" "}
<strong className="tabular-nums" style={{ color: "var(--ink)" }}>
{publishedCount}
</strong>
</span>
</div>
<AdminSegmented<StatusFilter>
ariaLabel="公告状态筛选"
size="sm"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ key: "all", label: "全部", count: list.length },
{ key: "published", label: "已发布", count: publishedCount },
{ key: "draft", label: "草稿", count: draftCount },
]}
/>
</div>
<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={save} 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={() => setForm(null)}
disabled={saving}
className="btn btn-line"
aria-label="取消编辑"
>
<X size={15} /> 取消
</button>
</div>
</div>
)}
{/* 公告列表 */}
<div className="panel overflow-hidden">
<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.5 flex items-center justify-between"
className="px-4 sm:px-5 py-3 flex items-center justify-between"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
全部公告
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
公告列表
</span>
<span className="meta text-[12px] tabular-nums">共 {list.length} 条</span>
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
</div>
{loadError ? (
<p className="alert-error m-4">{loadError}</p>
) : list.length === 0 ? (
) : filtered.length === 0 ? (
<AdminEmpty
icon={<Megaphone size={22} />}
title="还没有公告"
description="点击「新建公告」发布第一条吧"
title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"}
description={list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"}
/>
) : (
list.map((a) => (
<div
key={a.id}
className="group flex items-center gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
{!a.published && (
<span
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] leading-[1.55]"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
草稿
</span>
)}
<span className="meta text-[11px] tabular-nums">{formatDate(a.created_at)}</span>
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>
) : null}
</div>
</div>
<p
className="mt-1 truncate text-[14px]"
style={{ color: "var(--ink)" }}
data-tip={a.title}
>
{a.title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{a.published && (
<AdminIconButton
href={`/announcement/${a.id}`}
tip="查看"
ariaLabel="查看公告"
>
<ExternalLink size={15} />
</AdminIconButton>
)}
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
<Pencil size={15} />
</AdminIconButton>
<AdminIconButton
tip="删除"
ariaLabel="删除公告"
tone="danger"
disabled={deletingId === a.id}
onClick={() => setDeleteTarget(a)}
>
{deletingId === a.id ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}
</AdminIconButton>
</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>
</div>
{/* 移动端编辑时:若只在右侧显示表单,列表仍在上方可见,符合双栏降级 */}
<ConfirmDialog
open={!!deleteTarget}
title="删除公告"