外观设置改为半屏布局;背景图仅保存后生效,并支持媒体库选用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 10:51:20 +08:00
parent e598782279
commit 68e90ff6ff
7 changed files with 953 additions and 481 deletions

View File

@@ -7,7 +7,6 @@ import (
"strconv" "strconv"
"github.com/freefire/jiang13-bbs/middleware" "github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/realtime"
"github.com/freefire/jiang13-bbs/service" "github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -167,10 +166,9 @@ func (h *Handlers) DeleteAttachment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
} }
// UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),并立即绑定前台或后台 // UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),只落盘返回 URL,不写站点设置
func (h *Handlers) UploadBackground(c *gin.Context) { func (h *Handlers) UploadBackground(c *gin.Context) {
surface, ok := service.NormalizeBgSurface(c.PostForm("surface")) if _, ok := service.NormalizeBgSurface(c.PostForm("surface")); !ok {
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"})
return return
} }
@@ -209,26 +207,36 @@ func (h *Handlers) UploadBackground(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"url": url})
}
if err := h.applyBgURL(surface, url); err != nil { type backgroundFromMediaRequest struct {
writeBgSettingError(c, err) AttachmentID uint `json:"attachment_id"`
Surface string `json:"surface"`
}
// UploadBackgroundFromMedia 把本人媒体库图片复制进 backgrounds,不写站点设置
func (h *Handlers) UploadBackgroundFromMedia(c *gin.Context) {
var req backgroundFromMediaRequest
if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"})
return return
} }
if _, ok := service.NormalizeBgSurface(req.Surface); !ok {
saved, err := h.Setting.Public() c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"})
return
}
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
url, err := h.Upload.CopyBackgroundFromMedia(claims.ID, req.AttachmentID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
payload := settingsPayload(saved) c.JSON(http.StatusOK, gin.H{"url": url})
h.Hub.BroadcastAll(realtime.Envelope{
Type: realtime.EventSettingsChanged,
Data: payload,
})
out := settingsPayload(saved)
out["ok"] = true
out["url"] = url
c.JSON(http.StatusOK, out)
} }
// UploadBrand 超管上传 Logo / Favicon,只落盘返回 URL,不写站点设置 // UploadBrand 超管上传 Logo / Favicon,只落盘返回 URL,不写站点设置

View File

@@ -267,6 +267,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings) staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings) staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground) staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground)
staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia)
staffAPI.POST("/upload/brand", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrand) staffAPI.POST("/upload/brand", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrand)
staffAPI.POST("/upload/brand/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrandFromMedia) staffAPI.POST("/upload/brand/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrandFromMedia)

View File

