feat: 站点单页、时间线导入与行级 BBCode 短代码

首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 19:52:06 +08:00
parent 2aacf99a49
commit a83276060f
52 changed files with 5073 additions and 648 deletions

View File

@@ -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="删除公告"

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

View File

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

View 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} />;
}

View 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: "玫红" },
];

View 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" />;
}

View File

@@ -0,0 +1,263 @@
"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 {
apiAdminCreateSitePage,
apiAdminUpdateSitePage,
type SitePage,
type SitePageInput,
} from "@/lib/api";
import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminSegmented, AdminSwitch } from "@/components/admin";
type PublishKey = "published" | "draft";
type FormState = {
slug: string;
title: string;
content: string;
excerpt: string;
published: boolean;
show_in_sidebar: boolean;
sort_order: number;
};
function toForm(p?: SitePage | null): FormState {
return {
slug: p?.slug ?? "",
title: p?.title ?? "",
content: p?.content ?? "",
excerpt: p?.excerpt ?? "",
published: p?.published ?? true,
show_in_sidebar: p?.show_in_sidebar ?? false,
sort_order: p?.sort_order ?? 0,
};
}
/** slug 仅允许小写字母数字与连字符 */
function normalizeSlug(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
export default function PageComposeClient({
mode,
initial,
}: {
mode: "create" | "edit";
initial?: SitePage | 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 slug = normalizeSlug(current.slug);
const payload: SitePageInput = {
slug,
title: current.title.trim(),
content: current.content.trim(),
excerpt: current.excerpt.trim(),
published: current.published,
show_in_sidebar: current.show_in_sidebar,
sort_order: current.sort_order,
};
if (!payload.slug || !payload.title || !payload.content) {
toast("slug、标题和内容不能为空");
return;
}
setSaving(true);
try {
if (isEdit) {
await apiAdminUpdateSitePage(initial.id, payload);
toast("页面已更新", "ok");
} else {
await apiAdminCreateSitePage(payload);
toast(payload.published ? "页面已发布" : "草稿已保存", "ok");
}
router.push("/admin/pages");
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/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>
<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>
<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="显示在首页侧栏"
/>
</div>
</div>
</section>
<section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文">
<h2 className="j13-compose-section-title">标题与正文</h2>
<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={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/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>
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
Plus,
Pencil,
Trash2,
Loader2,
ExternalLink,
FileText,
} from "lucide-react";
import {
apiAdminDeleteSitePage,
type SitePage,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
AdminStatusChip,
} from "@/components/admin";
type StatusFilter = "all" | "published" | "draft";
export default function PagesAdmin({
initial,
initError,
}: {
initial: SitePage[];
initError: string;
}) {
const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SitePage | null>(null);
const publishedCount = useMemo(() => list.filter((p) => p.published).length, [list]);
const draftCount = useMemo(() => list.filter((p) => !p.published).length, [list]);
const filtered = useMemo(() => {
if (statusFilter === "published") return list.filter((p) => p.published);
if (statusFilter === "draft") return list.filter((p) => !p.published);
return list;
}, [list, statusFilter]);
async function remove(p: SitePage) {
setDeletingId(p.id);
try {
await apiAdminDeleteSitePage(p.id);
setList((l) => l.filter((x) => x.id !== p.id));
toast("页面已删除", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败");
} finally {
setDeletingId(null);
setDeleteTarget(null);
}
}
return (
<div>
<AdminPageHeader
icon={FileText}
title="单页管理"
description="关于本站、隐私政策等自定义 Markdown 单页;可展示在首页右栏"
actions={
<Link href="/admin/pages/new" className="btn btn-primary">
<Plus size={15} /> 新建单页
</Link>
}
/>
<div className="mb-4">
<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>
<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={<FileText size={22} />}
title={list.length === 0 ? "还没有单页" : "该状态下暂无单页"}
description={list.length === 0 ? "创建关于本站等内容页" : "试试切换筛选条件"}
/>
) : (
filtered.map((p, idx) => (
<div
key={p.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">
<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={p.title}
>
{p.title}
</p>
<code className="meta text-[11.5px] tabular-nums">/{p.slug}</code>
<AdminStatusChip tone={p.published ? "ok" : "warn"}>
{p.published ? "已发布" : "草稿"}
</AdminStatusChip>
{p.show_in_sidebar ? (
<AdminStatusChip tone="accent">侧栏</AdminStatusChip>
) : null}
<span className="meta text-[11px] tabular-nums shrink-0">
排序 {p.sort_order} · {formatRelative(p.updated_at || p.created_at)}
</span>
</div>
{p.excerpt ? (
<p className="meta text-[12.5px] mt-1 line-clamp-1">{p.excerpt}</p>
) : null}
</div>
<span className="inline-flex items-center gap-0.5 shrink-0">
{p.published ? (
<AdminIconButton
href={`/p/${p.slug}`}
tip="查看前台"
ariaLabel="查看单页"
>
<ExternalLink size={14} />
</AdminIconButton>
) : null}
<AdminIconButton
href={`/admin/pages/${p.id}/edit`}
tip="编辑"
ariaLabel="编辑单页"
>
<Pencil size={14} />
</AdminIconButton>
<AdminIconButton
tip="删除"
ariaLabel="删除单页"
tone="danger"
disabled={deletingId === p.id}
onClick={() => setDeleteTarget(p)}
>
{deletingId === p.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</AdminIconButton>
</span>
</div>
</div>
))
)}
{list.length === 0 && !loadError ? (
<div className="px-4 sm:px-5 pb-5 -mt-2 flex justify-center">
<Link href="/admin/pages/new" className="btn btn-primary">
<Plus size={15} /> 新建第一页
</Link>
</div>
) : null}
</div>
<ConfirmDialog
open={!!deleteTarget}
title="删除单页"
message={deleteTarget ? `确定删除「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
danger
busy={deletingId != null}
confirmLabel="删除"
onCancel={() => deletingId == null && setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) void remove(deleteTarget);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { apiAdminListSitePages, type SitePage } from "@/lib/api";
import PageComposeClient from "../../PageComposeClient";
/** 无详情 API:从管理列表按 id 拉取后进入编辑器 */
export default function PageEditLoader({ id }: { id: number }) {
const [item, setItem] = useState<SitePage | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
setLoading(true);
apiAdminListSitePages()
.then((res) => {
if (!alive) return;
const found = res.pages.find((p) => p.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 <PageComposeClient mode="edit" initial={item} />;
}

View 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 PageEditLoader from "./PageEditLoader";
export const metadata: Metadata = {
title: "编辑单页",
};
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function AdminPageEditPage({ 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 <PageEditLoader id={numId} />;
}

View 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 PageComposeClient from "../PageComposeClient";
export const metadata: Metadata = {
title: "新建单页",
};
export default async function AdminPageNewPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}
return <PageComposeClient mode="create" />;
}

View File

@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import PagesAdmin from "./PagesAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
title: "单页管理",
};
export default async function AdminPagesPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}
let initial: SitePage[] = [];
let initError = "";
try {
const res = await fetchAdminSitePages(cookie || undefined);
initial = res.pages ?? [];
} catch (e) {
initError = e instanceof Error ? e.message : "加载单页失败";
}
return <PagesAdmin initial={initial} initError={initError} />;
}

View File

@@ -17,6 +17,9 @@ import {
FileText,
Paperclip,
Image as ImageIcon,
GitBranch,
RotateCcw,
CheckCircle2,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
@@ -27,17 +30,61 @@ import {
AdminStepper,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
function extsEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
}
/** 与后端 DefaultTimelineGitAdapterJSON 对齐的内置默认适配器 */
const DEFAULT_TIMELINE_GIT_IMPORT = `{
"max_pages": 10,
"max_commits": 100,
"sources": [
{
"id": "github",
"host": "github.com",
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/(?P<ref>[^/]+)/?$",
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
"api_url": "https://api.github.com/repos/{owner}/{repo}/commits",
"commit_api": "https://api.github.com/repos/{owner}/{repo}/commits/{sha}",
"query": { "sha": "{ref}", "per_page": "100", "page": "{page}" },
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/vnd.github+json" },
"pagination": "link_header",
"item": {
"sha": "sha",
"date": "commit.committer.date",
"message": "commit.message",
"source_url": "html_url"
}
},
{
"id": "gitea",
"host": "*",
"list_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commits/branch/(?P<ref>.+)/?$",
"commit_path": "^/(?P<owner>[^/]+)/(?P<repo>[^/]+)/commit/(?P<sha>[0-9a-fA-F]{7,64})/?$",
"api_url": "https://{host}/api/v1/repos/{owner}/{repo}/commits",
"commit_api": "https://{host}/api/v1/repos/{owner}/{repo}/git/commits/{sha}",
"query": { "sha": "{ref}", "limit": "50", "page": "{page}" },
"headers": { "User-Agent": "jiang13-bbs", "Accept": "application/json" },
"pagination": "query_page",
"item": {
"sha": "sha",
"date": "created",
"message": "commit.message",
"source_url": "html_url"
}
}
]
}`;
type SectionId =
| "basic"
| "content"
| "gitimport"
| "brand"
| "footer"
| "security"
@@ -49,6 +96,7 @@ type SectionId =
const NAV: { id: SectionId; label: string; ready?: boolean }[] = [
{ id: "basic", label: "基本信息", ready: true },
{ id: "content", label: "内容与互动", ready: true },
{ id: "gitimport", label: "提交导入", ready: true },
{ id: "brand", label: "品牌与 SEO" },
{ id: "footer", label: "页脚与合规" },
{ id: "security", label: "安全与限流" },
@@ -138,8 +186,46 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
const [savingLimits, setSavingLimits] = useState(false);
const [savingExts, setSavingExts] = useState(false);
const [gitImportJSON, setGitImportJSON] = useState("");
const [savedGitImport, setSavedGitImport] = useState("");
const [gitImportLoading, setGitImportLoading] = useState(false);
const [gitImportLoaded, setGitImportLoaded] = useState(false);
const [savingGitImport, setSavingGitImport] = useState(false);
const [restoreGitOpen, setRestoreGitOpen] = useState(false);
const [gitValidateMsg, setGitValidateMsg] = useState<{ ok: boolean; text: string } | null>(null);
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
const extsDirty = useMemo(() => !extsEqual(exts, savedExts), [exts, savedExts]);
const gitImportDirty = gitImportJSON !== savedGitImport;
const loadGitImport = async () => {
if (gitImportLoading) return;
setGitImportLoading(true);
try {
const res = await apiGetAdminSettings();
const raw = res.timeline_git_import?.trim() || DEFAULT_TIMELINE_GIT_IMPORT;
let pretty = raw;
try {
pretty = JSON.stringify(JSON.parse(raw), null, 2);
} catch {
/* 保持原文 */
}
setGitImportJSON(pretty);
setSavedGitImport(pretty);
setGitImportLoaded(true);
setGitValidateMsg(null);
} catch (e) {
toast(e instanceof Error ? e.message : "加载适配器失败");
} finally {
setGitImportLoading(false);
}
};
useEffect(() => {
void loadGitImport();
// 仅挂载时拉取一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const ids = NAV.map((n) => n.id);
@@ -165,6 +251,73 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
const scrollTo = (id: SectionId) => {
setActive(id);
document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) {
void loadGitImport();
}
};
const validateGitImportClient = (): boolean => {
try {
JSON.parse(gitImportJSON);
setGitValidateMsg({ ok: true, text: "JSON 语法有效(服务端保存时还会做结构校验)" });
return true;
} catch (e) {
setGitValidateMsg({
ok: false,
text: e instanceof Error ? `JSON 无效:${e.message}` : "JSON 无效",
});
return false;
}
};
const saveGitImport = async () => {
if (savingGitImport) return;
if (!validateGitImportClient()) {
toast("请先修正 JSON 语法");
return;
}
setSavingGitImport(true);
try {
await apiUpdateSiteSettings({ timeline_git_import: gitImportJSON });
// 保存后以服务端规范化结果为准
const res = await apiGetAdminSettings();
const raw = res.timeline_git_import?.trim() || gitImportJSON;
let pretty = raw;
try {
pretty = JSON.stringify(JSON.parse(raw), null, 2);
} catch {
/* 保持 */
}
setGitImportJSON(pretty);
setSavedGitImport(pretty);
toast("提交导入适配器已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSavingGitImport(false);
}
};
const restoreGitDefault = async () => {
setSavingGitImport(true);
try {
let pretty = DEFAULT_TIMELINE_GIT_IMPORT;
try {
pretty = JSON.stringify(JSON.parse(DEFAULT_TIMELINE_GIT_IMPORT), null, 2);
} catch {
/* 保持 */
}
await apiUpdateSiteSettings({ timeline_git_import: pretty });
setGitImportJSON(pretty);
setSavedGitImport(pretty);
setGitValidateMsg(null);
setRestoreGitOpen(false);
toast("已恢复默认适配器", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "恢复失败");
} finally {
setSavingGitImport(false);
}
};
const saveIdentity = async () => {
@@ -602,6 +755,92 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
</AdminSection>
</div>
<div id="settings-gitimport" className="scroll-mt-4">
<AdminSection
title="提交导入"
description="声明式匹配规则,非可执行脚本。用于将 GitHub / Gitea 等提交历史导入时间线。"
>
<p className="meta text-[12.5px] mb-3 leading-relaxed">
通过 JSON 描述 host、路径正则与 API 字段映射;服务端只按规则拉取,不会执行任意代码。
</p>
{gitImportLoading && !gitImportLoaded ? (
<div className="flex items-center gap-2 meta py-8 justify-center">
<Loader2 size={16} className="animate-spin" /> 加载适配器…
</div>
) : (
<>
<label className="field-label" htmlFor="git-import-json">
适配器 JSON
</label>
<textarea
id="git-import-json"
className="j13-compose-field w-full font-mono text-[12.5px] leading-relaxed"
style={{ minHeight: 320 }}
spellCheck={false}
value={gitImportJSON}
onChange={(e) => {
setGitImportJSON(e.target.value);
setGitValidateMsg(null);
}}
placeholder="粘贴或编辑适配器 JSON…"
/>
{gitValidateMsg ? (
<p
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
gitValidateMsg.ok ? "" : "alert-error !mt-2"
}`}
style={gitValidateMsg.ok ? { color: "var(--ok, #137333)" } : undefined}
role="status"
>
{gitValidateMsg.ok ? <CheckCircle2 size={14} className="mt-0.5 shrink-0" /> : null}
{gitValidateMsg.text}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
type="button"
className="btn btn-line btn-sm"
disabled={gitImportLoading || savingGitImport}
onClick={() => {
if (validateGitImportClient()) toast("语法校验通过", "ok");
else toast("JSON 语法无效");
}}
>
<CheckCircle2 size={14} /> 校验
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!gitImportDirty || savingGitImport}
onClick={() => void saveGitImport()}
>
{savingGitImport ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
保存
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingGitImport}
onClick={() => setRestoreGitOpen(true)}
>
<RotateCcw size={14} /> 恢复默认
</button>
<span className="meta text-[11.5px] inline-flex items-center gap-1 ml-auto">
<GitBranch size={12} /> GitHub + Gitea 模板
</span>
</div>
</>
)}
</AdminSection>
</div>
<div id="settings-brand" className="scroll-mt-4">
<ComingSoonCard
title="品牌与 SEO"
@@ -645,6 +884,16 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
</div>
</div>
</div>
<ConfirmDialog
open={restoreGitOpen}
title="恢复默认适配器"
message="将覆盖当前「提交导入」JSON 为内置 GitHub + Gitea 模板,并立即保存。确定继续?"
busy={savingGitImport}
confirmLabel="恢复并保存"
onCancel={() => !savingGitImport && setRestoreGitOpen(false)}
onConfirm={() => void restoreGitDefault()}
/>
</div>
);
}