后台站点设计UIUX优化
This commit is contained in:
135
frontend/app/admin/settings/BasicIdentityPanel.tsx
Normal file
135
frontend/app/admin/settings/BasicIdentityPanel.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AdminField, AdminSection } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { BRAND_LIMITS, siteDocumentTitle } from "@/lib/brand";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export default function BasicIdentityPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial.site_name);
|
||||
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
||||
const [slogan, setSlogan] = useState(initial.site_slogan);
|
||||
const [desc, setDesc] = useState(initial.site_description);
|
||||
const [saved, setSaved] = useState({
|
||||
name: initial.site_name,
|
||||
wordmark: initial.site_wordmark,
|
||||
slogan: initial.site_slogan,
|
||||
desc: initial.site_description,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const dirty =
|
||||
name.trim() !== saved.name ||
|
||||
wordmark.trim() !== saved.wordmark ||
|
||||
slogan.trim() !== saved.slogan ||
|
||||
desc.trim() !== saved.desc;
|
||||
|
||||
const preview = siteDocumentTitle(name, slogan);
|
||||
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
const nextName = name.trim();
|
||||
if (!nextName) {
|
||||
toast("显示名称不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({
|
||||
site_name: nextName,
|
||||
site_wordmark: wordmark.trim(),
|
||||
site_slogan: slogan.trim(),
|
||||
site_description: desc.trim(),
|
||||
});
|
||||
setName(res.site_name);
|
||||
setWordmark(res.site_wordmark);
|
||||
setSlogan(res.site_slogan);
|
||||
setDesc(res.site_description);
|
||||
setSaved({
|
||||
name: res.site_name,
|
||||
wordmark: res.site_wordmark,
|
||||
slogan: res.site_slogan,
|
||||
desc: res.site_description,
|
||||
});
|
||||
onSaved?.(res);
|
||||
toast("基本信息已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-grid">
|
||||
<AdminSection title="名称与字标" description="显示名称用于版权与标题;无 Logo 时页眉改用字标">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
label="显示名称"
|
||||
maxLength={32}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="姜十三论坛"
|
||||
/>
|
||||
<AdminField
|
||||
id="site-wordmark"
|
||||
label="字标"
|
||||
hint="留空则回退为显示名称,最多 16 字"
|
||||
maxLength={BRAND_LIMITS.wordmark}
|
||||
value={wordmark}
|
||||
onChange={setWordmark}
|
||||
placeholder="姜十三"
|
||||
/>
|
||||
</div>
|
||||
</AdminSection>
|
||||
<AdminSection
|
||||
title="标语与简介"
|
||||
description="标语出现在浏览器标题和页眉副标题"
|
||||
extra={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminField
|
||||
id="site-slogan"
|
||||
label="标语"
|
||||
maxLength={BRAND_LIMITS.slogan}
|
||||
value={slogan}
|
||||
onChange={setSlogan}
|
||||
placeholder="技术分享与讨论"
|
||||
/>
|
||||
<p className="meta text-[12.5px]">
|
||||
标题预览:<span style={{ color: "var(--ink)" }}>{preview}</span>
|
||||
</p>
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
label="简介"
|
||||
as="textarea"
|
||||
minHeight="96px"
|
||||
maxLength={160}
|
||||
value={desc}
|
||||
onChange={setDesc}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
/>
|
||||
</div>
|
||||
</AdminSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
frontend/app/admin/settings/BrandMediaPicker.tsx
Normal file
104
frontend/app/admin/settings/BrandMediaPicker.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { Images, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
||||
|
||||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||
logo_light: "浅色 Logo",
|
||||
logo_dark: "暗色 Logo",
|
||||
favicon: "Favicon",
|
||||
};
|
||||
|
||||
export default function BrandMediaPicker({
|
||||
open,
|
||||
slot,
|
||||
onClose,
|
||||
onPick,
|
||||
}: {
|
||||
open: boolean;
|
||||
slot: BrandSlot;
|
||||
onClose: () => void;
|
||||
onPick: (item: MediaAttachment) => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<MediaAttachment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void apiMyMedia()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.error) {
|
||||
setError(res.error);
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
const all = res.attachments || [];
|
||||
setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url)));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("加载媒体库失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, slot]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
||||
description="只列出格式符合的图片。保存后会复制一份到品牌目录,之后删除原图不会影响 Logo。"
|
||||
onClose={onClose}
|
||||
maxWidthClass="max-w-lg"
|
||||
dismissOnOverlay
|
||||
>
|
||||
{loading ? (
|
||||
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
|
||||
<Loader2 size={14} className="animate-spin" /> 加载中
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
|
||||
{error}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="meta text-[13px] py-6 text-center">没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(item);
|
||||
onClose();
|
||||
}}
|
||||
className="relative group aspect-square rounded-xl overflow-hidden"
|
||||
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
|
||||
data-tip="用作草稿预览,点保存后才生效"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={item.url} alt="" className="w-full h-full object-contain" loading="lazy" />
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||||
>
|
||||
<Images size={14} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
408
frontend/app/admin/settings/BrandSeoPanel.tsx
Normal file
408
frontend/app/admin/settings/BrandSeoPanel.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { ImagePlus, Images, Loader2, Save, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { AdminSection, AdminSegmented } from "@/components/admin";
|
||||
import BrandLockup from "@/components/BrandLockup";
|
||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||
import {
|
||||
BRAND_LIMITS,
|
||||
BRAND_LOGO_FITS,
|
||||
BRAND_LOGO_SIZES,
|
||||
brandAccept,
|
||||
brandFileOk,
|
||||
runeCount,
|
||||
type BrandLogoFit,
|
||||
type BrandLogoSize,
|
||||
type BrandMark,
|
||||
type BrandSlot,
|
||||
} from "@/lib/brand";
|
||||
import { toast } from "@/lib/toast";
|
||||
import BrandMediaPicker from "./BrandMediaPicker";
|
||||
|
||||
type SlotDraft = {
|
||||
committed: string;
|
||||
file: File | null;
|
||||
blob: string;
|
||||
mediaId: number | null;
|
||||
mediaUrl: string;
|
||||
};
|
||||
|
||||
function emptyDraft(committed: string): SlotDraft {
|
||||
return { committed, file: null, blob: "", mediaId: null, mediaUrl: "" };
|
||||
}
|
||||
|
||||
function previewOf(d: SlotDraft): string {
|
||||
return d.blob || d.mediaUrl || d.committed;
|
||||
}
|
||||
|
||||
function slotDirty(d: SlotDraft, saved: string): boolean {
|
||||
if (d.file || d.mediaId != null) return true;
|
||||
return d.committed !== saved;
|
||||
}
|
||||
|
||||
const SLOTS: { id: BrandSlot; label: string; hint: string }[] = [
|
||||
{ id: "logo_light", label: "浅色 Logo", hint: "浅色主题。只传一套时,暗色共用这张。" },
|
||||
{ id: "logo_dark", label: "暗色 Logo", hint: "暗色主题。PNG / WebP / SVG,不超过 2MB。" },
|
||||
{ id: "favicon", label: "Favicon", hint: "标签图标。另支持 ICO,不超过 512KB。" },
|
||||
];
|
||||
|
||||
export default function BrandSeoPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
}) {
|
||||
const [light, setLight] = useState<SlotDraft>(() => emptyDraft(initial.logo_light_url));
|
||||
const [dark, setDark] = useState<SlotDraft>(() => emptyDraft(initial.logo_dark_url));
|
||||
const [favicon, setFavicon] = useState<SlotDraft>(() => emptyDraft(initial.favicon_url));
|
||||
const [saved, setSaved] = useState({
|
||||
light: initial.logo_light_url,
|
||||
dark: initial.logo_dark_url,
|
||||
favicon: initial.favicon_url,
|
||||
keywords: initial.site_keywords,
|
||||
mark: initial.brand_mark,
|
||||
size: initial.brand_logo_size,
|
||||
fit: initial.brand_logo_fit,
|
||||
});
|
||||
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
|
||||
const [mark, setMark] = useState<BrandMark>(initial.brand_mark);
|
||||
const [logoSize, setLogoSize] = useState<BrandLogoSize>(initial.brand_logo_size);
|
||||
const [logoFit, setLogoFit] = useState<BrandLogoFit>(initial.brand_logo_fit);
|
||||
const [kwInput, setKwInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||
const blobs = useRef<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const held = blobs;
|
||||
return () => {
|
||||
held.current.forEach((u) => URL.revokeObjectURL(u));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const drafts: Record<BrandSlot, [SlotDraft, Dispatch<SetStateAction<SlotDraft>>]> = {
|
||||
logo_light: [light, setLight],
|
||||
logo_dark: [dark, setDark],
|
||||
favicon: [favicon, setFavicon],
|
||||
};
|
||||
|
||||
const replaceFile = (slot: BrandSlot, file: File | null) => {
|
||||
const [, set] = drafts[slot];
|
||||
set((prev) => {
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
const blob = file ? URL.createObjectURL(file) : "";
|
||||
if (blob) blobs.current.push(blob);
|
||||
return { ...prev, file, blob, mediaId: null, mediaUrl: "" };
|
||||
});
|
||||
};
|
||||
|
||||
const keywordsDirty = JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
|
||||
const dirty =
|
||||
slotDirty(light, saved.light) ||
|
||||
slotDirty(dark, saved.dark) ||
|
||||
slotDirty(favicon, saved.favicon) ||
|
||||
keywordsDirty ||
|
||||
mark !== saved.mark ||
|
||||
logoSize !== saved.size ||
|
||||
logoFit !== saved.fit;
|
||||
|
||||
const addKeyword = (raw: string) => {
|
||||
const parts = raw.split(/[,,;;]/).map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length === 0) return;
|
||||
setKeywords((prev) => {
|
||||
const next = [...prev];
|
||||
const seen = new Set(next.map((k) => k.toLowerCase()));
|
||||
for (const k of parts) {
|
||||
if (runeCount(k) > BRAND_LIMITS.keywordRunes) {
|
||||
toast(`关键词「${k}」超过 ${BRAND_LIMITS.keywordRunes} 字`);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(k.toLowerCase())) continue;
|
||||
if (next.length >= BRAND_LIMITS.keywordCount) {
|
||||
toast(`最多 ${BRAND_LIMITS.keywordCount} 个关键词`);
|
||||
break;
|
||||
}
|
||||
seen.add(k.toLowerCase());
|
||||
next.push(k);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setKwInput("");
|
||||
};
|
||||
|
||||
const resolveSlot = async (slot: BrandSlot, draft: SlotDraft): Promise<string> => {
|
||||
if (draft.file) {
|
||||
const url = await apiUploadBrand(slot, draft.file);
|
||||
const [, set] = drafts[slot];
|
||||
set((prev) => {
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
|
||||
});
|
||||
return url;
|
||||
}
|
||||
if (draft.mediaId != null) {
|
||||
const url = await apiBrandFromMedia(slot, draft.mediaId);
|
||||
const [, set] = drafts[slot];
|
||||
set({ committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" });
|
||||
return url;
|
||||
}
|
||||
return draft.committed;
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving || !dirty) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const logoLight = await resolveSlot("logo_light", light);
|
||||
const logoDark = await resolveSlot("logo_dark", dark);
|
||||
const fav = await resolveSlot("favicon", favicon);
|
||||
const res = await apiUpdateSiteSettings({
|
||||
logo_light_url: logoLight,
|
||||
logo_dark_url: logoDark,
|
||||
favicon_url: fav,
|
||||
site_keywords: keywords,
|
||||
brand_mark: mark,
|
||||
brand_logo_size: logoSize,
|
||||
brand_logo_fit: logoFit,
|
||||
});
|
||||
setLight(emptyDraft(res.logo_light_url));
|
||||
setDark(emptyDraft(res.logo_dark_url));
|
||||
setFavicon(emptyDraft(res.favicon_url));
|
||||
setKeywords(res.site_keywords);
|
||||
setMark(res.brand_mark);
|
||||
setLogoSize(res.brand_logo_size);
|
||||
setLogoFit(res.brand_logo_fit);
|
||||
setSaved({
|
||||
light: res.logo_light_url,
|
||||
dark: res.logo_dark_url,
|
||||
favicon: res.favicon_url,
|
||||
keywords: res.site_keywords,
|
||||
mark: res.brand_mark,
|
||||
size: res.brand_logo_size,
|
||||
fit: res.brand_logo_fit,
|
||||
});
|
||||
onSaved?.(res);
|
||||
toast("品牌已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSlot = (slot: BrandSlot, label: string, hint: string, draft: SlotDraft) => {
|
||||
const preview = previewOf(draft);
|
||||
return (
|
||||
<div key={slot}>
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5 mb-2">{hint}</p>
|
||||
<div
|
||||
className="h-28 rounded-xl flex items-center justify-center px-3"
|
||||
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||||
>
|
||||
{preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preview} alt="" className="max-h-20 max-w-full object-contain" />
|
||||
) : (
|
||||
<span className="meta text-[12.5px]">未设置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<label className="btn btn-line btn-sm cursor-pointer">
|
||||
<ImagePlus size={14} /> 上传
|
||||
<input
|
||||
type="file"
|
||||
accept={brandAccept(slot)}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
const err = brandFileOk(slot, file);
|
||||
if (err) {
|
||||
toast(err);
|
||||
return;
|
||||
}
|
||||
replaceFile(slot, file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setPicker(slot)}>
|
||||
<Images size={14} /> 媒体库
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={!preview}
|
||||
onClick={() => {
|
||||
if (draft.blob) URL.revokeObjectURL(draft.blob);
|
||||
drafts[slot][1](emptyDraft(""));
|
||||
}}
|
||||
>
|
||||
<X size={14} /> 清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminSection
|
||||
title="品牌与 SEO"
|
||||
description="图加文字和纯图共用画幅与铺法。预览条即时变化,保存后访客才看到。"
|
||||
extra={
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存品牌
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 mb-5">
|
||||
<AdminSegmented
|
||||
role="group"
|
||||
size="sm"
|
||||
ariaLabel="页眉形式"
|
||||
value={mark}
|
||||
onChange={setMark}
|
||||
options={[
|
||||
{ key: "image_text", label: "图加文字" },
|
||||
{ key: "image", label: "纯图" },
|
||||
{ key: "text", label: "纯文字" },
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
className="rounded-xl overflow-hidden"
|
||||
style={{ background: "var(--panel-2)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||||
>
|
||||
<div className="h-[68px] px-4 flex items-center">
|
||||
<BrandLockup
|
||||
mode={mark}
|
||||
size={logoSize}
|
||||
fit={logoFit}
|
||||
frame
|
||||
name={initial.site_name}
|
||||
wordmark={initial.site_wordmark}
|
||||
slogan={initial.site_slogan}
|
||||
logoLight={previewOf(light)}
|
||||
logoDark={previewOf(dark)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{mark !== "text" ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="图槽画幅">
|
||||
{BRAND_LOGO_SIZES.map((item) => {
|
||||
const on = logoSize === item.id;
|
||||
const thumbH = 16;
|
||||
const thumbW = Math.round((item.w / item.h) * thumbH);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="rounded-xl px-3 py-2 text-left inline-flex items-center gap-2"
|
||||
style={{
|
||||
background: on ? "var(--accent-soft)" : "var(--panel)",
|
||||
color: on ? "var(--accent)" : "var(--ink)",
|
||||
boxShadow: on ? "inset 0 0 0 1px var(--accent)" : "inset 0 0 0 1px var(--line)",
|
||||
}}
|
||||
onClick={() => setLogoSize(item.id)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="shrink-0 rounded-sm"
|
||||
style={{
|
||||
width: thumbW,
|
||||
height: thumbH,
|
||||
boxShadow: "inset 0 0 0 1.5px currentColor",
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-[13px] font-medium">{item.label}</span>
|
||||
<span className="meta text-[12px]">{item.w}×{item.h}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<AdminSegmented
|
||||
role="group"
|
||||
size="sm"
|
||||
ariaLabel="图槽铺法"
|
||||
value={logoFit}
|
||||
onChange={setLogoFit}
|
||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="meta text-[12.5px]">纯文字不显示图槽。画幅和铺法只作用于图加文字和纯图。</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-brand-grid">
|
||||
{SLOTS.map((s) =>
|
||||
renderSlot(
|
||||
s.id,
|
||||
s.label,
|
||||
s.hint,
|
||||
s.id === "logo_dark" ? dark : s.id === "favicon" ? favicon : light
|
||||
)
|
||||
)}
|
||||
<div className="admin-brand-keywords">
|
||||
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
SEO 关键词
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5 mb-2">最多 16 个,每个 20 字。点芯片可删除。</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{keywords.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[12.5px]"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink)", boxShadow: "inset 0 0 0 1px var(--line)" }}
|
||||
onClick={() => setKeywords((prev) => prev.filter((x) => x !== k))}
|
||||
>
|
||||
{k}
|
||||
<X size={12} />
|
||||
</button>
|
||||
))}
|
||||
{keywords.length === 0 ? <span className="meta text-[12.5px]">尚未添加</span> : null}
|
||||
</div>
|
||||
<div className="flex gap-2 max-w-xl">
|
||||
<input
|
||||
value={kwInput}
|
||||
onChange={(e) => setKwInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addKeyword(kwInput);
|
||||
}
|
||||
}}
|
||||
placeholder="输入后回车添加"
|
||||
className="flex-1 min-w-0 px-3 py-2 text-sm rounded-lg"
|
||||
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
|
||||
aria-label="添加关键词"
|
||||
/>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => addKeyword(kwInput)}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<p className="meta text-[12.5px] mt-3">{dirty ? "未保存,访客仍看到旧 Logo" : "已与线上一致"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<BrandMediaPicker
|
||||
open={picker != null}
|
||||
slot={picker || "logo_light"}
|
||||
onClose={() => setPicker(null)}
|
||||
onPick={(item) => {
|
||||
if (!picker) return;
|
||||
const [prev, set] = drafts[picker];
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
|
||||
}}
|
||||
/>
|
||||
</AdminSection>
|
||||
);
|
||||
}
|
||||
173
frontend/app/admin/settings/FooterLinksPanel.tsx
Normal file
173
frontend/app/admin/settings/FooterLinksPanel.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AdminField, AdminSection } from "@/components/admin";
|
||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import {
|
||||
BRAND_LIMITS,
|
||||
footerURLAllowed,
|
||||
isExternalFooterURL,
|
||||
type FooterLink,
|
||||
} from "@/lib/brand";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
function clone(links: FooterLink[]): FooterLink[] {
|
||||
return links.map((l) => ({ ...l }));
|
||||
}
|
||||
|
||||
export default function FooterLinksPanel({
|
||||
initial,
|
||||
onSaved,
|
||||
}: {
|
||||
initial: PublicSettings;
|
||||
onSaved?: (next: PublicSettings) => void;
|
||||
}) {
|
||||
const [links, setLinks] = useState<FooterLink[]>(() => clone(initial.footer_links));
|
||||
const [saved, setSaved] = useState<FooterLink[]>(() => clone(initial.footer_links));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const dirty = JSON.stringify(links) !== JSON.stringify(saved);
|
||||
|
||||
const update = (i: number, patch: Partial<FooterLink>) => {
|
||||
setLinks((prev) => prev.map((item, idx) => (idx === i ? { ...item, ...patch } : item)));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving || !dirty) return;
|
||||
for (const item of links) {
|
||||
if (!item.label.trim()) {
|
||||
toast("链接文案不能为空");
|
||||
return;
|
||||
}
|
||||
if (!footerURLAllowed(item.url)) {
|
||||
toast(`地址无效:${item.url || "(空)"}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = links.map((item) => ({
|
||||
label: item.label.trim(),
|
||||
url: item.url.trim(),
|
||||
new_tab: item.new_tab,
|
||||
}));
|
||||
const res = await apiUpdateSiteSettings({ footer_links: payload });
|
||||
setLinks(clone(res.footer_links));
|
||||
setSaved(clone(res.footer_links));
|
||||
onSaved?.(res);
|
||||
toast("页脚链接已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const move = (i: number, dir: -1 | 1) => {
|
||||
setLinks((prev) => {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
const [row] = next.splice(i, 1);
|
||||
next.splice(j, 0, row);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-settings-grid">
|
||||
<AdminSection
|
||||
title="页脚与合规"
|
||||
description="用于 ICP 备案、用户协议等。最多 8 条。外链默认新标签打开。"
|
||||
extra={
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving} onClick={() => void save()}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{links.map((item, i) => (
|
||||
<div key={i} className="rounded-xl p-3 flex flex-col gap-3" style={{ boxShadow: "inset 0 0 0 1px var(--line)" }}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="meta text-[12px]">第 {i + 1} 条</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className="admin-icon-btn" aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
aria-label="下移"
|
||||
disabled={i === links.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
aria-label="删除"
|
||||
onClick={() => setLinks((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AdminField
|
||||
id={`footer-label-${i}`}
|
||||
label="文案"
|
||||
maxLength={BRAND_LIMITS.footerLabel}
|
||||
value={item.label}
|
||||
onChange={(v) => update(i, { label: v })}
|
||||
placeholder="京 ICP 备xxxxxxxx号"
|
||||
/>
|
||||
<AdminField
|
||||
id={`footer-url-${i}`}
|
||||
label="地址"
|
||||
maxLength={BRAND_LIMITS.footerURL}
|
||||
value={item.url}
|
||||
onChange={(v) => {
|
||||
const wasExt = isExternalFooterURL(item.url);
|
||||
const nowExt = isExternalFooterURL(v);
|
||||
update(i, { url: v, new_tab: !wasExt && nowExt ? true : item.new_tab });
|
||||
}}
|
||||
placeholder="https://beian.miit.gov.cn/ 或 /about"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.new_tab}
|
||||
onChange={(e) => update(i, { new_tab: e.target.checked })}
|
||||
/>
|
||||
新标签打开
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm self-start"
|
||||
disabled={links.length >= BRAND_LIMITS.footerLinks}
|
||||
onClick={() => setLinks((prev) => [...prev, { label: "", url: "", new_tab: false }])}
|
||||
>
|
||||
<Plus size={14} /> 新增链接
|
||||
</button>
|
||||
</div>
|
||||
</AdminSection>
|
||||
<AdminSection title="页脚预览" description="保存前仅在此处预览,前台页脚不会跟着变">
|
||||
<div className="rounded-xl px-4 py-3 flex flex-wrap items-center gap-x-4 gap-y-2" style={{ background: "var(--panel-2)" }}>
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {new Date().getFullYear()} {initial.site_name}</span>
|
||||
{links.filter((l) => l.label.trim() && l.url.trim()).map((item) => (
|
||||
<span key={`${item.label}-${item.url}`} className="text-[12px]" style={{ color: "var(--ink-2)" }}>
|
||||
{item.label.trim()}
|
||||
{item.new_tab ? " ↗" : ""}
|
||||
</span>
|
||||
))}
|
||||
<span className="meta text-[12px] ml-auto">性能指标</span>
|
||||
</div>
|
||||
<p className="meta text-[12px] mt-3">外链保存后带 target=_blank 与 rel=noopener。站内路径留在本页。</p>
|
||||
</AdminSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
HardDrive,
|
||||
Ban,
|
||||
Wrench,
|
||||
Palette,
|
||||
FileText,
|
||||
Paperclip,
|
||||
Image as ImageIcon,
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import {
|
||||
AdminExtChipEditor,
|
||||
AdminField,
|
||||
@@ -32,6 +31,9 @@ import {
|
||||
} from "@/components/admin";
|
||||
import { apiGetAdminSettings, apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import BasicIdentityPanel from "./BasicIdentityPanel";
|
||||
import BrandSeoPanel from "./BrandSeoPanel";
|
||||
import FooterLinksPanel from "./FooterLinksPanel";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
|
||||
function extsEqual(a: string[], b: string[]) {
|
||||
@@ -85,8 +87,6 @@ type SectionId =
|
||||
| "basic"
|
||||
| "content"
|
||||
| "gitimport"
|
||||
| "brand"
|
||||
| "footer"
|
||||
| "security"
|
||||
| "mail"
|
||||
| "storage"
|
||||
@@ -97,8 +97,6 @@ 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: "安全与限流" },
|
||||
{ id: "mail", label: "邮件" },
|
||||
{ id: "storage", label: "对象存储" },
|
||||
@@ -119,14 +117,14 @@ function SwitchRow({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start justify-between gap-4 flex-wrap py-4 first:pt-0 last:pb-0"
|
||||
className="flex items-start justify-between gap-3 flex-wrap py-3 first:pt-0 last:pb-0"
|
||||
style={bordered ? { borderBottom: "1px solid var(--line)" } : undefined}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[14.5px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||
<p className="text-[14px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-1 leading-relaxed">{description}</p>
|
||||
<p className="meta text-[12px] mt-0.5 leading-relaxed">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{control}</div>
|
||||
</div>
|
||||
@@ -145,7 +143,7 @@ function ComingSoonCard({
|
||||
return (
|
||||
<AdminSection title={title} description={description}>
|
||||
<div
|
||||
className="rounded-[var(--r-sm)] px-4 py-10 text-center"
|
||||
className="rounded-[var(--r-sm)] px-4 py-8 text-center"
|
||||
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
|
||||
>
|
||||
<span className="inline-flex mb-2 opacity-80" aria-hidden>
|
||||
@@ -160,21 +158,33 @@ function ComingSoonCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ComingSoonPair({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: { title: string; description: string; icon: ReactNode };
|
||||
right: { title: string; description: string; icon: ReactNode };
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-settings-grid">
|
||||
<ComingSoonCard {...left} />
|
||||
<ComingSoonCard {...right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
|
||||
const [siteName, setSiteName] = useState(initial.site_name);
|
||||
const [siteDescription, setSiteDescription] = useState(initial.site_description);
|
||||
const [live, setLive] = useState(initial);
|
||||
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
||||
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
||||
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
|
||||
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
|
||||
const [savedName, setSavedName] = useState(initial.site_name);
|
||||
const [savedDesc, setSavedDesc] = useState(initial.site_description);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [togglingReg, setTogglingReg] = useState(false);
|
||||
const [togglingComments, setTogglingComments] = useState(false);
|
||||
const [togglingMessages, setTogglingMessages] = useState(false);
|
||||
const [togglingLink, setTogglingLink] = useState(false);
|
||||
const [active, setActive] = useState<SectionId>("basic");
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const [extLimit, setExtLimit] = useState(initial.attachment_ext_limit !== false);
|
||||
const [exts, setExts] = useState<string[]>(() => [...(initial.attachment_exts || [])]);
|
||||
@@ -194,9 +204,31 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
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 gitPreview = useMemo(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(gitImportJSON) as {
|
||||
max_pages?: unknown;
|
||||
max_commits?: unknown;
|
||||
sources?: unknown;
|
||||
};
|
||||
if (!Array.isArray(parsed.sources)) return null;
|
||||
const sources = parsed.sources
|
||||
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
|
||||
.map((s) => ({
|
||||
id: typeof s.id === "string" ? s.id : "—",
|
||||
host: typeof s.host === "string" ? s.host : "*",
|
||||
}));
|
||||
return {
|
||||
maxPages: typeof parsed.max_pages === "number" ? parsed.max_pages : null,
|
||||
maxCommits: typeof parsed.max_commits === "number" ? parsed.max_commits : null,
|
||||
sources,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [gitImportJSON]);
|
||||
|
||||
const loadGitImport = async () => {
|
||||
if (gitImportLoading) return;
|
||||
@@ -227,35 +259,28 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const ids = NAV.map((n) => n.id);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible[0]?.target?.id) {
|
||||
const id = visible[0].target.id.replace(/^settings-/, "") as SectionId;
|
||||
if (ids.includes(id)) setActive(id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5] }
|
||||
);
|
||||
ids.forEach((id) => {
|
||||
const el = document.getElementById(`settings-${id}`);
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const scrollTo = (id: SectionId) => {
|
||||
setActive(id);
|
||||
document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
const selectTab = (id: SectionId) => {
|
||||
if (id !== active) {
|
||||
setActive(id);
|
||||
document.getElementById("main")?.scrollTo({ top: 0 });
|
||||
}
|
||||
if (id === "gitimport" && !gitImportLoaded && !gitImportLoading) {
|
||||
void loadGitImport();
|
||||
}
|
||||
};
|
||||
|
||||
const onTabKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
|
||||
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft" && e.key !== "Home" && e.key !== "End") return;
|
||||
e.preventDefault();
|
||||
let next = i;
|
||||
if (e.key === "ArrowRight") next = (i + 1) % NAV.length;
|
||||
else if (e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else next = NAV.length - 1;
|
||||
selectTab(NAV[next].id);
|
||||
tabRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
const validateGitImportClient = (): boolean => {
|
||||
try {
|
||||
JSON.parse(gitImportJSON);
|
||||
@@ -320,31 +345,6 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
}
|
||||
};
|
||||
|
||||
const saveIdentity = async () => {
|
||||
if (saving) return;
|
||||
const name = siteName.trim();
|
||||
if (!name) {
|
||||
toast("站点名称不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({
|
||||
site_name: name,
|
||||
site_description: siteDescription.trim(),
|
||||
});
|
||||
setSiteName(res.site_name);
|
||||
setSiteDescription(res.site_description);
|
||||
setSavedName(res.site_name);
|
||||
setSavedDesc(res.site_description);
|
||||
toast("站点信息已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRegister = async () => {
|
||||
if (togglingReg) return;
|
||||
const next = !allowRegister;
|
||||
@@ -479,80 +479,55 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
<AdminPageHeader
|
||||
icon={Globe}
|
||||
title="站点设置"
|
||||
description="配置站点名称、互动开关与后续扩展分区;主题色请到「外观」页调整"
|
||||
description="名称、Logo、关键词与页脚都在「基本信息」;主题色请到「外观」页调整"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||
<nav
|
||||
className="w-full lg:w-52 shrink-0 lg:sticky lg:top-4 flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-1 lg:pb-0"
|
||||
aria-label="设置分区"
|
||||
>
|
||||
{NAV.map((item) => {
|
||||
const isActive = active === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => scrollTo(item.id)}
|
||||
className="shrink-0 text-left px-3 py-2 rounded-[var(--r-sm)] text-[13.5px] transition-colors whitespace-nowrap"
|
||||
style={{
|
||||
background: isActive ? "var(--accent-soft)" : "transparent",
|
||||
color: isActive ? "var(--accent)" : "var(--ink-2)",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
{!item.ready && (
|
||||
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
即将
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div
|
||||
className="admin-settings-tabs"
|
||||
role="tablist"
|
||||
aria-label="设置分区"
|
||||
>
|
||||
{NAV.map((item, i) => {
|
||||
const isActive = active === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
tabRefs.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`settings-tab-${item.id}`}
|
||||
aria-selected={isActive}
|
||||
aria-controls={`settings-panel-${item.id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
className="admin-settings-tab"
|
||||
onClick={() => selectTab(item.id)}
|
||||
onKeyDown={(e) => onTabKeyDown(e, i)}
|
||||
>
|
||||
{item.label}
|
||||
{!item.ready && <span className="admin-settings-tab-soon">即将</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-4 w-full">
|
||||
<div id="settings-basic" className="scroll-mt-4">
|
||||
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
className="mb-4"
|
||||
label="站点名称"
|
||||
maxLength={32}
|
||||
value={siteName}
|
||||
onChange={setSiteName}
|
||||
placeholder="姜十三论坛"
|
||||
/>
|
||||
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
className="mb-4"
|
||||
label="站点简介"
|
||||
as="textarea"
|
||||
minHeight="96px"
|
||||
maxLength={160}
|
||||
value={siteDescription}
|
||||
onChange={setSiteDescription}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void saveIdentity()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存基本信息
|
||||
</button>
|
||||
</div>
|
||||
</AdminSection>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`settings-panel-${active}`}
|
||||
aria-labelledby={`settings-tab-${active}`}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{active === "basic" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BasicIdentityPanel initial={live} onSaved={setLive} />
|
||||
<BrandSeoPanel initial={live} onSaved={setLive} />
|
||||
<FooterLinksPanel initial={live} onSaved={setLive} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="settings-content" className="scroll-mt-4 flex flex-col gap-4">
|
||||
{active === "content" && (
|
||||
<div className="admin-settings-grid">
|
||||
<AdminSection title="内容与互动" description="控制注册、评论、消息与链接打开方式">
|
||||
<SwitchRow
|
||||
bordered
|
||||
@@ -754,29 +729,27 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
</div>
|
||||
</AdminSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="settings-gitimport" className="scroll-mt-4">
|
||||
{active === "gitimport" && (
|
||||
<div className="admin-settings-grid">
|
||||
<AdminSection
|
||||
title="提交导入"
|
||||
description="声明式匹配规则,非可执行脚本。用于将 GitHub / Gitea 等提交历史导入时间线。"
|
||||
title="适配器 JSON"
|
||||
description="声明式匹配规则,非可执行脚本。服务端只按规则拉取。"
|
||||
>
|
||||
<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">
|
||||
<label className="field-label sr-only" 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 }}
|
||||
style={{ minHeight: 360 }}
|
||||
spellCheck={false}
|
||||
value={gitImportJSON}
|
||||
onChange={(e) => {
|
||||
@@ -785,7 +758,6 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
}}
|
||||
placeholder="粘贴或编辑适配器 JSON…"
|
||||
/>
|
||||
|
||||
{gitValidateMsg ? (
|
||||
<p
|
||||
className={`mt-2 text-[12.5px] flex items-start gap-1.5 ${
|
||||
@@ -798,91 +770,158 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
||||
{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"
|
||||
description="站点图标、OG 图与结构化元数据"
|
||||
icon={<Palette size={22} />}
|
||||
/>
|
||||
<AdminSection
|
||||
title="说明与操作"
|
||||
description="用于将 GitHub / Gitea 等提交历史导入时间线"
|
||||
extra={
|
||||
<span className="meta text-[11.5px] inline-flex items-center gap-1">
|
||||
<GitBranch size={12} /> 内置模板
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p className="meta text-[12.5px] leading-relaxed mb-4">
|
||||
通过 JSON 描述 host、路径正则与 API 字段映射。改动不会执行任意代码,保存时服务端会做结构校验。
|
||||
</p>
|
||||
|
||||
{gitPreview ? (
|
||||
<div
|
||||
className="rounded-[var(--r-sm)] px-3.5 py-3 mb-4"
|
||||
style={{ background: "var(--panel-2)" }}
|
||||
>
|
||||
<p className="text-[12.5px] font-medium mb-2" style={{ color: "var(--ink)" }}>
|
||||
当前规则摘要
|
||||
</p>
|
||||
<p className="meta text-[12px] mb-2">
|
||||
最多 {gitPreview.maxPages ?? "—"} 页 · {gitPreview.maxCommits ?? "—"} 条提交
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{gitPreview.sources.map((s) => (
|
||||
<li
|
||||
key={`${s.id}-${s.host}`}
|
||||
className="text-[12.5px] flex items-center gap-2"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
<GitBranch size={12} className="shrink-0" style={{ color: "var(--ink-3)" }} />
|
||||
<span className="font-medium" style={{ color: "var(--ink)" }}>
|
||||
{s.id}
|
||||
</span>
|
||||
<span className="meta">{s.host}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : gitImportLoaded ? (
|
||||
<p className="meta text-[12.5px] mb-4">JSON 无法解析时不显示摘要,保存前请先校验语法。</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</AdminSection>
|
||||
</div>
|
||||
<div id="settings-footer" className="scroll-mt-4">
|
||||
<ComingSoonCard
|
||||
title="页脚与合规"
|
||||
description="备案号、版权声明与合规链接"
|
||||
icon={<FileText size={22} />}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-security" className="scroll-mt-4">
|
||||
<ComingSoonCard
|
||||
title="安全与限流"
|
||||
description="登录/注册速率与发帖冷静期等"
|
||||
icon={<Shield size={22} />}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-mail" className="scroll-mt-4">
|
||||
<ComingSoonCard title="邮件" description="SMTP 与通知邮件模板" icon={<Mail size={22} />} />
|
||||
</div>
|
||||
<div id="settings-storage" className="scroll-mt-4">
|
||||
<ComingSoonCard
|
||||
title="对象存储"
|
||||
description="附件与头像的云端存储配置"
|
||||
icon={<HardDrive size={22} />}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-words" className="scroll-mt-4">
|
||||
<ComingSoonCard title="敏感词" description="内容过滤词库与命中策略" icon={<Ban size={22} />} />
|
||||
</div>
|
||||
<div id="settings-maintenance" className="scroll-mt-4">
|
||||
<ComingSoonCard
|
||||
title="系统维护"
|
||||
description="维护模式、缓存与诊断工具"
|
||||
icon={<Wrench size={22} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === "security" && (
|
||||
<ComingSoonPair
|
||||
left={{
|
||||
title: "登录与注册限流",
|
||||
description: "按 IP / 账号的速率与锁定策略",
|
||||
icon: <Shield size={22} />,
|
||||
}}
|
||||
right={{
|
||||
title: "发帖冷静期",
|
||||
description: "新用户发帖等待时长与例外规则",
|
||||
icon: <UserPlus size={22} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{active === "mail" && (
|
||||
<ComingSoonPair
|
||||
left={{
|
||||
title: "SMTP 发信",
|
||||
description: "服务器、端口、加密与发件人身份",
|
||||
icon: <Mail size={22} />,
|
||||
}}
|
||||
right={{
|
||||
title: "通知模板",
|
||||
description: "注册验证、重置密码与系统通知",
|
||||
icon: <FileText size={22} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{active === "storage" && (
|
||||
<ComingSoonPair
|
||||
left={{
|
||||
title: "附件存储",
|
||||
description: "对象存储桶、路径前缀与访问策略",
|
||||
icon: <HardDrive size={22} />,
|
||||
}}
|
||||
right={{
|
||||
title: "头像存储",
|
||||
description: "头像目录、体积上限与 CDN 域名",
|
||||
icon: <ImageIcon size={22} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{active === "words" && (
|
||||
<ComingSoonPair
|
||||
left={{
|
||||
title: "过滤词库",
|
||||
description: "敏感词列表、导入导出与分组",
|
||||
icon: <Ban size={22} />,
|
||||
}}
|
||||
right={{
|
||||
title: "命中策略",
|
||||
description: "拦截、替换或送审的处理方式",
|
||||
icon: <Shield size={22} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{active === "maintenance" && (
|
||||
<ComingSoonPair
|
||||
left={{
|
||||
title: "维护模式",
|
||||
description: "全站只读、公告文案与白名单",
|
||||
icon: <Wrench size={22} />,
|
||||
}}
|
||||
right={{
|
||||
title: "缓存与诊断",
|
||||
description: "缓存清理、健康检查与日志下载",
|
||||
icon: <HardDrive size={22} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
Reference in New Issue
Block a user