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