外观设置改为半屏布局;背景图仅保存后生效,并支持媒体库选用。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,不写站点设置
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
121
frontend/app/admin/appearance/BgMediaPicker.tsx
Normal file
121
frontend/app/admin/appearance/BgMediaPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user