import "./ScreenSaverManagement.css"; import { App, Button, Card, Col, Drawer, Empty, Form, Input, InputNumber, Modal, Popconfirm, Row, Select, Slider, Space, Switch, Table, Tag, Typography, Upload, } from "antd"; import type { ColumnsType } from "antd/es/table"; import { DeleteOutlined, EditOutlined, PictureOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, ScissorOutlined, SearchOutlined, TeamOutlined, UploadOutlined, UserOutlined, } from "@ant-design/icons"; import { useEffect, useMemo, useRef, useState } from "react"; import type { UploadProps } from "antd"; import AppPagination from "@/components/shared/AppPagination"; import { createScreenSaver, deleteScreenSaver, listScreenSavers, type ScreenSaverDTO, type ScreenSaverScopeType, type ScreenSaverUploadResult, type ScreenSaverVO, updateScreenSaver, uploadScreenSaverImage, } from "@/api/business/screenSaver"; import { listUsers } from "@/api"; import type { SysUser } from "@/types"; import dayjs from "dayjs"; const { Text, Title } = Typography; const { TextArea } = Input; const CROP_WIDTH = 1280; const CROP_HEIGHT = 800; const VIEWPORT_WIDTH = 640; const VIEWPORT_HEIGHT = 400; const ALLOWED_TYPES = new Map([ ["image/jpeg", "image/jpeg"], ["image/jpg", "image/jpeg"], ["image/png", "image/png"], ]); type ScreenSaverFormValues = { scopeType: ScreenSaverScopeType; ownerUserId?: number; name: string; imageUrl: string; description?: string; displayDurationSec: number; imageWidth: number; imageHeight: number; imageFormat: string; sortOrder?: number; statusEnabled: boolean; remark?: string; }; type CropModalState = { open: boolean; src: string; fileName: string; mimeType: "image/jpeg" | "image/png"; }; type DragState = { startX: number; startY: number; originX: number; originY: number; }; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function normalizeOwnerLabel(user?: SysUser) { if (!user) { return "未指定"; } return user.displayName || user.username || `用户 ${user.userId}`; } function getImageFormatLabel(format?: string) { if (!format) { return "-"; } return format.toUpperCase(); } function validateImageFile(file: File) { const normalizedType = ALLOWED_TYPES.get(file.type.toLowerCase()); if (!normalizedType) { throw new Error("仅支持 jpg、jpeg、png 图片"); } return normalizedType; } async function readFileAsDataUrl(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(new Error("读取图片失败")); reader.readAsDataURL(file); }); } function createImage(src: string) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("图片加载失败")); image.src = src; }); } type CropDialogProps = { state: CropModalState; onCancel: () => void; onConfirm: (file: File) => Promise; }; function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps) { const { message } = App.useApp(); const [loading, setLoading] = useState(false); const [dragging, setDragging] = useState(false); const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }); const [minZoom, setMinZoom] = useState(1); const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const dragRef = useRef(null); useEffect(() => { if (!state.open) { return; } let active = true; void createImage(state.src) .then((image) => { if (!active) { return; } const nextMinZoom = Math.max(VIEWPORT_WIDTH / image.width, VIEWPORT_HEIGHT / image.height); setNaturalSize({ width: image.width, height: image.height }); setMinZoom(nextMinZoom); setZoom(nextMinZoom); setOffset({ x: 0, y: 0 }); }) .catch((error: Error) => { message.error(error.message || "图片加载失败"); }); return () => { active = false; }; }, [message, state.open, state.src]); const displaySize = useMemo(() => ({ width: naturalSize.width * zoom, height: naturalSize.height * zoom, }), [naturalSize, zoom]); const clampOffset = (x: number, y: number, currentZoom = zoom) => { const width = naturalSize.width * currentZoom; const height = naturalSize.height * currentZoom; const maxX = Math.max(0, (width - VIEWPORT_WIDTH) / 2); const maxY = Math.max(0, (height - VIEWPORT_HEIGHT) / 2); return { x: clamp(x, -maxX, maxX), y: clamp(y, -maxY, maxY), }; }; const handleZoomChange = (nextZoom: number) => { const nextOffset = clampOffset(offset.x, offset.y, nextZoom); setZoom(nextZoom); setOffset(nextOffset); }; const beginDrag = (clientX: number, clientY: number) => { dragRef.current = { startX: clientX, startY: clientY, originX: offset.x, originY: offset.y, }; setDragging(true); }; const updateDrag = (clientX: number, clientY: number) => { if (!dragRef.current) { return; } const deltaX = clientX - dragRef.current.startX; const deltaY = clientY - dragRef.current.startY; setOffset(clampOffset(dragRef.current.originX + deltaX, dragRef.current.originY + deltaY)); }; const endDrag = () => { dragRef.current = null; setDragging(false); }; const exportCroppedFile = async () => { const image = await createImage(state.src); const canvas = document.createElement("canvas"); canvas.width = CROP_WIDTH; canvas.height = CROP_HEIGHT; const context = canvas.getContext("2d"); if (!context) { throw new Error("浏览器不支持图片裁剪"); } const previewScale = CROP_WIDTH / VIEWPORT_WIDTH; const exportedWidth = image.width * zoom * previewScale; const exportedHeight = image.height * zoom * previewScale; const drawX = CROP_WIDTH / 2 - exportedWidth / 2 + offset.x * previewScale; const drawY = CROP_HEIGHT / 2 - exportedHeight / 2 + offset.y * previewScale; context.drawImage(image, drawX, drawY, exportedWidth, exportedHeight); const extension = state.mimeType === "image/png" ? "png" : "jpg"; const fileName = state.fileName.replace(/\.[^.]+$/, "") + `_1280x800.${extension}`; const blob = await new Promise((resolve) => { if (state.mimeType === "image/png") { canvas.toBlob(resolve, "image/png"); } else { canvas.toBlob(resolve, "image/jpeg", 0.92); } }); if (!blob) { throw new Error("导出裁剪图片失败"); } return new File([blob], fileName, { type: state.mimeType }); }; const handleConfirm = async () => { setLoading(true); try { const file = await exportCroppedFile(); await onConfirm(file); } catch (error) { message.error(error instanceof Error ? error.message : "裁剪上传失败"); } finally { setLoading(false); } }; return (

裁剪成屏保成品图

请在固定 8:5 取景框内调整画面位置。导出尺寸固定为 1280 × 800,安卓端将直接使用该成品图展示。

beginDrag(event.clientX, event.clientY)} onMouseMove={(event) => updateDrag(event.clientX, event.clientY)} onMouseUp={endDrag} onMouseLeave={endDrag} onTouchStart={(event) => { const touch = event.touches[0]; if (touch) { beginDrag(touch.clientX, touch.clientY); } }} onTouchMove={(event) => { const touch = event.touches[0]; if (touch) { updateDrag(touch.clientX, touch.clientY); } }} onTouchEnd={endDrag} > {state.src ? ( 待裁剪屏保 ) : null}
原图:{naturalSize.width || "-"} × {naturalSize.height || "-"} 输出:{CROP_WIDTH} × {CROP_HEIGHT}

