feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -677,10 +677,14 @@ export interface PublicSettings {
|
||||
attachment_max_count: number;
|
||||
/** 正文插图上限 MB;缺省 5 */
|
||||
image_max_mb: number;
|
||||
/** 全站自定义 CSS;空串=无 */
|
||||
custom_css: string;
|
||||
/** 全站自定义 JS;空串=无(保存后整页加载生效) */
|
||||
custom_js: string;
|
||||
/** 前台背景图 URL;空=默认画布 */
|
||||
bg_site_url: string;
|
||||
/** 前台铺放模式 */
|
||||
bg_site_mode: string;
|
||||
/** 后台背景图 URL;空=默认画布 */
|
||||
bg_admin_url: string;
|
||||
/** 后台铺放模式 */
|
||||
bg_admin_mode: string;
|
||||
}
|
||||
|
||||
/** 与后端 DefaultAttachmentExts 保持同步 */
|
||||
@@ -715,8 +719,10 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
attachment_max_mb: 20,
|
||||
attachment_max_count: 10,
|
||||
image_max_mb: 5,
|
||||
custom_css: "",
|
||||
custom_js: "",
|
||||
bg_site_url: "",
|
||||
bg_site_mode: "cover",
|
||||
bg_admin_url: "",
|
||||
bg_admin_mode: "cover",
|
||||
};
|
||||
|
||||
function normalizeAttachmentExts(raw: unknown): string[] {
|
||||
@@ -786,8 +792,10 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
attachment_max_mb: maxMB,
|
||||
attachment_max_count: maxCount,
|
||||
image_max_mb: imageMB,
|
||||
custom_css: typeof data?.custom_css === "string" ? data.custom_css : "",
|
||||
custom_js: typeof data?.custom_js === "string" ? data.custom_js : "",
|
||||
bg_site_url: typeof data?.bg_site_url === "string" ? data.bg_site_url : "",
|
||||
bg_site_mode: typeof data?.bg_site_mode === "string" ? data.bg_site_mode : "cover",
|
||||
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
|
||||
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1893,6 +1901,8 @@ export interface AdminUsersQuery {
|
||||
q?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
function adminUsersParams(query: AdminUsersQuery): URLSearchParams {
|
||||
@@ -1902,6 +1912,9 @@ function adminUsersParams(query: AdminUsersQuery): URLSearchParams {
|
||||
if (query.q) params.set("q", query.q);
|
||||
if (query.role) params.set("role", query.role);
|
||||
if (query.status) params.set("status", query.status);
|
||||
if (query.sort && query.sort !== "id") params.set("sort", query.sort);
|
||||
if (query.order === "asc") params.set("order", "asc");
|
||||
else if (query.sort && query.sort !== "id") params.set("order", query.order || "desc");
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -2144,8 +2157,10 @@ export type UpdateSiteSettingsBody = {
|
||||
attachment_max_mb?: number;
|
||||
attachment_max_count?: number;
|
||||
image_max_mb?: number;
|
||||
custom_css?: string;
|
||||
custom_js?: string;
|
||||
bg_site_url?: string;
|
||||
bg_site_mode?: string;
|
||||
bg_admin_url?: string;
|
||||
bg_admin_mode?: string;
|
||||
timeline_git_import?: string;
|
||||
};
|
||||
|
||||
@@ -2161,6 +2176,24 @@ export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promi
|
||||
return normalizePublicSettings(data as Partial<PublicSettings>);
|
||||
}
|
||||
|
||||
/** 上传站点背景并立即绑定前台或后台 */
|
||||
export async function apiUploadSiteBackground(
|
||||
surface: "site" | "admin",
|
||||
file: File
|
||||
): Promise<PublicSettings> {
|
||||
const form = new FormData();
|
||||
form.append("surface", surface);
|
||||
form.append("file", file, file.name || "background.jpg");
|
||||
const res = await fetchWithRefresh("/api/admin/upload/background", {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "上传背景图失败");
|
||||
return normalizePublicSettings(data as Partial<PublicSettings>);
|
||||
}
|
||||
|
||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||
|
||||
export async function apiListBoards(): Promise<{ boards: Board[] }> {
|
||||
|
||||
173
frontend/lib/bg.ts
Normal file
173
frontend/lib/bg.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
// 站点背景图(前台 / 后台分轨):仅接受本站 /uploads/backgrounds/ 下的文件。
|
||||
// 铺放模式映射到 CSS background-*,真正绘制在 body::before 固定层上,
|
||||
// 避免 background-attachment:fixed 在移动端掉帧。
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export type BgSurface = "site" | "admin";
|
||||
|
||||
export type BgMode =
|
||||
| "cover"
|
||||
| "contain"
|
||||
| "repeat"
|
||||
| "repeat-x"
|
||||
| "repeat-y"
|
||||
| "stretch"
|
||||
| "center";
|
||||
|
||||
export const DEFAULT_BG_MODE: BgMode = "cover";
|
||||
|
||||
export const BG_MODE_OPTIONS: { key: BgMode; label: string; hint: string }[] = [
|
||||
{ key: "cover", label: "覆盖铺满", hint: "裁切以铺满视口" },
|
||||
{ key: "contain", label: "完整显示", hint: "完整放入,两侧留底色" },
|
||||
{ key: "stretch", label: "拉伸填满", hint: "变形拉伸至视口" },
|
||||
{ key: "repeat", label: "平铺", hint: "按原尺寸重复铺满" },
|
||||
{ key: "repeat-x", label: "横向平铺", hint: "只沿水平方向重复" },
|
||||
{ key: "repeat-y", label: "纵向平铺", hint: "只沿垂直方向重复" },
|
||||
{ key: "center", label: "居中原图", hint: "不缩放,居中显示" },
|
||||
];
|
||||
|
||||
export interface SiteBgConfig {
|
||||
bg_site_url: string;
|
||||
bg_site_mode: BgMode;
|
||||
bg_admin_url: string;
|
||||
bg_admin_mode: BgMode;
|
||||
}
|
||||
|
||||
export type SiteBgInput = {
|
||||
bg_site_url?: string | null;
|
||||
bg_site_mode?: string | null;
|
||||
bg_admin_url?: string | null;
|
||||
bg_admin_mode?: string | null;
|
||||
};
|
||||
|
||||
const BG_URL_RE = /^\/uploads\/backgrounds\/[0-9a-f]{32}\.(jpg|jpeg|png|webp)$/;
|
||||
const MODE_SET = new Set<BgMode>(BG_MODE_OPTIONS.map((o) => o.key));
|
||||
|
||||
const SSR_STYLE_ID = "j13-bg-vars";
|
||||
const RUNTIME_STYLE_ID = "j13-bg-runtime";
|
||||
|
||||
export function normalizeBgURL(raw: string | null | undefined): string {
|
||||
const u = (raw || "").trim().toLowerCase();
|
||||
return BG_URL_RE.test(u) ? u : "";
|
||||
}
|
||||
|
||||
export function normalizeBgMode(raw: string | null | undefined): BgMode {
|
||||
const m = (raw || "").trim().toLowerCase() as BgMode;
|
||||
return MODE_SET.has(m) ? m : DEFAULT_BG_MODE;
|
||||
}
|
||||
|
||||
export function bgConfigFromSettings(s: SiteBgInput | null | undefined): SiteBgConfig {
|
||||
return {
|
||||
bg_site_url: normalizeBgURL(s?.bg_site_url),
|
||||
bg_site_mode: normalizeBgMode(s?.bg_site_mode),
|
||||
bg_admin_url: normalizeBgURL(s?.bg_admin_url),
|
||||
bg_admin_mode: normalizeBgMode(s?.bg_admin_mode),
|
||||
};
|
||||
}
|
||||
|
||||
function modeCSS(mode: BgMode): { size: string; repeat: string; position: string } {
|
||||
switch (mode) {
|
||||
case "contain":
|
||||
return { size: "contain", repeat: "no-repeat", position: "center" };
|
||||
case "repeat":
|
||||
return { size: "auto", repeat: "repeat", position: "0 0" };
|
||||
case "repeat-x":
|
||||
return { size: "auto", repeat: "repeat-x", position: "center top" };
|
||||
case "repeat-y":
|
||||
return { size: "auto", repeat: "repeat-y", position: "center top" };
|
||||
case "stretch":
|
||||
return { size: "100% 100%", repeat: "no-repeat", position: "center" };
|
||||
case "center":
|
||||
return { size: "auto", repeat: "no-repeat", position: "center" };
|
||||
default:
|
||||
return { size: "cover", repeat: "no-repeat", position: "center" };
|
||||
}
|
||||
}
|
||||
|
||||
function cssUrl(url: string): string {
|
||||
// 已由 regex 锁死路径,仍去掉引号,避免意外打断 url("…")
|
||||
return `url("${url.replace(/["'\\]/g, "")}")`;
|
||||
}
|
||||
|
||||
function surfaceVars(prefix: "site" | "admin", url: string, mode: BgMode): string {
|
||||
const m = modeCSS(mode);
|
||||
const image = url ? cssUrl(url) : "none";
|
||||
return [
|
||||
`--j13-bg-${prefix}-image:${image};`,
|
||||
`--j13-bg-${prefix}-size:${m.size};`,
|
||||
`--j13-bg-${prefix}-repeat:${m.repeat};`,
|
||||
`--j13-bg-${prefix}-position:${m.position};`,
|
||||
].join("");
|
||||
}
|
||||
|
||||
/** SSR 内联:仅在至少一侧配置了图时输出 */
|
||||
export function bgStyleCSS(cfg: SiteBgConfig): string {
|
||||
if (!cfg.bg_site_url && !cfg.bg_admin_url) return "";
|
||||
return (
|
||||
"html{" +
|
||||
surfaceVars("site", cfg.bg_site_url, cfg.bg_site_mode) +
|
||||
surfaceVars("admin", cfg.bg_admin_url, cfg.bg_admin_mode) +
|
||||
"}"
|
||||
);
|
||||
}
|
||||
|
||||
function ensureRuntimeStyle(): HTMLStyleElement {
|
||||
let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (el) return el;
|
||||
el = document.createElement("style");
|
||||
el.id = RUNTIME_STYLE_ID;
|
||||
el.setAttribute("data-bg-source", "runtime");
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function sameConfig(a: SiteBgConfig, b: SiteBgConfig): boolean {
|
||||
return (
|
||||
a.bg_site_url === b.bg_site_url &&
|
||||
a.bg_site_mode === b.bg_site_mode &&
|
||||
a.bg_admin_url === b.bg_admin_url &&
|
||||
a.bg_admin_mode === b.bg_admin_mode
|
||||
);
|
||||
}
|
||||
|
||||
let lastApplied: SiteBgConfig | null = null;
|
||||
|
||||
/** 客户端热替换背景;禁止改写 SSR 节点 */
|
||||
export function applySiteBackground(input: SiteBgInput | null): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const cfg = bgConfigFromSettings(input);
|
||||
if (lastApplied && sameConfig(lastApplied, cfg)) return;
|
||||
lastApplied = cfg;
|
||||
|
||||
const html = document.documentElement;
|
||||
html.classList.toggle("j13-has-site-bg", !!cfg.bg_site_url);
|
||||
html.classList.toggle("j13-has-admin-bg", !!cfg.bg_admin_url);
|
||||
|
||||
const css = bgStyleCSS(cfg);
|
||||
if (!css) {
|
||||
const ssr = document.getElementById(SSR_STYLE_ID);
|
||||
if (ssr && ssr.textContent && ssr.textContent.trim()) {
|
||||
ensureRuntimeStyle().textContent =
|
||||
"html{" +
|
||||
surfaceVars("site", "", DEFAULT_BG_MODE) +
|
||||
surfaceVars("admin", "", DEFAULT_BG_MODE) +
|
||||
"}";
|
||||
} else {
|
||||
document.getElementById(RUNTIME_STYLE_ID)?.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
ensureRuntimeStyle().textContent = css;
|
||||
}
|
||||
|
||||
export function previewBoxStyle(url: string, mode: BgMode): CSSProperties {
|
||||
const m = modeCSS(mode);
|
||||
return {
|
||||
backgroundColor: "var(--bg)",
|
||||
backgroundImage: url ? cssUrl(url) : "none",
|
||||
backgroundSize: m.size,
|
||||
backgroundRepeat: m.repeat,
|
||||
backgroundPosition: m.position,
|
||||
};
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// 全站自定义 CSS:SSR <style id="j13-custom-css"> + 客户端 #j13-custom-css-runtime。
|
||||
// 禁止对 SSR 节点 remove/改写;预览时 disable SSR,由 runtime 作为唯一源。
|
||||
|
||||
export const MAX_CUSTOM_CSS_CHARS = 48000;
|
||||
export const MAX_CUSTOM_JS_CHARS = 48000;
|
||||
|
||||
/** 空配置时展示的注释模板:保存前不生效,全部注释故误保存也无副作用 */
|
||||
export const SAMPLE_CUSTOM_CSS = `/* 姜十三 · 自定义 CSS 示例
|
||||
* 覆盖 globals.css 令牌或组件类。保存后对全站访客生效。
|
||||
* 勿写入 </style>
|
||||
*
|
||||
* 常用令牌:
|
||||
* --bg / --panel / --ink / --accent
|
||||
* --bg-halo 顶部光晕色(建议 ≤ 0.04)
|
||||
* --bg-noise-opacity 噪点层透明度
|
||||
* --r / --shadow-sm 卡片圆角与阴影
|
||||
*/
|
||||
|
||||
/* 改底色与光晕(浅 / 暗成对改)
|
||||
:root {
|
||||
--bg: #f7f7f5;
|
||||
--bg-halo: rgba(7, 192, 95, 0.04);
|
||||
--bg-noise-opacity: 0.03;
|
||||
--panel: #ffffff;
|
||||
--r: 12px;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.dark {
|
||||
--bg: #101211;
|
||||
--bg-halo: rgba(7, 192, 95, 0.03);
|
||||
--bg-noise-opacity: 0.04;
|
||||
}
|
||||
*/
|
||||
|
||||
/* 关掉光晕和噪点
|
||||
body::before,
|
||||
body::after {
|
||||
display: none;
|
||||
}
|
||||
*/
|
||||
|
||||
/* 隐藏页脚
|
||||
footer { display: none; }
|
||||
*/
|
||||
`;
|
||||
|
||||
export const SAMPLE_CUSTOM_JS = `// 姜十三 · 自定义 JS 示例
|
||||
// 仅整页加载执行一次(F5 / 首次进入),站内软导航不会重跑。
|
||||
// 勿写入 </script>
|
||||
//
|
||||
// 给 <html> 打标记,便于 CSS 写 html[data-custom] .panel { ... }
|
||||
// document.documentElement.dataset.custom = "1";
|
||||
//
|
||||
// 等 DOM 就绪后再改节点
|
||||
// document.addEventListener("DOMContentLoaded", function () {
|
||||
// // document.querySelector("footer")?.remove();
|
||||
// });
|
||||
`;
|
||||
|
||||
const SSR_STYLE_ID = "j13-custom-css";
|
||||
const RUNTIME_STYLE_ID = "j13-custom-css-runtime";
|
||||
|
||||
export function clipCustomSnippet(raw: string, max: number): string {
|
||||
const s = (raw || "").replace(/\u0000/g, "");
|
||||
if (s.length <= max) return s;
|
||||
return [...s].slice(0, max).join("");
|
||||
}
|
||||
|
||||
function ensureRuntimeStyle(): HTMLStyleElement {
|
||||
let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null;
|
||||
if (el) return el;
|
||||
el = document.createElement("style");
|
||||
el.id = RUNTIME_STYLE_ID;
|
||||
el.setAttribute("data-custom-css-source", "runtime");
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function removeRuntimeStyle(): void {
|
||||
document.getElementById(RUNTIME_STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
function ssrStyle(): HTMLStyleElement | null {
|
||||
return document.getElementById(SSR_STYLE_ID) as HTMLStyleElement | null;
|
||||
}
|
||||
|
||||
/** 将自定义 CSS 应用到当前文档。preview=true 时忽略 SSR 节点(外观页草稿)。 */
|
||||
export function applyCustomCSS(css: string, preview = false): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const text = clipCustomSnippet(css, MAX_CUSTOM_CSS_CHARS).trim();
|
||||
const ssr = ssrStyle();
|
||||
if (ssr) ssr.disabled = preview;
|
||||
|
||||
if (!text) {
|
||||
removeRuntimeStyle();
|
||||
if (!preview && ssr) ssr.disabled = false;
|
||||
return;
|
||||
}
|
||||
ensureRuntimeStyle().textContent = text;
|
||||
}
|
||||
@@ -12,9 +12,13 @@ export type HideSegment =
|
||||
body: string;
|
||||
};
|
||||
|
||||
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
|
||||
const HIDE_KINDS = new Set<string>(["login", "reply", "points", "password"]);
|
||||
const HIDE_OPEN_RE = /^\[hide(?:\s+(.+))?\]$/i;
|
||||
|
||||
function isHideKind(s: string): s is HideKind {
|
||||
return HIDE_KINDS.has(s);
|
||||
}
|
||||
|
||||
function isClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/hide]";
|
||||
}
|
||||
@@ -40,8 +44,8 @@ function parseOpen(trimmed: string): {
|
||||
kind = head.slice(0, eq);
|
||||
arg = head.slice(eq + 1);
|
||||
}
|
||||
kind = kind.toLowerCase() as HideKind;
|
||||
if (!HIDE_KINDS.has(kind)) return null;
|
||||
kind = kind.toLowerCase();
|
||||
if (!isHideKind(kind)) return null;
|
||||
|
||||
let idx = 1;
|
||||
let points = 0;
|
||||
|
||||
@@ -59,8 +59,10 @@ export interface RtSettingsData {
|
||||
attachment_max_mb?: number;
|
||||
attachment_max_count?: number;
|
||||
image_max_mb?: number;
|
||||
custom_css?: string;
|
||||
custom_js?: string;
|
||||
bg_site_url?: string;
|
||||
bg_site_mode?: string;
|
||||
bg_admin_url?: string;
|
||||
bg_admin_mode?: string;
|
||||
}
|
||||
|
||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
||||
|
||||
@@ -157,7 +157,7 @@ function clampForLight(hex: string): string {
|
||||
function deriveDark(hex: string): string {
|
||||
const [h, s, lRaw] = rgbToHsl(hexToRgb(hex));
|
||||
let l = Math.max(lRaw, 0.62);
|
||||
const surfaces = ["#181b21", "#101211", "#10162b"];
|
||||
const surfaces = ["#181b21", "#0c0d10", "#10162b"];
|
||||
let cur = hex;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
cur = rgbToHex(hslToRgb([h, s, l]));
|
||||
@@ -207,7 +207,7 @@ function buildAccentCSS(input: string): string {
|
||||
`--accent-hi:${hi};`,
|
||||
`--accent-soft:${rgba(light, 0.1)};`,
|
||||
`--accent-on:#ffffff;`,
|
||||
`--nav-active-bg:var(--panel);`,
|
||||
`--nav-active-bg:${rgba(light, 0.1)};`,
|
||||
`--nav-active-ink:${light};`,
|
||||
`--nav-active-line:${rgba(light, 0.38)};`,
|
||||
`}`,
|
||||
@@ -216,7 +216,7 @@ function buildAccentCSS(input: string): string {
|
||||
`--accent-hi:${darkHi};`,
|
||||
`--accent-soft:${rgba(dark, 0.16)};`,
|
||||
`--accent-on:#10162b;`,
|
||||
`--nav-active-bg:var(--panel);`,
|
||||
`--nav-active-bg:${rgba(dark, 0.14)};`,
|
||||
`--nav-active-ink:${darkHi};`,
|
||||
`--nav-active-line:${rgba(dark, 0.38)};`,
|
||||
`}`,
|
||||
|
||||
Reference in New Issue
Block a user