diff --git a/backend/handler/setting.go b/backend/handler/setting.go index a6243d6..f998acf 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -41,6 +41,8 @@ type updateSettingsRequest struct { AttachmentMaxMB *int `json:"attachment_max_mb"` AttachmentMaxCount *int `json:"attachment_max_count"` ImageMaxMB *int `json:"image_max_mb"` + CustomCSS *string `json:"custom_css"` + CustomJS *string `json:"custom_js"` TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播 } @@ -66,6 +68,8 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "attachment_max_mb": saved.AttachmentMaxMB, "attachment_max_count": saved.AttachmentMaxCount, "image_max_mb": saved.ImageMaxMB, + "custom_css": saved.CustomCSS, + "custom_js": saved.CustomJS, } } @@ -76,7 +80,8 @@ func (req *updateSettingsRequest) hasAny() bool { req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil || req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil || req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil || - req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.TimelineGitImport != nil + req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.CustomCSS != nil || + req.CustomJS != nil || req.TimelineGitImport != nil } // AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings) @@ -264,6 +269,26 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.CustomCSS != nil { + if err := h.Setting.SetCustomCSS(*req.CustomCSS); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 CSS 过长或含非法闭合标签"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } + if req.CustomJS != nil { + if err := h.Setting.SetCustomJS(*req.CustomJS); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 JS 过长或含非法闭合标签"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } var savedAdapter string if req.TimelineGitImport != nil { diff --git a/backend/service/custom_code_settings_test.go b/backend/service/custom_code_settings_test.go new file mode 100644 index 0000000..2b42991 --- /dev/null +++ b/backend/service/custom_code_settings_test.go @@ -0,0 +1,19 @@ +package service + +import "testing" + +func TestSanitizeCustomSnippet(t *testing.T) { + got, err := sanitizeCustomSnippet(" body{color:red} ", "b", "b", " maxRunes { + return "", ErrInvalidSiteSetting + } + if closer != "" && strings.Contains(strings.ToLower(s), closer) { + return "", ErrInvalidSiteSetting + } + return s, nil +} + +// CustomCSS 全站自定义 CSS。缺行/空=无。 +func (s *SettingService) CustomCSS() (string, error) { + v, found, err := s.getValue(SettingKeyCustomCSS) + if err != nil || !found { + return "", err + } + out, nerr := sanitizeCustomSnippet(v, "
diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx index 7e4794f..d7c0fe7 100644 --- a/frontend/app/admin/appearance/AppearanceAdmin.tsx +++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx @@ -12,6 +12,7 @@ import { RotateCcw, Pipette, Sparkles, + FileCode2, } from "lucide-react"; import { AdminPageHeader, @@ -20,7 +21,7 @@ import { AdminStepper, AdminSwitch, } from "@/components/admin"; -import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; +import { apiUpdateSiteSettings, type PublicSettings, type UpdateSiteSettingsBody } from "@/lib/api"; import { toast } from "@/lib/toast"; import { ACCENT_PRESETS, @@ -30,14 +31,27 @@ import { findPreset, normalizeHex, } from "@/lib/theme"; +import { + MAX_CUSTOM_CSS_CHARS, + MAX_CUSTOM_JS_CHARS, + SAMPLE_CUSTOM_CSS, + SAMPLE_CUSTOM_JS, + applyCustomCSS, + clipCustomSnippet, +} from "@/lib/customCode"; -// 后台「外观设置」:主题色、代码块折叠与界面动画。 -// 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。 +// 后台「外观设置」:主题色、自定义 CSS/JS、代码块折叠与界面动画。 +// 主题色 / 自定义 CSS 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。 +// 自定义 JS 不在编辑时执行,保存后整页加载生效。 export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) { const router = useRouter(); const initialAccent = initial.accent || ""; const [savedHex, setSavedHex] = useState(initialAccent); const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT); + const [savedCustomCSS, setSavedCustomCSS] = useState(initial.custom_css || ""); + const [customCSS, setCustomCSS] = useState(initial.custom_css || SAMPLE_CUSTOM_CSS); + const [savedCustomJS, setSavedCustomJS] = useState(initial.custom_js || ""); + const [customJS, setCustomJS] = useState(initial.custom_js || SAMPLE_CUSTOM_JS); const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false); const [foldLines, setFoldLines] = useState( Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5 @@ -54,6 +68,8 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } const [saving, setSaving] = useState(false); const savedRef = useRef(savedHex); savedRef.current = savedHex; + const savedCSSRef = useRef(savedCustomCSS); + savedCSSRef.current = savedCustomCSS; const normalized = useMemo(() => normalizeHex(draft), [draft]); const pair = useMemo( @@ -62,7 +78,12 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } ); const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]); const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : ""; - const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); + const accentDirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); + const cssIsGuide = !savedCustomCSS && customCSS === SAMPLE_CUSTOM_CSS; + const jsIsGuide = !savedCustomJS && customJS === SAMPLE_CUSTOM_JS; + const cssDirty = customCSS !== savedCustomCSS && !cssIsGuide; + const jsDirty = customJS !== savedCustomJS && !jsIsGuide; + const dirty = accentDirty || cssDirty || jsDirty; const invalid = draft.trim() !== "" && !normalized; useEffect(() => { @@ -72,19 +93,35 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } }; }, [normalized]); + useEffect(() => { + applyCustomCSS(customCSS, true); + return () => { + applyCustomCSS(savedCSSRef.current, false); + }; + }, [customCSS]); + const handleSave = async () => { if (!normalized || saving) return; + const body: UpdateSiteSettingsBody = {}; + if (accentDirty) body.accent = submitHex; + if (cssDirty) body.custom_css = customCSS; + if (jsDirty) body.custom_js = customJS; + if (Object.keys(body).length === 0) return; setSaving(true); try { - const res = await apiUpdateSiteSettings({ accent: submitHex }); + const res = await apiUpdateSiteSettings(body); const next = (res.accent || "").toLowerCase(); setSavedHex(next); + setSavedCustomCSS(res.custom_css || ""); + setSavedCustomJS(res.custom_js || ""); applyAccentTheme(next || null); + applyCustomCSS(res.custom_css || "", false); router.refresh(); - toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色"); + toast(jsDirty ? "外观已保存;自定义 JS 将在整页刷新后执行" : "外观已保存并对全站生效"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败", "error"); applyAccentTheme(savedRef.current || null); + applyCustomCSS(savedCSSRef.current, false); } finally { setSaving(false); } @@ -92,7 +129,10 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } const discard = () => { setDraft(savedHex || DEFAULT_ACCENT); + setCustomCSS(savedCustomCSS || SAMPLE_CUSTOM_CSS); + setCustomJS(savedCustomJS || SAMPLE_CUSTOM_JS); applyAccentTheme(savedHex || null); + applyCustomCSS(savedCustomCSS, true); }; const toggleCodeFold = async () => { @@ -167,7 +207,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
@@ -296,7 +336,10 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
-
- - - - 选中的板块卡片 +
+
+ + + + 选中的板块卡片 +
@@ -470,6 +515,85 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
+ + 自定义代码 + + } + description="注入全站 HTML。空配置时先显示注释示例(不会自动写入);保存后才对访客生效。勿包含 或 。" + className="mb-6" + > +
+
+
+ + {customCSS !== SAMPLE_CUSTOM_CSS && ( + + )} +
+

+ 覆盖令牌或组件类。本页即时预览;去掉注释后的规则才会改变外观。上限{" "} + {MAX_CUSTOM_CSS_CHARS.toLocaleString()} 字。 +

+