站点背景功能优化、页脚上边距调整
This commit is contained in:
@@ -17,14 +17,11 @@ const (
|
|||||||
BgSurfaceSite = "site"
|
BgSurfaceSite = "site"
|
||||||
BgSurfaceAdmin = "admin"
|
BgSurfaceAdmin = "admin"
|
||||||
|
|
||||||
BgModeCover = "cover"
|
BgModeCover = "cover"
|
||||||
BgModeContain = "contain"
|
BgModeContain = "contain"
|
||||||
BgModeRepeat = "repeat"
|
BgModeRepeat = "repeat"
|
||||||
BgModeRepeatX = "repeat-x"
|
BgModeStretch = "stretch"
|
||||||
BgModeRepeatY = "repeat-y"
|
DefaultBgMode = BgModeCover
|
||||||
BgModeStretch = "stretch"
|
|
||||||
BgModeCenter = "center"
|
|
||||||
DefaultBgMode = BgModeCover
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrBgFileMissing 设置了合法 URL 但磁盘上没有对应文件
|
// ErrBgFileMissing 设置了合法 URL 但磁盘上没有对应文件
|
||||||
@@ -37,10 +34,7 @@ var bgModes = map[string]struct{}{
|
|||||||
BgModeCover: {},
|
BgModeCover: {},
|
||||||
BgModeContain: {},
|
BgModeContain: {},
|
||||||
BgModeRepeat: {},
|
BgModeRepeat: {},
|
||||||
BgModeRepeatX: {},
|
|
||||||
BgModeRepeatY: {},
|
|
||||||
BgModeStretch: {},
|
BgModeStretch: {},
|
||||||
BgModeCenter: {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeBgMode 校验铺放模式;空串视为默认 cover
|
// NormalizeBgMode 校验铺放模式;空串视为默认 cover
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ func TestNormalizeBgMode(t *testing.T) {
|
|||||||
if _, ok := NormalizeBgMode("zoom"); ok {
|
if _, ok := NormalizeBgMode("zoom"); ok {
|
||||||
t.Fatal("unknown mode should fail")
|
t.Fatal("unknown mode should fail")
|
||||||
}
|
}
|
||||||
|
for _, legacy := range []string{"repeat-x", "repeat-y", "center"} {
|
||||||
|
if _, ok := NormalizeBgMode(legacy); ok {
|
||||||
|
t.Fatalf("legacy mode %s should fail", legacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
BgModeCover, BgModeContain, BgModeRepeat, BgModeRepeatX, BgModeRepeatY, BgModeStretch, BgModeCenter,
|
BgModeCover, BgModeContain, BgModeRepeat, BgModeStretch,
|
||||||
} {
|
} {
|
||||||
got, ok := NormalizeBgMode(" " + want + " ")
|
got, ok := NormalizeBgMode(" " + want + " ")
|
||||||
if !ok || got != want {
|
if !ok || got != want {
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import {
|
|||||||
AdminSettingRow,
|
AdminSettingRow,
|
||||||
AdminStepper,
|
AdminStepper,
|
||||||
AdminSwitch,
|
AdminSwitch,
|
||||||
AdminSegmented,
|
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
import BgModePicker from "./BgModePicker";
|
||||||
import {
|
import {
|
||||||
apiUpdateSiteSettings,
|
apiUpdateSiteSettings,
|
||||||
apiUploadSiteBackground,
|
apiUploadSiteBackground,
|
||||||
@@ -41,10 +41,9 @@ import {
|
|||||||
normalizeHex,
|
normalizeHex,
|
||||||
} from "@/lib/theme";
|
} from "@/lib/theme";
|
||||||
import {
|
import {
|
||||||
BG_MODE_OPTIONS,
|
BG_PREVIEW_ASPECT,
|
||||||
applySiteBackground,
|
applySiteBackground,
|
||||||
bgConfigFromSettings,
|
bgConfigFromSettings,
|
||||||
normalizeBgMode,
|
|
||||||
previewBoxStyle,
|
previewBoxStyle,
|
||||||
type BgMode,
|
type BgMode,
|
||||||
type BgSurface,
|
type BgSurface,
|
||||||
@@ -239,7 +238,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
try {
|
try {
|
||||||
await saveBgPatch(
|
await saveBgPatch(
|
||||||
{ [key]: mode },
|
{ [key]: mode },
|
||||||
surface === "site" ? "前台铺放模式已保存" : "后台铺放模式已保存"
|
surface === "site" ? "前台铺放方式已保存" : "后台铺放方式已保存"
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
toast(e instanceof Error ? e.message : "保存失败", "error");
|
||||||
@@ -285,11 +284,15 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="relative h-36 rounded-[var(--r-sm)] border overflow-hidden"
|
className="relative w-full max-w-[480px] rounded-[var(--r-sm)] border overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
|
aspectRatio: BG_PREVIEW_ASPECT,
|
||||||
borderColor: "var(--line)",
|
borderColor: "var(--line)",
|
||||||
...previewBoxStyle(url, mode),
|
...previewBoxStyle(url, mode),
|
||||||
}}
|
}}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${title}背景预览,按 16:9 常见显示器比例`}
|
||||||
|
title="按 16:9(1920×1080)显示器比例预览"
|
||||||
>
|
>
|
||||||
{!url && (
|
{!url && (
|
||||||
<span
|
<span
|
||||||
@@ -299,6 +302,17 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
默认纹饰画布
|
默认纹饰画布
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<span
|
||||||
|
className="absolute bottom-1.5 right-1.5 text-[10.5px] leading-none px-1.5 py-1 rounded-[4px] pointer-events-none"
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--panel) 88%, transparent)",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
16:9
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
@@ -333,22 +347,13 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="field-label mb-2">铺放模式</p>
|
<p className="field-label mb-2">铺放方式</p>
|
||||||
<AdminSegmented
|
<BgModePicker
|
||||||
size="sm"
|
|
||||||
role="group"
|
|
||||||
ariaLabel={`${title}铺放模式`}
|
|
||||||
value={mode}
|
value={mode}
|
||||||
onChange={(key) => void onChangeBgMode(surface, normalizeBgMode(key))}
|
disabled={busy}
|
||||||
options={BG_MODE_OPTIONS.map((o) => ({
|
ariaLabel={`${title}铺放方式`}
|
||||||
key: o.key,
|
onChange={(next) => void onChangeBgMode(surface, next)}
|
||||||
label: o.label,
|
|
||||||
disabled: busy,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
<p className="meta text-[12px] mt-1.5">
|
|
||||||
{BG_MODE_OPTIONS.find((o) => o.key === mode)?.hint}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -675,7 +680,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
<ImageIcon size={15} /> 站点背景图
|
<ImageIcon size={15} /> 站点背景图
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
description="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。未配置时使用默认纹饰画布。"
|
description="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。预览按 16:9 常见显示器比例。未配置时使用默认纹饰画布。"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{renderBgRail(
|
{renderBgRail(
|
||||||
|
|||||||
107
frontend/app/admin/appearance/BgModePicker.tsx
Normal file
107
frontend/app/admin/appearance/BgModePicker.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BG_MODE_OPTIONS, type BgMode } from "@/lib/bg";
|
||||||
|
|
||||||
|
/** 铺放示意:16:9 小窗里用色块表达裁切 / 完整 / 平铺 / 拉伸。 */
|
||||||
|
function ModeGlyph({ mode }: { mode: BgMode }) {
|
||||||
|
const photo = "var(--accent-soft)";
|
||||||
|
const ink = "var(--accent)";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="relative block w-16 aspect-video overflow-hidden rounded-[3px] shrink-0"
|
||||||
|
style={{ background: "var(--bg)", border: "1px solid var(--line-2)" }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{mode === "cover" && (
|
||||||
|
<span
|
||||||
|
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
style={{ width: "72%", height: "140%", background: photo }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute left-1/2 top-[18%] -translate-x-1/2 w-[30%] aspect-square rounded-full"
|
||||||
|
style={{ background: ink, opacity: 0.45 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mode === "contain" && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-y-0 left-1/2 -translate-x-1/2"
|
||||||
|
style={{ width: "58%", background: photo }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute left-1/2 top-[18%] -translate-x-1/2 w-[36%] aspect-square rounded-full"
|
||||||
|
style={{ background: ink, opacity: 0.45 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mode === "repeat" && (
|
||||||
|
<span className="absolute inset-[2px] grid grid-cols-3 grid-rows-2 gap-[2px]">
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<span key={i} style={{ background: photo }} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{mode === "stretch" && (
|
||||||
|
<span className="absolute inset-0" style={{ background: photo }}>
|
||||||
|
<span
|
||||||
|
className="absolute left-1/2 top-[22%] -translate-x-1/2 h-[38%]"
|
||||||
|
style={{
|
||||||
|
width: "70%",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: ink,
|
||||||
|
opacity: 0.45,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BgModePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
value: BgMode;
|
||||||
|
onChange: (mode: BgMode) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2" role="group" aria-label={ariaLabel}>
|
||||||
|
{BG_MODE_OPTIONS.map((o) => {
|
||||||
|
const active = value === o.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => {
|
||||||
|
if (!active) onChange(o.key);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2.5 min-w-0 px-2.5 py-2 rounded-[var(--r-sm)] border text-left transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
borderColor: active ? "var(--accent)" : "var(--line)",
|
||||||
|
background: active ? "var(--accent-soft)" : "var(--panel)",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModeGlyph mode={o.key} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span
|
||||||
|
className="block text-[13px] font-medium leading-tight"
|
||||||
|
style={{ color: active ? "var(--accent)" : "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</span>
|
||||||
|
<span className="block meta text-[11.5px] mt-0.5 leading-snug">{o.hint}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ export default function Footer({ siteName = "姜十三社区" }: { siteName?: st
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className="mt-14"
|
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid var(--line)",
|
borderTop: "1px solid var(--line)",
|
||||||
background: "var(--panel)",
|
background: "var(--panel)",
|
||||||
|
|||||||
@@ -6,25 +6,18 @@ import type { CSSProperties } from "react";
|
|||||||
|
|
||||||
export type BgSurface = "site" | "admin";
|
export type BgSurface = "site" | "admin";
|
||||||
|
|
||||||
export type BgMode =
|
export type BgMode = "cover" | "contain" | "repeat" | "stretch";
|
||||||
| "cover"
|
|
||||||
| "contain"
|
|
||||||
| "repeat"
|
|
||||||
| "repeat-x"
|
|
||||||
| "repeat-y"
|
|
||||||
| "stretch"
|
|
||||||
| "center";
|
|
||||||
|
|
||||||
export const DEFAULT_BG_MODE: BgMode = "cover";
|
export const DEFAULT_BG_MODE: BgMode = "cover";
|
||||||
|
|
||||||
|
/** 预览框按常见 PC 显示器 16:9(1920×1080)等比例缩放 */
|
||||||
|
export const BG_PREVIEW_ASPECT = "16 / 9";
|
||||||
|
|
||||||
export const BG_MODE_OPTIONS: { key: BgMode; label: string; hint: string }[] = [
|
export const BG_MODE_OPTIONS: { key: BgMode; label: string; hint: string }[] = [
|
||||||
{ key: "cover", label: "覆盖铺满", hint: "裁切以铺满视口" },
|
{ key: "cover", label: "填充", hint: "裁切铺满,适合照片" },
|
||||||
{ key: "contain", label: "完整显示", hint: "完整放入,两侧留底色" },
|
{ key: "contain", label: "适应", hint: "整图可见,可能留边" },
|
||||||
{ key: "stretch", label: "拉伸填满", hint: "变形拉伸至视口" },
|
{ key: "repeat", label: "平铺", hint: "原尺寸重复,适合纹理" },
|
||||||
{ key: "repeat", label: "平铺", hint: "按原尺寸重复铺满" },
|
{ key: "stretch", label: "拉伸", hint: "拉满屏幕,可能变形" },
|
||||||
{ key: "repeat-x", label: "横向平铺", hint: "只沿水平方向重复" },
|
|
||||||
{ key: "repeat-y", label: "纵向平铺", hint: "只沿垂直方向重复" },
|
|
||||||
{ key: "center", label: "居中原图", hint: "不缩放,居中显示" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface SiteBgConfig {
|
export interface SiteBgConfig {
|
||||||
@@ -72,14 +65,8 @@ function modeCSS(mode: BgMode): { size: string; repeat: string; position: string
|
|||||||
return { size: "contain", repeat: "no-repeat", position: "center" };
|
return { size: "contain", repeat: "no-repeat", position: "center" };
|
||||||
case "repeat":
|
case "repeat":
|
||||||
return { size: "auto", repeat: "repeat", position: "0 0" };
|
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":
|
case "stretch":
|
||||||
return { size: "100% 100%", repeat: "no-repeat", position: "center" };
|
return { size: "100% 100%", repeat: "no-repeat", position: "center" };
|
||||||
case "center":
|
|
||||||
return { size: "auto", repeat: "no-repeat", position: "center" };
|
|
||||||
default:
|
default:
|
||||||
return { size: "cover", repeat: "no-repeat", position: "center" };
|
return { size: "cover", repeat: "no-repeat", position: "center" };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user