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