@@ -364,6 +364,24 @@ func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
return "/uploads/backgrounds/" + filename, nil return "/uploads/backgrounds/" + filename, nil
} }
// CopyBackgroundFromMedia 把当前用户媒体库里的一张图复制进 backgrounds,不写站点设置
func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (string, error) {
var att model.Attachment
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
return "", errors.New("图片不存在或不属于你")
}
abs, ok := s.safeUploadPath(att.URL)
if !ok {
return "", errors.New("无效的图片地址")
}
f, err := os.Open(abs)
if err != nil {
return "", errors.New("读取图片失败")
}
defer f.Close()
return s.SaveBackground(f)
}
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内 // BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
func (s *UploadService) BackgroundFileExists(url string) bool { func (s *UploadService) BackgroundFileExists(url string) bool {
rel := BgRelPath(url) rel := BgRelPath(url)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
"use client";
import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
const SURFACE_LABEL: Record<BgSurface, string> = {
site: "前台背景",
admin: "后台背景",
};
const BG_MAX_BYTES = 8 * 1024 * 1024;
const BG_MAX_DIM = 8192;
function mediaFitsBackground(item: MediaAttachment): boolean {
if (!mediaURLFitsBackground(item.url)) return false;
if (item.size > 0 && item.size > BG_MAX_BYTES) return false;
if (item.width > 0 && item.width > BG_MAX_DIM) return false;
if (item.height > 0 && item.height > BG_MAX_DIM) return false;
return true;
}
export default function BgMediaPicker({
open,
surface,
onClose,
onPick,
}: {
open: boolean;
surface: BgSurface;
onClose: () => void;
onPick: (item: MediaAttachment) => void;
}) {
const [items, setItems] = useState<MediaAttachment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
void apiMyMedia()
.then((res) => {
if (cancelled) return;
if (res.error) {
setError(res.error);
setItems([]);
return;
}
const all = res.attachments || [];
setItems(all.filter(mediaFitsBackground));
})
.catch(() => {
if (!cancelled) setError("加载媒体库失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, surface]);
return (
<Modal
open={open}
title={`从媒体库选择 · ${SURFACE_LABEL[surface]}`}
description="只列出 JPEG / PNG / WebP(≤8MB、边长≤8192)。保存后会复制到背景目录,之后删除原图不影响背景。"
onClose={onClose}
maxWidthClass="max-w-lg"
dismissOnOverlay
>
{loading ? (
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
<Loader2 size={14} className="animate-spin" /> 加载中
</p>
) : error ? (
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13px] py-6 text-center">
没有可用图片。请先在发帖或媒体库上传 JPEG / PNG / WebP。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="用作草稿预览,点保存后才生效"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.url}
alt={`${SURFACE_LABEL[surface]}候选图`}
className="w-full h-full object-cover"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
)}
</Modal>
);
}

View File

@@ -2268,10 +2268,11 @@ export async function apiBrandFromMedia(slot: BrandSlot, attachmentId: number):
} }
/** 上传站点背景并立即绑定前台或后台 */ /** 上传站点背景并立即绑定前台或后台 */
/** 超管上传站点背景图:只落盘返回 URL,需再 PUT settings 才绑定并广播 */
export async function apiUploadSiteBackground( export async function apiUploadSiteBackground(
surface: "site" | "admin", surface: "site" | "admin",
file: File file: File
): Promise<PublicSettings> { ): Promise<string> {
const form = new FormData(); const form = new FormData();
form.append("surface", surface); form.append("surface", surface);
form.append("file", file, file.name || "background.jpg"); form.append("file", file, file.name || "background.jpg");
@@ -2282,7 +2283,26 @@ export async function apiUploadSiteBackground(
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "上传背景图失败"); if (!res.ok) throw new Error(data.error || "上传背景图失败");
return normalizePublicSettings(data as Partial<PublicSettings>); const url = String(data.url || "");
if (!url) throw new Error("上传背景图失败");
return url;
}
/** 把本人媒体库图片复制进 backgrounds,不写入站点设置 */
export async function apiBackgroundFromMedia(
surface: "site" | "admin",
attachmentId: number
): Promise<string> {
const res = await fetchWithRefresh("/api/admin/upload/background/from-media", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ attachment_id: attachmentId, surface }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
const url = String(data.url || "");
if (!url) throw new Error("选用媒体库图片失败");
return url;
} }
// ===== 板块(客户端,供角色授权弹窗勾选) ===== // ===== 板块(客户端,供角色授权弹窗勾选) =====

View File

@@ -50,6 +50,11 @@ export function normalizeBgMode(raw: string | null | undefined): BgMode {
return MODE_SET.has(m) ? m : DEFAULT_BG_MODE; return MODE_SET.has(m) ? m : DEFAULT_BG_MODE;
} }
/** 媒体库条目是否可作为背景图(JPEG / PNG / WebP) */
export function mediaURLFitsBackground(url: string): boolean {
return /\.(jpe?g|png|webp)(\?|$)/i.test(url);
}
export function bgConfigFromSettings(s: SiteBgInput | null | undefined): SiteBgConfig { export function bgConfigFromSettings(s: SiteBgInput | null | undefined): SiteBgConfig {
return { return {
bg_site_url: normalizeBgURL(s?.bg_site_url), bg_site_url: normalizeBgURL(s?.bg_site_url),