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()} 字。
+
+
+
+
+
+ {customJS !== SAMPLE_CUSTOM_JS && (
+
+ )}
+
+
+ 仅站长/超管可写。不在编辑时运行,保存后需整页刷新。上限{" "}
+ {MAX_CUSTOM_JS_CHARS.toLocaleString()} 字。
+
+
+
+
+
{dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"}
diff --git a/frontend/app/admin/economy/EconomyClient.tsx b/frontend/app/admin/economy/EconomyClient.tsx
index ceec513..b163b75 100644
--- a/frontend/app/admin/economy/EconomyClient.tsx
+++ b/frontend/app/admin/economy/EconomyClient.tsx
@@ -157,6 +157,174 @@ function todayDelta(items: PointLedgerItem[]) {
return { granted, spent };
}
+type FlowHealth = "low" | "ok" | "hot";
+
+function stockFromStats(stats: EconomyStats) {
+ let granted = 0;
+ let spent = 0;
+ const series = stats.delta_series || [];
+ const seriesHas = series.some((d) => (Number(d.granted) || 0) > 0 || (Number(d.spent) || 0) > 0);
+ if (seriesHas) {
+ for (const d of series) {
+ granted += Math.max(0, Number(d.granted) || 0);
+ spent += Math.max(0, Number(d.spent) || 0);
+ }
+ } else {
+ for (const it of stats.recent_ledger || []) {
+ const n = Number(it.delta) || 0;
+ if (n > 0) granted += n;
+ else if (n < 0) spent += -n;
+ }
+ }
+ let frozen = 0;
+ for (const it of stats.recent_ledger || []) {
+ if (it.reason === "bounty_escrow" && (Number(it.delta) || 0) < 0) {
+ frozen += Math.abs(Number(it.delta));
+ }
+ }
+ if ((stats.escrowed_points || 0) > frozen) {
+ frozen = stats.escrowed_points;
+ }
+ const circulating = granted - spent;
+ const empty = granted <= 0 && spent <= 0 && frozen <= 0;
+ const rate = granted > 0 ? Math.round((spent / granted) * 100) : 0;
+ let health: FlowHealth = "ok";
+ if (rate < 20) health = "low";
+ else if (rate > 60) health = "hot";
+ return { granted, spent, circulating, frozen, empty, rate, health };
+}
+
+const HEALTH_COPY: Record = {
+ low: "积分消耗偏低,可考虑增加积分消耗场景",
+ ok: "积分经济体健康",
+ hot: "积分消耗较快,注意通胀",
+};
+
+const HEALTH_LABEL: Record = {
+ low: "偏低",
+ ok: "健康",
+ hot: "偏快",
+};
+
+function StockOverviewCard({ stats }: { stats: EconomyStats }) {
+ const stock = useMemo(() => stockFromStats(stats), [stats]);
+ const { granted, spent, circulating, frozen, empty, rate, health } = stock;
+ const tip = HEALTH_COPY[health];
+ const showFrozen = frozen > 0;
+
+ let circBarPct = granted > 0 ? Math.max(0, (circulating / granted) * 100) : 0;
+ const spentLabelPct = granted > 0 ? Math.min(100, (spent / granted) * 100) : 0;
+ const circLabelPct = granted > 0 ? Math.max(0, Math.round((Math.max(0, circulating) / granted) * 100)) : 0;
+ const netSpent = Math.max(0, spent - frozen);
+ let netSpentPct = granted > 0 ? (netSpent / granted) * 100 : 0;
+ let frozenPct = granted > 0 && showFrozen ? (frozen / granted) * 100 : 0;
+ const segSum = circBarPct + netSpentPct + frozenPct;
+ if (segSum > 100 && segSum > 0) {
+ const scale = 100 / segSum;
+ circBarPct *= scale;
+ netSpentPct *= scale;
+ frozenPct *= scale;
+ }
+
+ return (
+
+
+
+
+ 积分存量概览
+
+
基于全站累计流水计算
+
+ {!empty ? (
+
+ 流通率 {granted > 0 ? `${rate}%` : "—"} · {HEALTH_LABEL[health]}
+
+ ) : null}
+
+
+ {empty ? (
+
+ ) : (
+
+
+
+ 流通中积分
+
+
+ {fmt(circulating)}
+
+
+
+
+
+ {circBarPct > 0 ? (
+
+ ) : null}
+ {netSpentPct > 0 ? (
+
+ ) : null}
+ {frozenPct > 0 ? (
+
+ ) : null}
+
+
+ 流通中 {circLabelPct}%
+ 消耗 {Math.round(spentLabelPct)}%
+ {showFrozen ? (
+ 托管 {fmt(frozen)}
+ ) : null}
+
+
+
+
+
+
+ 累计发放{" "}
+
+ {fmt(granted)}
+
+
+
+ 累计消耗{" "}
+
+ {fmt(spent)}
+
+
+ {showFrozen ? (
+
+ 托管冻结{" "}
+
+ {fmt(frozen)}
+
+
+ ) : null}
+
+ {showFrozen ? (
+
+ 托管的 {fmt(frozen)} 积分处于冻结态,悬赏采纳或退回后释放
+
+ ) : null}
+
+
+ )}
+
+ );
+}
+
function LedgerUserLink({ user, compact }: { user: LedgerParty; compact?: boolean }) {
const name = partyName(user);
return (
@@ -207,7 +375,7 @@ function RecentLedgerRow({ it }: { it: PointLedgerItem }) {
)}
{payee ? (
-
+
@@ -306,7 +474,7 @@ export default function EconomyClient({ stats }: { stats: EconomyStats }) {
/>
-
+
{checkinSeries.length === 0 ? (
暂无签到数据
@@ -343,48 +511,52 @@ export default function EconomyClient({ stats }: { stats: EconomyStats }) {
)}
-
-
-
-
- 最近流水
+
+
+
+
+
+ 今日 发放{" "}
+
+ +{fmt(today.granted)}
+
+ {" · "}
+ 消耗{" "}
+
+ -{fmt(today.spent)}
+
-
-
-
- 今日 发放{" "}
-
- +{fmt(today.granted)}
-
- {" · "}
- 消耗{" "}
-
- -{fmt(today.spent)}
-
-
- {ledger.length === 0 ? (
- 暂无流水
- ) : filtered.length === 0 ? (
- 该分类下暂无流水
- ) : (
-
- {filtered.map((it) => (
-
- ))}
-
- )}
-
+
+ {ledger.length === 0 ? (
+
暂无流水
+ ) : filtered.length === 0 ? (
+
该分类下暂无流水
+ ) : (
+
+ {filtered.map((it) => (
+
+ ))}
+
+ )}
+
+
+
用户可在{" "}
diff --git a/frontend/app/announcements/page.tsx b/frontend/app/announcements/page.tsx
index 0c3d174..d096337 100644
--- a/frontend/app/announcements/page.tsx
+++ b/frontend/app/announcements/page.tsx
@@ -41,7 +41,7 @@ function FeaturedAnnouncement({ a }: { a: Announcement }) {
return (
diff --git a/frontend/app/chat/ChatShell.tsx b/frontend/app/chat/ChatShell.tsx
index 886a658..e3dae59 100644
--- a/frontend/app/chat/ChatShell.tsx
+++ b/frontend/app/chat/ChatShell.tsx
@@ -419,7 +419,7 @@ export default function ChatShell({