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

View File

@@ -1,9 +1,12 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { ChevronRight, Megaphone } from "lucide-react";
import { fetchAnnouncementDetail } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { formatDate } from "@/lib/format";
import { getMeCached } from "@/lib/serverData";
import MarkdownBody from "@/components/MarkdownBody";
interface PageProps {
@@ -28,10 +31,15 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
export default async function AnnouncementPage({ params }: PageProps) {
const { id } = await params;
const data = await fetchAnnouncementDetail(id).catch(() => null);
const cookie = authCookieHeader(await cookies());
const [data, me] = await Promise.all([
fetchAnnouncementDetail(id, cookie || undefined).catch(() => null),
getMeCached(cookie || undefined),
]);
if (!data) notFound();
const a = data.announcement;
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
@@ -55,12 +63,21 @@ export default async function AnnouncementPage({ params }: PageProps) {
首页
</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>站点公告</span>
<Link href="/announcements" className="hover:text-[var(--accent)] transition-colors">
站点公告
</Link>
<ChevronRight size={13} />
<span className="truncate" style={{ color: "var(--accent)" }}>
{a.title}
</span>
</nav>
<article className="panel p-6 sm:p-8">
<div className="flex items-center justify-between gap-3 flex-wrap">
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<div className="flex items-center gap-2 flex-wrap">
{a.pinned ? <span className="badge-pin">置顶</span> : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
</div>
<time className="meta text-[12px] tabular-nums" dateTime={a.created_at}>
{formatDate(a.created_at)}
</time>
@@ -77,7 +94,11 @@ export default async function AnnouncementPage({ params }: PageProps) {
className="mt-5 pt-5"
style={{ borderTop: "1px solid var(--line)" }}
>
<MarkdownBody content={a.content} />
<MarkdownBody
content={a.content}
loggedIn={!!me.user}
loginHref={loginHref}
/>
</div>
</article>

View File

@@ -0,0 +1,109 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Megaphone } from "lucide-react";
import { fetchAnnouncementsPage, type Announcement } from "@/lib/api";
import { formatDate } from "@/lib/format";
import Pagination from "@/components/Pagination";
export const metadata: Metadata = {
title: "站点公告",
description: "站点公告与通知列表",
};
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
/** 粗略去掉 Markdown,取摘要 */
function plainSummary(md: string, max = 120): 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 AnnouncementCard({ a }: { a: Announcement }) {
const summary = plainSummary(a.content);
return (
<Link
href={`/announcement/${a.id}`}
className="panel block p-5 sm:p-6 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<div className="flex items-center gap-2 flex-wrap">
{a.pinned ? <span className="badge-pin">置顶</span> : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
{formatDate(a.created_at)}
</time>
</div>
<h2
className="mt-2.5 text-[17px] sm:text-[18px] font-semibold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{a.title}
</h2>
{summary ? (
<p className="mt-2 text-[13.5px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{summary}
</p>
) : null}
</Link>
);
}
export default async function AnnouncementsListPage({ searchParams }: PageProps) {
const sp = await searchParams;
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const size = 20;
let announcements: Announcement[] = [];
let total = 0;
let error = "";
try {
const res = await fetchAnnouncementsPage(page, size);
announcements = res.announcements ?? [];
total = res.total ?? 0;
} catch (e) {
error = e instanceof Error ? e.message : "加载公告失败";
}
return (
<div className="max-w-3xl mx-auto">
<header className="mb-6">
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
站点公告
</h1>
<p className="meta mt-2">站点通知与运营公告</p>
</header>
{error ? (
<p className="alert-error">{error}</p>
) : announcements.length === 0 ? (
<div className="panel p-10 text-center">
<p className="font-medium" style={{ color: "var(--ink)" }}>
暂无公告
</p>
<p className="meta mt-1.5">有新消息时会显示在这里</p>
</div>
) : (
<div className="flex flex-col gap-3">
{announcements.map((a) => (
<AnnouncementCard key={a.id} a={a} />
))}
</div>
)}
{!error ? (
<Pagination total={total} page={page} size={size} basePath="/announcements" />
) : null}
</div>
);
}

View File

@@ -1629,6 +1629,23 @@ body {
background: var(--panel-2) !important;
color: var(--ink) !important;
}
.j13-md-menu-item:disabled,
.j13-md-menu-item[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
color: var(--ink-3) !important;
}
.j13-md-menu-item:disabled:hover,
.j13-md-menu-item[aria-disabled="true"]:hover {
background: transparent !important;
color: var(--ink-3) !important;
}
.j13-md-menu-item-hint {
margin-left: auto;
font-size: 11.5px;
font-weight: 500;
color: var(--ink-3);
}
.j13-md-popover button[role="menuitem"]:focus-visible,
.j13-md-menu-item:focus-visible {
outline: 2px solid var(--accent);
@@ -3773,12 +3790,24 @@ a.j13-mention-token:hover {
.j13-anno-tag.is-red { background: rgba(200, 40, 30, 0.1); color: #c8281e; }
.j13-anno-tag.is-purple { background: rgba(109, 71, 192, 0.12); color: #6d47c0; }
.j13-anno-tag.is-gray { background: rgba(82, 96, 122, 0.13); color: #52607a; }
.j13-anno-tag.is-teal { background: rgba(15, 118, 110, 0.12); color: #0f766e; }
.j13-anno-tag.is-cyan { background: rgba(14, 116, 144, 0.12); color: #0e7490; }
.j13-anno-tag.is-pink { background: rgba(190, 24, 93, 0.11); color: #be185d; }
.j13-anno-tag.is-amber { background: rgba(180, 83, 9, 0.12); color: #b45309; }
.j13-anno-tag.is-indigo { background: rgba(67, 56, 202, 0.12); color: #4338ca; }
.j13-anno-tag.is-rose { background: rgba(190, 18, 60, 0.11); color: #be123c; }
.dark .j13-anno-tag.is-blue { background: rgba(169, 184, 255, 0.18); color: #a9b8ff; }
.dark .j13-anno-tag.is-green { background: rgba(88, 209, 138, 0.16); color: #58d18a; }
.dark .j13-anno-tag.is-orange { background: rgba(240, 135, 90, 0.17); color: #f0875a; }
.dark .j13-anno-tag.is-red { background: rgba(255, 107, 96, 0.16); color: #ff6b60; }
.dark .j13-anno-tag.is-purple { background: rgba(192, 168, 245, 0.18); color: #c0a8f5; }
.dark .j13-anno-tag.is-gray { background: rgba(170, 182, 207, 0.17); color: #aab6cf; }
.dark .j13-anno-tag.is-teal { background: rgba(94, 234, 212, 0.16); color: #5eead4; }
.dark .j13-anno-tag.is-cyan { background: rgba(103, 232, 249, 0.16); color: #67e8f9; }
.dark .j13-anno-tag.is-pink { background: rgba(251, 113, 133, 0.16); color: #fb7185; }
.dark .j13-anno-tag.is-amber { background: rgba(252, 211, 77, 0.16); color: #fcd34d; }
.dark .j13-anno-tag.is-indigo { background: rgba(165, 180, 252, 0.18); color: #a5b4fc; }
.dark .j13-anno-tag.is-rose { background: rgba(251, 113, 133, 0.16); color: #fb7185; }
/* 后台颜色选择器圆点 */
.j13-anno-dot {
@@ -3801,12 +3830,134 @@ a.j13-mention-token:hover {
.j13-anno-dot.is-red { background: #c8281e; }
.j13-anno-dot.is-purple { background: #6d47c0; }
.j13-anno-dot.is-gray { background: #52607a; }
.j13-anno-dot.is-teal { background: #0f766e; }
.j13-anno-dot.is-cyan { background: #0e7490; }
.j13-anno-dot.is-pink { background: #be185d; }
.j13-anno-dot.is-amber { background: #b45309; }
.j13-anno-dot.is-indigo { background: #4338ca; }
.j13-anno-dot.is-rose { background: #be123c; }
.dark .j13-anno-dot.is-blue { background: #a9b8ff; }
.dark .j13-anno-dot.is-green { background: #58d18a; }
.dark .j13-anno-dot.is-orange { background: #f0875a; }
.dark .j13-anno-dot.is-red { background: #ff6b60; }
.dark .j13-anno-dot.is-purple { background: #c0a8f5; }
.dark .j13-anno-dot.is-gray { background: #aab6cf; }
.dark .j13-anno-dot.is-teal { background: #5eead4; }
.dark .j13-anno-dot.is-cyan { background: #67e8f9; }
.dark .j13-anno-dot.is-pink { background: #fb7185; }
.dark .j13-anno-dot.is-amber { background: #fcd34d; }
.dark .j13-anno-dot.is-indigo { background: #a5b4fc; }
.dark .j13-anno-dot.is-rose { background: #fb7185; }
/* ----- 正文时间线(更新树) ----- */
.j13-timeline {
margin: 1.1rem 0;
padding: 0.15rem 0;
}
.j13-timeline-day {
margin-bottom: 1.15rem;
}
.j13-timeline-day:last-child {
margin-bottom: 0;
}
.j13-timeline-day-label {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink-3);
margin-bottom: 0.55rem;
font-variant-numeric: tabular-nums;
}
.j13-timeline-items {
list-style: none;
margin: 0;
padding: 0 0 0 0.85rem;
border-left: 2px solid var(--line);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.j13-timeline-item {
position: relative;
padding-left: 0.95rem;
}
.j13-timeline-dot {
position: absolute;
left: -0.85rem;
top: 0.45rem;
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: var(--accent);
border: 2px solid var(--panel);
box-shadow: 0 0 0 1px var(--line);
transform: translateX(-50%);
}
.j13-timeline-card {
min-width: 0;
}
.j13-timeline-head {
display: flex;
align-items: flex-start;
gap: 0.45rem;
}
.j13-timeline-title {
margin: 0;
font-size: 14.5px;
font-weight: 650;
line-height: 1.45;
color: var(--ink);
}
.j13-timeline-src {
flex-shrink: 0;
margin-top: 0.15rem;
color: var(--ink-3);
}
.j13-timeline-src:hover {
color: var(--accent);
}
.j13-timeline-body {
margin-top: 0.35rem;
font-size: 13.5px;
line-height: 1.65;
color: var(--ink-2);
}
.j13-timeline-body > :first-child { margin-top: 0; }
.j13-timeline-body > :last-child { margin-bottom: 0; }
/* 时间线编辑器 */
.j13-tl-import {
padding: 0.85rem 0.95rem;
border-radius: 10px;
background: var(--tint);
border: 1px solid var(--line);
}
.j13-tl-urls,
.j13-tl-field,
.j13-tl-select {
width: 100%;
border: 1px solid var(--line-2);
border-radius: 8px;
background: var(--panel);
color: var(--ink);
font-size: 13px;
padding: 0.45rem 0.6rem;
}
.j13-tl-urls {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
resize: vertical;
min-height: 4.5rem;
}
.j13-tl-select {
width: auto;
padding: 0.25rem 0.45rem;
}
.j13-tl-edit-card {
padding: 0.75rem;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--panel);
}
/* ----- 分页 ----- */
.pager-btn {

View File

@@ -0,0 +1,84 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ChevronRight } from "lucide-react";
import { fetchSitePageBySlug } from "@/lib/api";
import { formatDate } from "@/lib/format";
import MarkdownBody from "@/components/MarkdownBody";
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const data = await fetchSitePageBySlug(slug).catch(() => null);
if (!data) return { title: "页面不存在" };
const p = data.page;
return {
title: p.title,
description: p.excerpt || p.title,
openGraph: {
title: p.title,
description: p.excerpt || p.title,
type: "article",
publishedTime: p.created_at,
},
};
}
export default async function SitePagePublic({ params }: PageProps) {
const { slug } = await params;
const data = await fetchSitePageBySlug(slug).catch(() => null);
if (!data) notFound();
const p = data.page;
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: p.title,
description: p.excerpt || undefined,
datePublished: p.created_at,
dateModified: p.updated_at,
};
return (
<div className="max-w-3xl mx-auto">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
首页
</Link>
<ChevronRight size={13} />
<span className="truncate" style={{ color: "var(--accent)" }}>
{p.title}
</span>
</nav>
<article className="panel p-6 sm:p-8">
<time className="meta text-[12px] tabular-nums" dateTime={p.updated_at || p.created_at}>
{formatDate(p.updated_at || p.created_at)}
</time>
<h1
className="mt-2 text-[24px] sm:text-[28px] font-extrabold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{p.title}
</h1>
{p.excerpt ? (
<p className="mt-3 text-[14.5px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
{p.excerpt}
</p>
) : null}
<div className="mt-5 pt-5" style={{ borderTop: "1px solid var(--line)" }}>
<MarkdownBody content={p.content} />
</div>
</article>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import FeedTabs, { type FeedSort } from "@/components/home/FeedTabs";
@@ -110,6 +111,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
]);
const user = me.user;
const boards = overview?.boards ?? [];
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
const sidebarPages = overview?.sidebar_pages ?? [];
const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
const showBoardPanels = boardId > 0 && !isSearch;
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
@@ -235,6 +238,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
allowComments={allowComments}
canManageAnnouncements={canManageAnnouncements}
sidebarPages={sidebarPages}
/>
)
)}
@@ -266,6 +271,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
allowComments={allowComments}
canManageAnnouncements={canManageAnnouncements}
sidebarPages={sidebarPages}
/>
</div>
)

View File

@@ -1,10 +1,16 @@
import type { MetadataRoute } from "next";
import { fetchPosts } from "@/lib/api";
import { fetchPosts, fetchAnnouncementsPage, fetchOverview } from "@/lib/api";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "http://localhost:3000";
const entries: MetadataRoute.Sitemap = [
{ url: `${baseUrl}/`, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
{
url: `${baseUrl}/announcements`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.7,
},
];
try {
@@ -21,5 +27,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
/* 忽略 API 错误 */
}
try {
const { announcements } = await fetchAnnouncementsPage(1, 100);
for (const a of announcements) {
entries.push({
url: `${baseUrl}/announcement/${a.id}`,
lastModified: new Date(a.updated_at || a.created_at),
changeFrequency: "weekly",
priority: 0.5,
});
}
} catch {
/* 忽略 */
}
// 公开单页列表 API 尚未提供:用 overview 侧栏页近似收录已发布且展示中的页面
try {
const overview = await fetchOverview();
for (const p of overview.sidebar_pages ?? []) {
entries.push({
url: `${baseUrl}/p/${p.slug}`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.4,
});
}
} catch {
/* 忽略 */
}
return entries;
}

View File

@@ -1,7 +1,10 @@
import type { ReactNode } from "react";
import type { Components } from "react-markdown";
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
import { splitHideSegments, type HideKind } from "@/lib/hideBlocks";
import { splitContentSegments } from "@/lib/directiveBlocks";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
type MarkdownBodyProps = {
content: string;
@@ -9,20 +12,23 @@ type MarkdownBodyProps = {
components?: Components;
/** 详情页解锁门禁需要 */
postId?: number;
/** 登录跳转(公告等无 postId 时) */
loginHref?: string;
loggedIn?: boolean;
};
/**
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 :::hide 内嵌块。
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 [hide] / [timeline]。
*/
export default async function MarkdownBody({
content,
className = "prose-content",
components,
postId,
loginHref,
loggedIn = false,
}: MarkdownBodyProps) {
const segs = splitHideSegments(content || "");
const segs = splitContentSegments(content || "");
const nodes = [];
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
@@ -33,6 +39,25 @@ export default async function MarkdownBody({
);
continue;
}
if (seg.type === "timeline") {
const bodies: ReactNode[] = [];
for (let j = 0; j < seg.items.length; j++) {
const it = seg.items[j];
if (!it.body.trim()) {
bodies.push(null);
continue;
}
bodies.push(await renderMarkdownToReact(it.body, components));
}
nodes.push(
<MdTimeline
key={`tl-${i}`}
items={seg.items}
renderBody={(_it, idx) => bodies[idx]}
/>
);
continue;
}
const kind = seg.kind as HideKind;
if (seg.locked) {
nodes.push(
@@ -42,6 +67,7 @@ export default async function MarkdownBody({
points={seg.points}
locked
postId={postId}
loginHref={loginHref}
loggedIn={loggedIn}
/>
);

View File

@@ -3,28 +3,32 @@
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
import { splitHideSegments, type HideKind } from "@/lib/hideBlocks";
import { splitContentSegments } from "@/lib/directiveBlocks";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
type MarkdownBodyClientProps = {
content: string;
className?: string;
components?: Components;
postId?: number;
loginHref?: string;
loggedIn?: boolean;
};
/**
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块徽章预览。
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块 / 时间线预览。
*/
export default function MarkdownBodyClient({
content,
className = "prose-content",
components,
postId,
loginHref,
loggedIn = false,
}: MarkdownBodyClientProps) {
const segs = splitHideSegments(content || "");
const segs = splitContentSegments(content || "");
const mdComponents = mergeMarkdownComponents(components);
return (
@@ -38,6 +42,21 @@ export default function MarkdownBodyClient({
</ReactMarkdown>
);
}
if (seg.type === "timeline") {
return (
<MdTimeline
key={`tl-${i}`}
items={seg.items}
renderBody={(it) =>
it.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{it.body}
</ReactMarkdown>
) : null
}
/>
);
}
const kind = seg.kind as HideKind;
if (seg.locked) {
return (
@@ -47,6 +66,7 @@ export default function MarkdownBodyClient({
points={seg.points}
locked
postId={postId}
loginHref={loginHref}
loggedIn={loggedIn}
/>
);

View File

@@ -33,10 +33,13 @@ import {
ChevronDown,
Upload,
Link2,
History,
} from "lucide-react";
import { apiUploadPostImage } from "@/lib/api";
import { toast } from "@/lib/toast";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import TimelineEditor from "@/components/TimelineEditor";
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
type MarkdownEditorProps = {
@@ -49,6 +52,13 @@ type MarkdownEditorProps = {
id?: string;
/** 正文插图体积上限(MB),缺省 5 */
imageMaxMB?: number;
/** 是否提供隐藏可见([hide]) */
allowHide?: boolean;
/**
* 是否允许「回复可见」。
* 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。
*/
allowReplyHide?: boolean;
};
type EditorMode = "split" | "write" | "preview";
@@ -110,6 +120,8 @@ export default function MarkdownEditor({
onSubmitShortcut,
id,
imageMaxMB = 5,
allowHide = false,
allowReplyHide = true,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
@@ -128,6 +140,11 @@ export default function MarkdownEditor({
const [hidePoints, setHidePoints] = useState(10);
const [hidePassword, setHidePassword] = useState("");
const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu");
const [timelineOpen, setTimelineOpen] = useState(false);
const [timelineBody, setTimelineBody] = useState("");
const [timelineRange, setTimelineRange] = useState<{ start: number; end: number } | null>(
null
);
const savedSel = useRef<{ start: number; end: number } | null>(null);
useEffect(() => {
@@ -336,19 +353,20 @@ export default function MarkdownEditor({
points?: number,
password?: string
) => {
if (!allowHide) return;
restoreSel();
const el = taRef.current;
const start = el?.selectionStart ?? 0;
const end = el?.selectionEnd ?? 0;
const selected = value.slice(start, end);
const body = selected || "在此填写隐藏内容";
let open = `:::hide ${kind}`;
let open = `[hide ${kind}]`;
if (kind === "points") {
open = `:::hide points ${points || 10}`;
open = `[hide points=${points || 10}]`;
} else if (kind === "password") {
open = `:::hide password ${password || "password"}`;
open = `[hide password=${password || "password"}]`;
}
const block = `${open}\n${body}\n:::`;
const block = `${open}\n${body}\n[/hide]`;
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
const inserted = padBefore + block + padAfter;
@@ -363,6 +381,51 @@ export default function MarkdownEditor({
setPopover(null);
};
const openTimelineEditor = () => {
rememberSel();
const el = taRef.current;
const cursor = el?.selectionStart ?? savedSel.current?.start ?? 0;
const found = resolveTimelineBlockForEdit(value, cursor);
if (found) {
setTimelineBody(found.body);
setTimelineRange({ start: found.start, end: found.end });
} else {
setTimelineBody("");
setTimelineRange(null);
}
setPopover(null);
setTimelineOpen(true);
};
const applyTimelineBlock = (block: string) => {
const el = taRef.current;
if (timelineRange) {
const next = value.slice(0, timelineRange.start) + block + value.slice(timelineRange.end);
onChange(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const pos = timelineRange.start + block.length;
el.setSelectionRange(pos, pos);
});
return;
}
restoreSel();
const start = el?.selectionStart ?? 0;
const end = el?.selectionEnd ?? start;
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
const inserted = padBefore + block + padAfter;
const next = value.slice(0, start) + inserted + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const pos = start + inserted.length;
el.setSelectionRange(pos, pos);
});
};
const insertOrderedList = () => {
const el = taRef.current;
if (!el || disabled) return;
@@ -717,134 +780,154 @@ export default function MarkdownEditor({
)}
</div>
<Sep />
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="隐藏内容"
icon={<Lock size={15} />}
active={popover === "hide"}
onClick={() => openPopover("hide")}
/>
{popover === "hide" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入隐藏内容">
{hideStep === "menu" ? (
<>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
登录可见
</button>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("reply")}>
回复可见
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("points")}
>
积分可见…
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("password")}
>
密码可见…
</button>
</>
) : hideStep === "points" ? (
<>
<label className="j13-md-field">
<span>所需积分</span>
<input
autoFocus
type="number"
min={1}
max={100000}
value={hidePoints}
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (hidePoints >= 1 && hidePoints <= 100000) {
insertHide("points", hidePoints);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
<ToolBtn
label="时间线"
icon={<History size={15} />}
onClick={openTimelineEditor}
/>
{allowHide ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="隐藏内容"
icon={<Lock size={15} />}
active={popover === "hide"}
onClick={() => openPopover("hide")}
/>
{popover === "hide" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入隐藏内容">
{hideStep === "menu" ? (
<>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
登录可见
</button>
<button
type="button"
className="btn btn-primary btn-sm"
className="j13-md-menu-item"
disabled={!allowReplyHide}
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
aria-disabled={!allowReplyHide}
onClick={() => {
if (hidePoints < 1 || hidePoints > 100000) {
toast("积分须在 1–100000", "error");
return;
}
insertHide("points", hidePoints);
if (!allowReplyHide) return;
insertHide("reply");
}}
>
插入
回复可见
{!allowReplyHide ? (
<span className="j13-md-menu-item-hint">不可用</span>
) : null}
</button>
</div>
</>
) : (
<>
<label className="j13-md-field">
<span>查看密码(不含空格,1–64 字)</span>
<input
autoFocus
type="text"
value={hidePassword}
maxLength={64}
placeholder="例如:secret"
onChange={(e) => setHidePassword(e.target.value)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("points")}
>
积分可见…
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("password")}
>
密码可见…
</button>
</>
) : hideStep === "points" ? (
<>
<label className="j13-md-field">
<span>所需积分</span>
<input
autoFocus
type="number"
min={1}
max={100000}
value={hidePoints}
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (hidePoints >= 1 && hidePoints <= 100000) {
insertHide("points", hidePoints);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
if (hidePoints < 1 || hidePoints > 100000) {
toast("积分须在 1–100000", "error");
return;
}
insertHide("points", hidePoints);
}}
>
插入
</button>
</div>
</>
) : (
<>
<label className="j13-md-field">
<span>查看密码(不含空格,1–64 字)</span>
<input
autoFocus
type="text"
value={hidePassword}
maxLength={64}
placeholder="例如:secret"
onChange={(e) => setHidePassword(e.target.value)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const p = hidePassword.trim();
if (p && !/\s/.test(p)) {
insertHide("password", undefined, p);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
const p = hidePassword.trim();
if (p && !/\s/.test(p)) {
insertHide("password", undefined, p);
if (!p || /\s/.test(p) || p.length > 64) {
toast("密码须为 1–64 字符且不含空格", "error");
return;
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
const p = hidePassword.trim();
if (!p || /\s/.test(p) || p.length > 64) {
toast("密码须为 1–64 字符且不含空格", "error");
return;
}
insertHide("password", undefined, p);
}}
>
插入
</button>
</div>
</>
)}
</div>
)}
</div>
insertHide("password", undefined, p);
}}
>
插入
</button>
</div>
</>
)}
</div>
)}
</div>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="j13-md-mode" role="tablist" aria-label="编辑模式">
@@ -950,6 +1033,17 @@ export default function MarkdownEditor({
<span>支持粘贴/拖拽图片 · Markdown · Ctrl/⌘ + Enter 提交</span>
<span className="tabular-nums">{value.length} 字</span>
</div>
<TimelineEditor
open={timelineOpen}
initialBody={timelineBody}
onClose={() => {
setTimelineOpen(false);
setTimelineRange(null);
setTimelineBody("");
}}
onInsert={applyTimelineBlock}
/>
</div>
);
}

View File

@@ -1227,6 +1227,7 @@ export default function PostComposer(props: PostComposerProps) {
id="compose-content"
value={content}
onChange={setContent}
allowHide
disabled={loading}
rows={18}
imageMaxMB={attachLimits.imageMaxMB}

View File

@@ -0,0 +1,364 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
ArrowDown,
ArrowUp,
GitBranch,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import Modal from "@/components/Modal";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
isLikelyMergeCommit,
parseTimelineBody,
sanitizeTimelineText,
sanitizeTimelineTitle,
serializeTimelineBlock,
type TimelineItem,
} from "@/lib/timelineBlocks";
import { apiTimelineFromGit, type TimelineGitItem } from "@/lib/api";
import { toast } from "@/lib/toast";
type ImportMode = "replace" | "append";
type TimelineEditorProps = {
open: boolean;
/** 编辑已有块时传入 body;新建为空 */
initialBody?: string;
onInsert: (block: string) => void;
onClose: () => void;
};
function todayISO(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function fromGitItem(it: TimelineGitItem): TimelineItem {
const title = sanitizeTimelineTitle(it.title || "提交");
return {
date: /^\d{4}-\d{2}-\d{2}$/.test(it.date) ? it.date : todayISO(),
title,
body: sanitizeTimelineText(it.body || "", 2000),
sourceUrl: it.source_url,
selected: !isLikelyMergeCommit(title),
};
}
function emptyItem(): TimelineItem {
return {
date: todayISO(),
title: "",
body: "",
selected: true,
};
}
function seedItems(initialBody: string): TimelineItem[] {
const parsed = parseTimelineBody(initialBody);
return parsed.length > 0 ? parsed : [emptyItem()];
}
export default function TimelineEditor({
open,
initialBody = "",
onInsert,
onClose,
}: TimelineEditorProps) {
const [items, setItems] = useState<TimelineItem[]>(() => seedItems(initialBody));
const [urlsText, setUrlsText] = useState("");
const [followPages, setFollowPages] = useState(true);
const [importMode, setImportMode] = useState<ImportMode>("replace");
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [deleteIdx, setDeleteIdx] = useState<number | null>(null);
useEffect(() => {
if (!open) return;
setItems(seedItems(initialBody));
setUrlsText("");
setImportError(null);
setFollowPages(true);
setImportMode(initialBody.trim() ? "append" : "replace");
}, [open, initialBody]);
const updateItem = useCallback((idx: number, patch: Partial<TimelineItem>) => {
setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it)));
}, []);
const moveItem = (idx: number, dir: -1 | 1) => {
setItems((prev) => {
const j = idx + dir;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[idx], next[j]] = [next[j], next[idx]];
return next;
});
};
const sortByDate = () => {
setItems((prev) =>
[...prev].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
);
};
const runImport = async () => {
const urls = urlsText
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean);
if (urls.length === 0) {
setImportError("请粘贴至少一条提交记录页面 URL");
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromGit({ urls, follow_pages: followPages });
const mapped = (res.items || []).map(fromGitItem);
if (mapped.length === 0) {
setImportError(res.error || "未能解析出提交;请检查 URL 或改为手填");
if (res.warning) toast(res.warning);
return;
}
setItems((prev) => {
if (importMode === "append") {
const seen = new Set(
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
);
const extra = mapped.filter(
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
);
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
}
return mapped;
});
if (res.warning) toast(res.warning, "ok");
else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
} catch (e) {
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
} finally {
setImporting(false);
}
};
const handleInsert = () => {
const kept = items.filter((it) => it.selected !== false);
if (kept.length === 0) {
toast("请至少保留一条时间线项目", "error");
return;
}
for (const it of kept) {
if (!it.title.trim()) {
toast("每条时间线需要标题", "error");
return;
}
}
onInsert(serializeTimelineBlock(kept));
onClose();
};
return (
<>
<Modal
open={open}
title="编辑时间线"
onClose={onClose}
zIndexClass="z-[1200]"
maxWidthClass="max-w-2xl"
panelClassName="p-0 overflow-hidden"
footer={
<>
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={handleInsert}>
{initialBody.trim() ? "更新时间线" : "插入时间线"}
</button>
</>
}
>
<div className="max-h-[min(70vh,640px)] overflow-y-auto px-5 py-4 space-y-4">
{/* 导入区 */}
<section className="j13-tl-import" aria-label="从提交记录导入">
<div className="flex items-center gap-2 mb-2">
<GitBranch size={15} style={{ color: "var(--accent)" }} />
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
从提交记录导入
</p>
</div>
<p className="meta text-[12px] mb-2 leading-relaxed">
粘贴 Gitea / GitHub 的 commits 列表页(一行一个)。默认自动拉取后续页;也可只贴某几页。
</p>
<textarea
value={urlsText}
onChange={(e) => setUrlsText(e.target.value)}
rows={3}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
className="j13-tl-urls"
disabled={importing}
/>
<div className="mt-2 flex flex-wrap items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<input
type="checkbox"
checked={followPages}
onChange={(e) => setFollowPages(e.target.checked)}
disabled={importing}
/>
自动拉取后续页
</label>
<div className="flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<span>导入方式</span>
<select
className="j13-tl-select"
value={importMode}
onChange={(e) => setImportMode(e.target.value as ImportMode)}
disabled={importing}
>
<option value="replace">替换当前</option>
<option value="append">追加</option>
</select>
</div>
<button
type="button"
className="btn btn-line btn-sm ml-auto"
disabled={importing}
onClick={() => void runImport()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
"导入"
)}
</button>
</div>
{importError ? (
<p className="mt-2 text-[12.5px] leading-relaxed" style={{ color: "var(--danger)" }}>
{importError}
</p>
) : null}
</section>
{/* 条目列表 */}
<div className="flex items-center justify-between gap-2">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
时间线条目
</p>
<div className="flex items-center gap-1.5">
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
按日期整理
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setItems((prev) => [...prev, emptyItem()])}
>
<Plus size={14} /> 添加
</button>
</div>
</div>
<ul className="space-y-3">
{items.map((it, idx) => (
<li key={idx} className="j13-tl-edit-card">
<div className="flex items-start gap-2">
<label className="mt-2 shrink-0">
<input
type="checkbox"
checked={it.selected !== false}
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
aria-label="保留此条"
/>
</label>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap gap-2">
<input
type="date"
value={it.date}
onChange={(e) => updateItem(idx, { date: e.target.value })}
className="j13-tl-field w-[9.5rem]"
/>
<input
type="text"
value={it.title}
maxLength={200}
placeholder="标题"
onChange={(e) => updateItem(idx, { title: e.target.value })}
className="j13-tl-field flex-1 min-w-[10rem]"
/>
</div>
<textarea
value={it.body}
rows={2}
placeholder="说明(可选,支持 Markdown)"
onChange={(e) => updateItem(idx, { body: e.target.value })}
className="j13-tl-field w-full"
/>
{it.sourceUrl ? (
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
来自提交 · {it.sourceUrl}
</p>
) : null}
</div>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="j13-md-tool"
aria-label="上移"
disabled={idx === 0}
onClick={() => moveItem(idx, -1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="下移"
disabled={idx === items.length - 1}
onClick={() => moveItem(idx, 1)}
>
<ArrowDown size={14} />
</button>
<button
type="button"
className="j13-md-tool"
aria-label="删除"
onClick={() => setDeleteIdx(idx)}
>
<Trash2 size={14} />
</button>
</div>
</div>
</li>
))}
</ul>
</div>
</Modal>
<ConfirmDialog
open={deleteIdx != null}
title="删除条目"
message="确定删除这条时间线项目?"
danger
confirmLabel="删除"
onCancel={() => setDeleteIdx(null)}
onConfirm={() => {
if (deleteIdx != null) {
setItems((prev) => prev.filter((_, i) => i !== deleteIdx));
}
setDeleteIdx(null);
}}
/>
</>
);
}

View File

@@ -21,6 +21,7 @@ import {
User as UserIcon,
Settings,
ChevronDown,
FileText,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -83,6 +84,12 @@ const NAV_GROUPS: NavGroup[] = [
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/pages",
label: "单页",
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{

View File

@@ -11,6 +11,8 @@ import {
PenLine,
Activity,
TrendingUp,
Info,
Pin,
} from "lucide-react";
import type {
PostListItem,
@@ -19,6 +21,7 @@ import type {
AnnouncementSummary,
NewUser,
CheckinStatus,
SidebarPageItem,
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import { PulseCheckin } from "./CheckinCard";
@@ -117,14 +120,82 @@ function PulseCard({
);
}
/* ---------- 关于本站(侧栏单页) ---------- */
function AboutSite({ pages }: { pages: SidebarPageItem[] }) {
if (pages.length === 0) return null;
if (pages.length === 1) {
const p = pages[0];
return (
<section className="j13-module" aria-label="关于本站">
<p className="j13-module-title">
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
</p>
<Link href={`/p/${p.slug}`} className="block mt-2 rounded-lg px-0.5 py-1 transition-colors hover:bg-[var(--accent-soft)]">
<p className="text-[14px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
{p.title}
</p>
{p.excerpt ? (
<p className="meta text-[12.5px] mt-1.5 leading-relaxed line-clamp-3">{p.excerpt}</p>
) : null}
</Link>
</section>
);
}
return (
<section className="j13-module" aria-label="关于本站">
<p className="j13-module-title mb-1.5">
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
</p>
<ul className="space-y-0.5">
{pages.map((p) => (
<li key={p.id}>
<Link href={`/p/${p.slug}`} className="j13-raillink" data-tip={p.title}>
<span className="flex-1 min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{p.title}
</span>
</Link>
</li>
))}
</ul>
</section>
);
}
/* ---------- 站点公告 ---------- */
function Announcements({ items }: { items: AnnouncementSummary[] }) {
function Announcements({
items,
canManageAnnouncements,
}: {
items: AnnouncementSummary[];
canManageAnnouncements?: boolean;
}) {
return (
<section className="j13-module" aria-label="站点公告">
<p className="j13-module-title">
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
</p>
<div className="flex items-center justify-between gap-2">
<p className="j13-module-title !mb-0">
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
</p>
<span className="inline-flex items-center gap-2 shrink-0">
<Link
href="/announcements"
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
全部
</Link>
{canManageAnnouncements ? (
<Link
href="/admin/announcements"
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
管理
</Link>
) : null}
</span>
</div>
{items.length === 0 ? (
<p className="meta mt-2 px-0.5">暂无公告</p>
) : (
@@ -137,7 +208,17 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
data-tip={a.title}
>
<span className="j13-anno-item-body">
<span className="j13-anno-item-title truncate">{a.title}</span>
<span className="j13-anno-item-title truncate inline-flex items-center gap-1 min-w-0">
{a.pinned ? (
<Pin
size={11}
className="shrink-0"
style={{ color: "var(--accent)" }}
aria-label="置顶"
/>
) : null}
<span className="truncate">{a.title}</span>
</span>
<span className="j13-anno-item-meta">
{a.tag ? (
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
@@ -338,6 +419,8 @@ type PanelsProps = {
newUsers: NewUser[];
checkin?: CheckinStatus | null;
allowComments: boolean;
canManageAnnouncements?: boolean;
sidebarPages?: SidebarPageItem[];
};
function CommunityPanelsBody({
@@ -348,11 +431,14 @@ function CommunityPanelsBody({
newUsers,
checkin,
allowComments,
canManageAnnouncements,
sidebarPages = [],
}: PanelsProps) {
return (
<>
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
<Announcements items={announcements} />
<AboutSite pages={sidebarPages} />
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
<HotRanking posts={hot} allowComments={allowComments} />
<NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} allowComments={allowComments} />

View File

@@ -27,6 +27,8 @@ type MdHideBlockProps = {
points?: number;
locked?: boolean;
postId?: number;
/** 登录跳转路径(公告等无 postId 时使用) */
loginHref?: string;
loggedIn?: boolean;
children?: ReactNode;
};
@@ -37,6 +39,7 @@ export default function MdHideBlock({
points = 0,
locked = false,
postId,
loginHref,
loggedIn = false,
children,
}: MdHideBlockProps) {
@@ -46,6 +49,9 @@ export default function MdHideBlock({
const [pwd, setPwd] = useState("");
const [pwdError, setPwdError] = useState("");
const label = hideKindLabel(kind);
const loginTo =
loginHref ||
(postId ? `/login?redirect=${encodeURIComponent(`/post/${postId}`)}` : "/login");
if (locked) {
const unlockPoints = async () => {
@@ -115,11 +121,8 @@ export default function MdHideBlock({
: "登录后即可阅读此段"}
</p>
<div className="j13-hide-gate-actions">
{kind === "login" && !loggedIn && postId && (
<Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
className="btn btn-primary"
>
{kind === "login" && !loggedIn && (
<Link href={loginTo} className="btn btn-primary">
去登录
</Link>
)}
@@ -133,23 +136,24 @@ export default function MdHideBlock({
{busy ? "解锁中…" : `支付 ${points || 0} 积分解锁`}
</button>
)}
{kind === "points" && !loggedIn && postId && (
<Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
className="btn btn-primary"
>
{kind === "points" && !loggedIn && (
<Link href={loginTo} className="btn btn-primary">
登录后解锁
</Link>
)}
{kind === "reply" && postId && !loggedIn && (
{kind === "reply" && !loggedIn && (
<Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}#comment-input`)}`}
href={
postId
? `/login?redirect=${encodeURIComponent(`/post/${postId}#comment-input`)}`
: loginTo
}
className="btn btn-primary"
>
登录后去回复
</Link>
)}
{kind === "reply" && loggedIn && (
{kind === "reply" && loggedIn && postId && (
<a
href="#comment-input"
className="btn btn-primary"

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { ExternalLink } from "lucide-react";
import {
groupTimelineByDate,
type TimelineItem,
} from "@/lib/timelineBlocks";
type MdTimelineProps = {
items: TimelineItem[];
/** 每项正文已渲染的 React 节点(与 items 等长);缺省则只显示纯文本 */
renderBody?: (item: TimelineItem, index: number) => ReactNode;
};
/**
* 按日分组的垂直时间线(更新树)。颜色走 CSS 令牌。
*/
export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
if (!items.length) {
return <p className="meta px-1 py-2">暂无时间线条目</p>;
}
const groups = groupTimelineByDate(items);
let flat = 0;
return (
<div className="j13-timeline" role="list" aria-label="时间线">
{groups.map((g) => (
<div key={g.date} className="j13-timeline-day">
<div className="j13-timeline-day-label">
<time dateTime={g.date}>{g.date}</time>
</div>
<ul className="j13-timeline-items">
{g.items.map((it) => {
const idx = flat++;
return (
<li key={`${g.date}-${idx}-${it.title}`} className="j13-timeline-item" role="listitem">
<span className="j13-timeline-dot" aria-hidden />
<div className="j13-timeline-card">
<div className="j13-timeline-head">
<h3 className="j13-timeline-title">{it.title}</h3>
{it.sourceUrl ? (
<a
href={it.sourceUrl}
className="j13-timeline-src"
target="_blank"
rel="noopener noreferrer"
data-tip="查看提交"
aria-label="查看提交"
>
<ExternalLink size={13} />
</a>
) : null}
</div>
{renderBody ? (
<div className="j13-timeline-body">{renderBody(it, idx)}</div>
) : it.body.trim() ? (
<div className="j13-timeline-body whitespace-pre-wrap">{it.body}</div>
) : null}
</div>
</li>
);
})}
</ul>
</div>
))}
</div>
);
}

View File

@@ -86,7 +86,13 @@ export type AnnouncementColor =
| "orange"
| "red"
| "purple"
| "gray";
| "gray"
| "teal"
| "cyan"
| "pink"
| "amber"
| "indigo"
| "rose";
// 右栏站点公告摘要(标签/日期/标题)
export interface AnnouncementSummary {
@@ -95,6 +101,7 @@ export interface AnnouncementSummary {
tag: string;
tag_color: AnnouncementColor;
created_at: string;
pinned?: boolean;
}
// 公告详情/后台管理完整对象
@@ -105,10 +112,42 @@ export interface Announcement {
tag: string;
tag_color: AnnouncementColor;
published: boolean;
pinned: boolean;
created_at: string;
updated_at: string;
}
export interface SidebarPageItem {
id: number;
slug: string;
title: string;
excerpt: string;
}
export interface SitePage {
id: number;
slug: string;
title: string;
content: string;
excerpt: string;
published: boolean;
show_in_sidebar: boolean;
sort_order: number;
allow_comments: boolean;
created_at: string;
updated_at: string;
}
export interface SitePageInput {
slug: string;
title: string;
content: string;
excerpt?: string;
published: boolean;
show_in_sidebar: boolean;
sort_order?: number;
}
export interface NewUser {
id: number;
username: string;
@@ -130,6 +169,8 @@ export interface OverviewResponse {
active_users: ActiveUser[];
boards: BoardWithCount[];
announcements: AnnouncementSummary[];
announcements_total?: number;
sidebar_pages?: SidebarPageItem[];
new_users: NewUser[];
checkin?: CheckinStatus | null;
}
@@ -1509,11 +1550,15 @@ export async function apiDoCheckin(): Promise<CheckinStatus> {
// ===== 站点公告 =====
// SSR 公告详情(仅已发布;草稿/不存在由调用方按 404 处理)
// SSR 公告详情(仅已发布;转发 cookie 以便按登录态脱敏 hide)
export async function fetchAnnouncementDetail(
id: string
id: string,
cookieHeader?: string
): Promise<{ announcement: Announcement } | null> {
const res = await fetch(`${API_BASE}/api/announcements/${id}`, ssrInit());
const res = await fetch(
`${API_BASE}/api/announcements/${id}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (res.status === 404) return null;
if (!res.ok) throw new Error("获取公告失败");
return res.json();
@@ -1550,6 +1595,7 @@ export interface AnnouncementInput {
tag: string;
tag_color: AnnouncementColor;
published: boolean;
pinned?: boolean;
}
async function saveAnnouncement(
@@ -1586,6 +1632,126 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
}
}
export async function apiAdminToggleAnnouncementPin(
id: number
): Promise<{ announcement: Announcement }> {
const res = await fetchWithRefresh(`/api/admin/announcements/${id}/pin`, {
method: "POST",
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "置顶失败");
return data as { announcement: Announcement };
}
/** SSR / 公开:分页公告列表 */
export async function fetchAnnouncementsPage(
page = 1,
size = 20
): Promise<{ announcements: Announcement[]; total: number; page: number }> {
const res = await fetch(
`${API_BASE}/api/announcements?page=${page}&size=${size}`,
ssrInit()
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取公告失败");
return data as { announcements: Announcement[]; total: number; page: number };
}
export type TimelineGitItem = {
date: string;
title: string;
body: string;
source_url?: string;
sha?: string;
};
export async function apiTimelineFromGit(input: {
urls: string[];
follow_pages?: boolean;
}): Promise<{ items: TimelineGitItem[]; warning?: string; error?: string }> {
const res = await fetchWithRefresh("/api/timeline/from-git", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return data as { items: TimelineGitItem[]; warning?: string; error?: string };
}
export async function fetchSitePageBySlug(
slug: string
): Promise<{ page: SitePage } | null> {
const res = await fetch(`${API_BASE}/api/pages/${encodeURIComponent(slug)}`, ssrInit());
if (res.status === 404) return null;
if (!res.ok) throw new Error("获取页面失败");
return res.json();
}
export async function apiAdminListSitePages(): Promise<{ pages: SitePage[] }> {
const res = await fetchWithRefresh("/api/admin/pages", { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取单页失败");
return data as { pages: SitePage[] };
}
export async function fetchAdminSitePages(
cookieHeader?: string
): Promise<{ pages: SitePage[] }> {
const res = await fetch(`${API_BASE}/api/admin/pages`, ssrInit(cookieHeaders(cookieHeader)));
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取单页失败");
return data as { pages: SitePage[] };
}
async function saveSitePage(
method: "POST" | "PUT",
url: string,
input: SitePageInput
): Promise<{ page: SitePage }> {
const res = await fetchWithRefresh(url, {
method,
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存页面失败");
return data as { page: SitePage };
}
export function apiAdminCreateSitePage(input: SitePageInput) {
return saveSitePage("POST", "/api/admin/pages", input);
}
export function apiAdminUpdateSitePage(id: number, input: SitePageInput) {
return saveSitePage("PUT", `/api/admin/pages/${id}`, input);
}
export async function apiAdminDeleteSitePage(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/pages/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除页面失败");
}
}
export async function apiGetAdminSettings(): Promise<
PublicSettings & { timeline_git_import?: string }
> {
const res = await fetchWithRefresh("/api/admin/settings", { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取设置失败");
return {
...normalizePublicSettings(data as Partial<PublicSettings>),
timeline_git_import:
typeof data.timeline_git_import === "string" ? data.timeline_git_import : undefined,
};
}
// ===== 用户管理(管理员) =====
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
@@ -1901,6 +2067,7 @@ export type UpdateSiteSettingsBody = {
attachment_max_mb?: number;
attachment_max_count?: number;
image_max_mb?: number;
timeline_git_import?: string;
};
// 更新站点设置(字段可选);accent 传空串恢复默认主题色

View File

@@ -0,0 +1,205 @@
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */
import {
splitHideSegments,
type HideKind,
type HideSegment,
} from "@/lib/hideBlocks";
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks";
export type ContentSegment =
| { type: "md"; text: string }
| {
type: "hide";
kind: HideKind;
points: number;
locked: boolean;
body: string;
}
| { type: "timeline"; items: TimelineItem[]; rawBody: string };
function isTimelineClose(line: string): boolean {
return line.trim().toLowerCase() === "[/timeline]";
}
function isTimelineOpen(trimmed: string): boolean {
return trimmed.toLowerCase() === "[timeline]";
}
/**
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
*/
export function splitContentSegments(content: string): ContentSegment[] {
if (!content) return [{ type: "md", text: "" }];
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const raw: Array<{ type: "md"; text: string } | { type: "timeline"; body: string }> = [];
let buf: string[] = [];
let inCode = false;
let i = 0;
const flushMd = () => {
if (buf.length === 0) return;
raw.push({ type: "md", text: buf.join("\n") });
buf = [];
};
while (i < lines.length) {
const trimmed = lines[i].trim();
if (trimmed.startsWith("```")) {
inCode = !inCode;
buf.push(lines[i]);
i++;
continue;
}
if (inCode) {
buf.push(lines[i]);
i++;
continue;
}
if (isTimelineOpen(trimmed)) {
flushMd();
const bodyLines: string[] = [];
let j = i + 1;
let innerCode = false;
let found = false;
while (j < lines.length) {
const inner = lines[j].trim();
if (inner.startsWith("```")) {
innerCode = !innerCode;
bodyLines.push(lines[j]);
j++;
continue;
}
if (innerCode) {
bodyLines.push(lines[j]);
j++;
continue;
}
if (isTimelineOpen(inner)) {
// 嵌套视为失败:整段当 md
buf.push(lines[i]);
i++;
found = false;
break;
}
if (isTimelineClose(inner)) {
found = true;
break;
}
bodyLines.push(lines[j]);
j++;
}
if (!found) {
buf.push(lines[i]);
i++;
continue;
}
raw.push({ type: "timeline", body: bodyLines.join("\n") });
i = j + 1;
continue;
}
buf.push(lines[i]);
i++;
}
flushMd();
const out: ContentSegment[] = [];
for (const part of raw) {
if (part.type === "timeline") {
out.push({
type: "timeline",
items: parseTimelineBody(part.body),
rawBody: part.body,
});
continue;
}
const hideSegs = splitHideSegments(part.text) as HideSegment[];
for (const s of hideSegs) {
if (s.type === "md") {
out.push({ type: "md", text: s.text });
} else {
out.push({
type: "hide",
kind: s.kind,
points: s.points,
locked: s.locked,
body: s.body,
});
}
}
}
return out.length > 0 ? out : [{ type: "md", text: content }];
}
/** 光标是否落在某个 [timeline]…[/timeline] 块内;返回块起止与正文 */
export function findTimelineBlockAt(
content: string,
cursor: number
): { start: number; end: number; body: string } | null {
const blocks = listTimelineBlocks(content);
for (const b of blocks) {
if (cursor >= b.start && cursor <= b.end) return b;
}
return null;
}
/**
* 编辑器打开时间线面板时的目标块:
* 1) 光标落在某块内 → 该块
* 2) 正文恰有一块 → 该块(changelog 页常见:光标在标题区仍应更新已有时间线)
* 3) 否则 null(新建插入)
*/
export function resolveTimelineBlockForEdit(
content: string,
cursor: number
): { start: number; end: number; body: string } | null {
const at = findTimelineBlockAt(content, cursor);
if (at) return at;
const blocks = listTimelineBlocks(content);
if (blocks.length === 1) return blocks[0];
return null;
}
function listTimelineBlocks(
content: string
): Array<{ start: number; end: number; body: string }> {
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const out: Array<{ start: number; end: number; body: string }> = [];
let offset = 0;
let inCode = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineStart = offset;
const lineEnd = offset + line.length;
const trimmed = line.trim();
if (trimmed.startsWith("```")) inCode = !inCode;
if (!inCode && isTimelineOpen(trimmed)) {
const openStart = lineStart;
let j = i + 1;
let innerCode = false;
let closeEnd = -1;
const bodyLines: string[] = [];
let pos = lineEnd + 1;
while (j < lines.length) {
const l = lines[j];
const t = l.trim();
if (t.startsWith("```")) innerCode = !innerCode;
if (!innerCode && isTimelineClose(t)) {
closeEnd = pos + l.length;
break;
}
bodyLines.push(l);
pos += l.length + 1;
j++;
}
if (closeEnd >= 0) {
out.push({ start: openStart, end: closeEnd, body: bodyLines.join("\n") });
}
}
offset = lineEnd + 1;
}
return out;
}

View File

@@ -1,4 +1,4 @@
/** 正文 :::hide 块解析(与 backend/markdown/hide.go 契约一致) */
/** 正文 [hide]…[/hide] 块解析(与 backend/markdown/hide.go 契约一致) */
export type HideKind = "login" | "reply" | "points" | "password";
@@ -13,25 +13,34 @@ export type HideSegment =
};
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
const HIDE_OPEN_RE = /^\[hide(?:\s+(.+))?\]$/i;
function isClose(line: string): boolean {
return line.trim() === ":::";
return line.trim().toLowerCase() === "[/hide]";
}
/** 与 Go parseOpenMarker 对齐::::hide <kind> [points|password] [locked] */
/** 与 Go parseOpenMarker 对齐:[hide <kind>[=arg] [locked]] */
function parseOpen(trimmed: string): {
kind: HideKind;
points: number;
locked: boolean;
} | null {
if (!trimmed.startsWith(":::hide")) return null;
const parts = trimmed
.slice(":::hide".length)
.trim()
.split(/\s+/)
.filter(Boolean);
const m = trimmed.match(HIDE_OPEN_RE);
if (!m) return null;
const rest = (m[1] || "").trim();
if (!rest) return null;
const parts = rest.split(/\s+/).filter(Boolean);
if (parts.length === 0) return null;
const kind = parts[0] as HideKind;
let head = parts[0];
let kind = head;
let arg = "";
const eq = head.indexOf("=");
if (eq >= 0) {
kind = head.slice(0, eq);
arg = head.slice(eq + 1);
}
kind = kind.toLowerCase() as HideKind;
if (!HIDE_KINDS.has(kind)) return null;
let idx = 1;
@@ -39,20 +48,21 @@ function parseOpen(trimmed: string): {
let locked = false;
if (kind === "points") {
if (idx >= parts.length) return null;
const n = Number(parts[idx]);
if (!arg) return null;
const n = Number(arg);
if (!Number.isFinite(n) || n < 1 || n > 100000) return null;
points = n;
idx++;
} else if (kind === "password") {
// 脱敏::::hide password locked;原文::::hide password <pwd> [locked]
if (idx < parts.length && parts[idx] !== "locked") {
idx++;
// 脱敏:[hide password locked];原文密码不在前端解析保留
if (arg) {
/* skip plaintext */
}
} else if (arg) {
return null;
}
for (; idx < parts.length; idx++) {
if (parts[idx] === "locked") locked = true;
if (parts[idx].toLowerCase() === "locked") locked = true;
else return null;
}
return { kind, points, locked };

View File

@@ -0,0 +1,140 @@
/** 正文 [timeline]…[/timeline] 块解析与序列化 */
export type TimelineItem = {
date: string;
title: string;
body: string;
sourceUrl?: string;
/** 导入勾选;手填项默认 true */
selected?: boolean;
};
export type TimelineDayGroup = {
date: string;
items: TimelineItem[];
};
const GIT_COMMENT_RE = /^<!--\s*git:\s*(https?:\/\/\S+)\s*-->\s*$/i;
const HEADING_RE = /^##\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/;
/** 去掉控制字符与 RTL 覆盖,截断长度 */
export function sanitizeTimelineText(raw: string, max: number): string {
let s = raw
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
.replace(/[\u202A-\u202E\u2066-\u2069]/g, "");
if ([...s].length > max) {
s = [...s].slice(0, max).join("");
}
return s;
}
function isDirectiveLine(t: string): boolean {
const lower = t.toLowerCase();
return (
lower === "[/hide]" ||
lower === "[/timeline]" ||
lower === "[timeline]" ||
lower.startsWith("[hide") ||
t.startsWith("<!-- git:")
);
}
/** 避免导入文本拆坏指令块:危险独立行加前缀空格 */
export function neutralizeDirectiveLines(text: string): string {
return text
.split("\n")
.map((line) => {
const t = line.trim();
if (isDirectiveLine(t)) {
return " " + line;
}
return line;
})
.join("\n");
}
/** 标题行:去掉换行,防止拆项 */
export function sanitizeTimelineTitle(title: string): string {
return sanitizeTimelineText(title.replace(/[\r\n]+/g, " ").trim(), 200);
}
export function parseTimelineBody(body: string): TimelineItem[] {
const lines = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const items: TimelineItem[] = [];
let cur: TimelineItem | null = null;
let bodyBuf: string[] = [];
const flush = () => {
if (!cur) return;
let sourceUrl: string | undefined;
const kept: string[] = [];
for (const line of bodyBuf) {
const m = line.trim().match(GIT_COMMENT_RE);
if (m) {
sourceUrl = m[1];
continue;
}
kept.push(line);
}
cur.body = kept.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
if (sourceUrl) cur.sourceUrl = sourceUrl;
items.push(cur);
cur = null;
bodyBuf = [];
};
for (const line of lines) {
const m = line.match(HEADING_RE);
if (m) {
flush();
cur = {
date: m[1],
title: sanitizeTimelineTitle(m[2]),
body: "",
selected: true,
};
continue;
}
if (cur) bodyBuf.push(line);
}
flush();
return items;
}
export function serializeTimelineBlock(items: TimelineItem[]): string {
const parts = items
.filter((it) => it.selected !== false)
.map((it) => {
const date = /^\d{4}-\d{2}-\d{2}$/.test(it.date)
? it.date
: new Date().toISOString().slice(0, 10);
const title = sanitizeTimelineTitle(it.title) || "未命名";
const body = neutralizeDirectiveLines(sanitizeTimelineText(it.body || "", 2000));
const lines = [`## ${date} ${title}`];
if (body.trim()) lines.push(body.trimEnd());
if (it.sourceUrl && /^https:\/\//i.test(it.sourceUrl)) {
lines.push(`<!-- git: ${it.sourceUrl} -->`);
}
return lines.join("\n");
});
return `[timeline]\n${parts.join("\n\n")}\n[/timeline]`;
}
export function groupTimelineByDate(items: TimelineItem[]): TimelineDayGroup[] {
const map = new Map<string, TimelineItem[]>();
const order: string[] = [];
for (const it of items) {
const d = it.date || "未知日期";
if (!map.has(d)) {
map.set(d, []);
order.push(d);
}
map.get(d)!.push(it);
}
order.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
return order.map((date) => ({ date, items: map.get(date)! }));
}
export function isLikelyMergeCommit(title: string): boolean {
return /^merge\b/i.test(title.trim()) || /^合并\b/.test(title.trim());
}