缩放与构图

拖动画面调整主体位置。保留足够安全边距,避免标题、人物或徽标在不同设备上被视觉切边。

缩放 `${Math.round((value || 1) / minZoom * 100)}%` }} onChange={handleZoomChange} />

交付标准

仅支持 JPG / JPEG / PNG。导出的屏保图片会以 1280 × 800 固定尺寸上传,后端仅做格式与尺寸校验,不再重新裁剪。

); } export default function ScreenSaverManagement() { const { message } = App.useApp(); const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [records, setRecords] = useState([]); const [users, setUsers] = useState([]); const [searchValue, setSearchValue] = useState(""); const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all"); const [scopeFilter, setScopeFilter] = useState<"all" | ScreenSaverScopeType>("all"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [uploading, setUploading] = useState(false); const [cropState, setCropState] = useState({ open: false, src: "", fileName: "", mimeType: "image/jpeg", }); const userMap = useMemo(() => { return new Map(users.map((user) => [user.userId, user])); }, [users]); const loadData = async () => { setLoading(true); try { const [screenSavers, userList] = await Promise.all([ listScreenSavers(), listUsers().catch(() => [] as SysUser[]), ]); setRecords(screenSavers || []); setUsers(userList || []); } finally { setLoading(false); } }; useEffect(() => { void loadData(); }, []); const filteredRecords = useMemo(() => { const keyword = searchValue.trim().toLowerCase(); return records.filter((item) => { if (scopeFilter !== "all" && item.scopeType !== scopeFilter) { return false; } if (statusFilter === "enabled" && item.status !== 1) { return false; } if (statusFilter === "disabled" && item.status === 1) { return false; } if (!keyword) { return true; } const ownerName = normalizeOwnerLabel(item.ownerUserId ? userMap.get(item.ownerUserId) : undefined); return [item.name, item.description, item.creatorUsername, ownerName, item.imageFormat] .some((field) => String(field || "").toLowerCase().includes(keyword)); }); }, [records, scopeFilter, statusFilter, searchValue, userMap]); const pagedRecords = useMemo(() => { const start = (page - 1) * pageSize; return filteredRecords.slice(start, start + pageSize); }, [filteredRecords, page, pageSize]); useEffect(() => { setPage(1); }, [searchValue, statusFilter, scopeFilter]); const openCreate = () => { setEditing(null); form.resetFields(); form.setFieldsValue({ scopeType: "PLATFORM", displayDurationSec: 15, sortOrder: 0, statusEnabled: true, imageWidth: CROP_WIDTH, imageHeight: CROP_HEIGHT, imageFormat: "jpg", }); setDrawerOpen(true); }; const openEdit = (record: ScreenSaverVO) => { setEditing(record); form.setFieldsValue({ scopeType: record.scopeType, ownerUserId: record.ownerUserId || undefined, name: record.name, imageUrl: record.imageUrl, description: record.description, displayDurationSec: record.displayDurationSec, imageWidth: record.imageWidth || CROP_WIDTH, imageHeight: record.imageHeight || CROP_HEIGHT, imageFormat: record.imageFormat || "jpg", sortOrder: record.sortOrder, statusEnabled: record.status === 1, remark: record.remark, }); setDrawerOpen(true); }; const handleDelete = async (record: ScreenSaverVO) => { await deleteScreenSaver(record.id); message.success("屏保已删除"); await loadData(); }; const handleSubmit = async () => { const values = await form.validateFields(); const payload: ScreenSaverDTO = { scopeType: values.scopeType, ownerUserId: values.scopeType === "USER" ? values.ownerUserId ?? null : null, name: values.name.trim(), imageUrl: values.imageUrl.trim(), description: values.description?.trim(), displayDurationSec: values.displayDurationSec, imageWidth: values.imageWidth, imageHeight: values.imageHeight, imageFormat: values.imageFormat.trim().toLowerCase(), sortOrder: values.sortOrder, status: values.statusEnabled ? 1 : 0, remark: values.remark?.trim(), }; setSaving(true); try { if (editing) { await updateScreenSaver(editing.id, payload); message.success("屏保已更新"); } else { await createScreenSaver(payload); message.success("屏保已创建"); } setDrawerOpen(false); await loadData(); } finally { setSaving(false); } }; const openCropper = async (file: File) => { const mimeType = validateImageFile(file); const src = await readFileAsDataUrl(file); setCropState({ open: true, src, fileName: file.name, mimeType, }); }; const handleUploadCroppedImage = async (file: File) => { setUploading(true); try { const result: ScreenSaverUploadResult = await uploadScreenSaverImage(file); form.setFieldsValue({ imageUrl: result.imageUrl, imageWidth: result.imageWidth, imageHeight: result.imageHeight, imageFormat: result.imageFormat, }); setCropState({ open: false, src: "", fileName: "", mimeType: "image/jpeg" }); message.success("屏保图片已上传"); } finally { setUploading(false); } }; const uploadProps: UploadProps = { showUploadList: false, beforeUpload: (file) => { void openCropper(file as File).catch((error: Error) => { message.error(error.message || "图片处理失败"); }); return Upload.LIST_IGNORE; }, }; const handleToggleStatus = async (record: ScreenSaverVO, checked: boolean) => { await updateScreenSaver(record.id, { status: checked ? 1 : 0 }); message.success(checked ? "屏保已启用" : "屏保已停用"); await loadData(); }; const columns: ColumnsType = [ { title: "屏保画面", key: "visual", width: 330, render: (_, record) => (
{record.imageUrl ? {record.name} : null}
{record.name} {record.description || "暂无描述"} {getImageFormatLabel(record.imageFormat)} {record.imageWidth || CROP_WIDTH} × {record.imageHeight || CROP_HEIGHT}
), }, { title: "作用域", key: "scope", width: 220, render: (_, record) => ( {record.scopeType === "USER" ? ( }>用户级 ) : ( }>平台级 )} {record.scopeType === "USER" ? `归属:${normalizeOwnerLabel(record.ownerUserId ? userMap.get(record.ownerUserId) : undefined)}` : "全平台共用"} ), }, { title: "播放与状态", key: "status", width: 210, render: (_, record) => ( {record.displayDurationSec} 秒 / 张 排序值:{record.sortOrder ?? 0} void handleToggleStatus(record, checked)} /> ), }, { title: "创建信息", key: "creator", width: 180, render: (_, record) => { const timeValue = record.updatedAt || record.createdAt; return ( {record.creatorUsername || "-"} {timeValue ? dayjs(timeValue).format("YYYY-MM-DD HH:mm:ss") : "-"} ); }, }, { title: "操作", key: "action", width: 140, fixed: "right", render: (_, record) => ( )} >
, }} scroll={{ x: 1100, y: "100%" }} /> { setPage(nextPage); setPageSize(nextSize); }} /> setDrawerOpen(false)} width={760} destroyOnHidden forceRender className="screen-saver-drawer" styles={{ body: { padding: 24 } }} footer={(
)} >
({ value: user.userId, label: `${normalizeOwnerLabel(user)} / ${user.username}`, }))} />
屏保成片预览 固定 8:5 构图,导出 1280 × 800。上传后后端只做校验与存储。
{currentImageUrl ? ( 屏保预览 ) : (
请选择图片并完成裁剪后上传
)}
输出规格 1280 × 800 当前作用域 {currentScopeType === "USER" ? "用户级" : "平台级"} {currentScopeType === "USER" && currentOwnerUserId ? ( 归属 {normalizeOwnerLabel(userMap.get(currentOwnerUserId))} ) : null}