From 5f7193042f1096fbeb9d978f54bcefac7fb63d47 Mon Sep 17 00:00:00 2001 From: freefire Date: Fri, 18 Sep 2026 04:16:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=96=E8=A7=82=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=20CSS/JS=EF=BC=8C=E5=B9=B6=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E5=8F=91=E5=B8=96=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=B8=8E?= =?UTF-8?q?=E7=A7=AF=E5=88=86=E5=AD=98=E9=87=8F=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。 Co-authored-by: Cursor --- backend/handler/setting.go | 27 +- backend/service/custom_code_settings_test.go | 19 + backend/service/setting.go | 83 + frontend/app/about/page.tsx | 2 +- .../app/admin/appearance/AppearanceAdmin.tsx | 156 +- frontend/app/admin/economy/EconomyClient.tsx | 256 ++- frontend/app/announcements/page.tsx | 4 +- frontend/app/chat/ChatShell.tsx | 6 +- frontend/app/chat/[id]/ChatRoomClient.tsx | 12 +- frontend/app/compose/layout.tsx | 11 +- frontend/app/compose/page.tsx | 14 +- frontend/app/globals.css | 712 ++++++-- frontend/app/layout.tsx | 14 +- .../app/notifications/NotificationsClient.tsx | 2 +- frontend/app/post/[id]/edit/layout.tsx | 22 + frontend/app/post/[id]/edit/page.tsx | 22 +- frontend/app/post/[id]/page.tsx | 2 +- frontend/components/BoardPicker.tsx | 20 +- frontend/components/ComposeAttachments.tsx | 6 +- frontend/components/ConfirmDialog.tsx | 2 +- frontend/components/Header.tsx | 8 +- frontend/components/MarkdownEditor.tsx | 14 +- frontend/components/MobilePostBar.tsx | 2 +- frontend/components/Modal.tsx | 47 +- frontend/components/NotificationBell.tsx | 2 +- frontend/components/PinnedPosts.tsx | 2 +- frontend/components/PostActions.tsx | 2 +- frontend/components/PostComposer.tsx | 1490 ++++++++++------- frontend/components/PostRow.tsx | 2 +- frontend/components/SiteChrome.tsx | 9 +- frontend/components/StaffUserMenu.tsx | 2 +- frontend/components/ThemeSync.tsx | 48 +- .../components/admin/AdminSearchField.tsx | 2 +- frontend/components/admin/AdminShell.tsx | 2 +- frontend/components/admin/charts.tsx | 14 +- frontend/components/home/CommunityPanels.tsx | 2 +- frontend/lib/api.ts | 10 + frontend/lib/customCode.ts | 100 ++ frontend/lib/postMeta.ts | 8 +- frontend/lib/realtime.ts | 2 + frontend/lib/theme.ts | 10 +- 41 files changed, 2297 insertions(+), 873 deletions(-) create mode 100644 backend/service/custom_code_settings_test.go create mode 100644 frontend/app/post/[id]/edit/layout.tsx create mode 100644 frontend/lib/customCode.ts 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()} 字。 +

+