feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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