imeeting/frontend/src/pages/business/ScreenSaverManagement.tsx

905 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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,
updateScreenSaverStatus,
uploadScreenSaverImage,
} from "@/api/business/screenSaver";
import { listUsers } from "@/api";
import type { SysUser, UserProfile } 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<string, "image/jpeg" | "image/png">([
["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";
targetWidth: number;
targetHeight: number;
};
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<string>((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<HTMLImageElement>((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<void>;
};
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<DragState | null>(null);
const { targetWidth, targetHeight } = state;
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 = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("浏览器不支持图片裁剪");
}
const previewScale = targetWidth / VIEWPORT_WIDTH;
const exportedWidth = image.width * zoom * previewScale;
const exportedHeight = image.height * zoom * previewScale;
const drawX = targetWidth / 2 - exportedWidth / 2 + offset.x * previewScale;
const drawY = targetHeight / 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(/\.[^.]+$/, "") + `_${targetWidth}x${targetHeight}.${extension}`;
const blob = await new Promise<Blob | null>((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 (
<Modal
open={state.open}
onCancel={loading ? undefined : onCancel}
footer={null}
width={1100}
centered
destroyOnHidden
className="screen-saver-crop-modal"
maskClosable={!loading}
closable={!loading}
>
<div className="screen-saver-crop-modal__layout">
<div className="screen-saver-crop-modal__stage">
<div className="screen-saver-crop-modal__stage-head">
<h3></h3>
<p> 8:5 {targetWidth} × {targetHeight}使</p>
</div>
<div
className={`screen-saver-crop-modal__viewport${dragging ? " is-dragging" : ""}`}
onMouseDown={(event) => 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 ? (
<img
src={state.src}
alt="待裁剪屏保"
className="screen-saver-crop-modal__image"
draggable={false}
style={{
width: displaySize.width,
height: displaySize.height,
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`,
}}
/>
) : null}
</div>
<div className="screen-saver-crop-modal__meta">
<span>{naturalSize.width || "-"} × {naturalSize.height || "-"}</span>
<span>{targetWidth} × {targetHeight}</span>
</div>
</div>
<div className="screen-saver-crop-modal__sidebar">
<div className="screen-saver-crop-modal__sidebar-card">
<h4></h4>
<p></p>
<div style={{ marginTop: 18 }}>
<Text type="secondary"></Text>
<Slider
min={minZoom}
max={Math.max(minZoom * 3, minZoom + 0.2)}
step={0.01}
value={zoom}
tooltip={{ formatter: (value) => `${Math.round((value || 1) / minZoom * 100)}%` }}
onChange={handleZoomChange}
/>
</div>
</div>
<div className="screen-saver-crop-modal__sidebar-card">
<h4></h4>
<p> JPG / JPEG / PNG {targetWidth} × {targetHeight} </p>
</div>
<div className="screen-saver-crop-modal__footer">
<Button onClick={onCancel} disabled={loading}></Button>
<Button type="primary" icon={<ScissorOutlined />} loading={loading} onClick={() => void handleConfirm()}>
</Button>
</div>
</div>
</div>
</Modal>
);
}
export default function ScreenSaverManagement() {
const { message } = App.useApp();
const [form] = Form.useForm<ScreenSaverFormValues>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<ScreenSaverVO | null>(null);
const [records, setRecords] = useState<ScreenSaverVO[]>([]);
const [users, setUsers] = useState<SysUser[]>([]);
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<CropModalState>({
open: false,
src: "",
fileName: "",
mimeType: "image/jpeg",
targetWidth: CROP_WIDTH,
targetHeight: CROP_HEIGHT,
});
const userProfile = useMemo<UserProfile>(() => {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr) : {};
}, []);
const currentUserId = Number(userProfile.userId || 0);
const isAdmin = userProfile.isAdmin === true || userProfile.isPlatformAdmin === true || userProfile.isTenantAdmin === true;
const userMap = useMemo(() => {
return new Map<number, SysUser>(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: "USER",
ownerUserId: currentUserId || undefined,
displayDurationSec: 15,
sortOrder: 0,
statusEnabled: true,
imageWidth: CROP_WIDTH,
imageHeight: CROP_HEIGHT,
imageFormat: "jpg",
});
setDrawerOpen(true);
};
const openEdit = (record: ScreenSaverVO) => {
if (!isAdmin && (record.scopeType !== "USER" || record.ownerUserId !== currentUserId)) {
message.warning("普通用户只能编辑自己的用户级屏保");
return;
}
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 resolvedScopeType = isAdmin ? values.scopeType : "USER";
const resolvedOwnerUserId = resolvedScopeType === "USER" ? currentUserId || null : null;
const payload: ScreenSaverDTO = {
scopeType: resolvedScopeType,
ownerUserId: resolvedOwnerUserId,
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);
const targetWidth = form.getFieldValue("imageWidth") || CROP_WIDTH;
const targetHeight = form.getFieldValue("imageHeight") || CROP_HEIGHT;
setCropState({
open: true,
src,
fileName: file.name,
mimeType,
targetWidth,
targetHeight,
});
};
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((prev) => ({ ...prev, open: false, src: "" }));
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 updateScreenSaverStatus(record.id, checked ? 1 : 0);
message.success(checked ? "屏保已启用" : "屏保已停用");
await loadData();
};
const columns: ColumnsType<ScreenSaverVO> = [
{
title: "屏保画面",
key: "visual",
width: 330,
render: (_, record) => (
<div className="screen-saver-table-visual">
<div className="screen-saver-table-thumb">
{record.imageUrl ? <img src={record.imageUrl} alt={record.name} /> : null}
</div>
<Space direction="vertical" size={3}>
<Text strong>{record.name}</Text>
<Text type="secondary">{record.description || "暂无描述"}</Text>
<Space wrap size={[8, 6]}>
<span className="screen-saver-preview-pill">{getImageFormatLabel(record.imageFormat)}</span>
<span className="screen-saver-preview-pill">{record.imageWidth || CROP_WIDTH} × {record.imageHeight || CROP_HEIGHT}</span>
</Space>
</Space>
</div>
),
},
{
title: "作用域",
key: "scope",
width: 220,
render: (_, record) => (
<Space direction="vertical" size={4}>
{record.scopeType === "USER" ? (
<Tag color="gold" icon={<UserOutlined />}></Tag>
) : (
<Tag color="blue" icon={<TeamOutlined />}></Tag>
)}
<Text type="secondary">
{record.scopeType === "USER"
? `归属:${normalizeOwnerLabel(record.ownerUserId ? userMap.get(record.ownerUserId) : undefined)}`
: "全平台共用"}
</Text>
</Space>
),
},
{
title: "播放与状态",
key: "status",
width: 210,
render: (_, record) => (
<Space direction="vertical" size={6}>
<Text>{record.displayDurationSec} / </Text>
<Text type="secondary">{record.sortOrder ?? 0}</Text>
<Switch size="small" checked={record.status === 1} onChange={(checked) => void handleToggleStatus(record, checked)} />
</Space>
),
},
{
title: "创建信息",
key: "creator",
width: 180,
render: (_, record) => {
const timeValue = record.updatedAt || record.createdAt;
return (
<Space direction="vertical" size={4}>
<Text>{record.creatorUsername || "-"}</Text>
<Text type="secondary">
{timeValue ? dayjs(timeValue).format("YYYY-MM-DD HH:mm:ss") : "-"}
</Text>
</Space>
);
},
},
{
title: "操作",
key: "action",
width: 140,
fixed: "right",
render: (_, record) => {
const canManageRecord = isAdmin || (record.scopeType === "USER" && record.ownerUserId === currentUserId);
if (!canManageRecord) {
return null;
}
return (
<Space size={4}>
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="确认删除该屏保吗?" onConfirm={() => void handleDelete(record)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
);
},
},
];
const currentImageUrl = Form.useWatch("imageUrl", form);
const currentScopeType = Form.useWatch("scopeType", form) || "PLATFORM";
const currentOwnerUserId = Form.useWatch("ownerUserId", form);
const currentWidth = Form.useWatch("imageWidth", form) || CROP_WIDTH;
const currentHeight = Form.useWatch("imageHeight", form) || CROP_HEIGHT;
return (
<div className="app-page screen-saver-page">
<Card
className="app-page__content-card shadow-sm screen-saver-table-card"
style={{ flex: 1, minHeight: 0 }}
styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}
title="屏保管理"
extra={(
<Space wrap>
<Input
placeholder="搜索名称、描述、创建人或归属用户"
prefix={<SearchOutlined />}
allowClear
style={{ width: 280 }}
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
/>
<Select
value={scopeFilter}
style={{ width: 140 }}
onChange={(value) => setScopeFilter(value)}
options={[
{ label: "全部作用域", value: "all" },
{ label: "平台级", value: "PLATFORM" },
{ label: "用户级", value: "USER" },
]}
/>
<Select
value={statusFilter}
style={{ width: 140 }}
onChange={(value) => setStatusFilter(value)}
options={[
{ label: "全部状态", value: "all" },
{ label: "已启用", value: "enabled" },
{ label: "已停用", value: "disabled" },
]}
/>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</Space>
)}
>
<div className="app-page__table-wrap screen-saver-table-wrap">
<Table
rowKey="id"
columns={columns}
dataSource={pagedRecords}
loading={loading}
pagination={false}
locale={{
emptyText: <Empty description="暂无屏保素材" />,
}}
scroll={{ x: 1100, y: "100%" }}
/>
</div>
<AppPagination
current={page}
pageSize={pageSize}
total={filteredRecords.length}
onChange={(nextPage, nextSize) => {
setPage(nextPage);
setPageSize(nextSize);
}}
/>
</Card>
<Drawer
title={editing ? "编辑屏保" : "新增屏保"}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={760}
destroyOnHidden
forceRender
className="screen-saver-drawer"
styles={{ body: { padding: 24 } }}
footer={(
<div className="screen-saver-drawer__footer">
<Button onClick={() => setDrawerOpen(false)}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSubmit()}>
</Button>
</div>
)}
>
<Form form={form} layout="vertical" className="screen-saver-drawer__form">
<Row gutter={16}>
<Col xs={24} md={14}>
<Form.Item name="name" label="屏保名称" rules={[{ required: true, message: "请输入屏保名称" }]}>
<Input placeholder="例如:大厅欢迎屏、品牌发布屏" />
</Form.Item>
</Col>
<Col xs={24} md={10}>
<Form.Item name="displayDurationSec" label="展示时长(秒)" rules={[{ required: true, message: "请输入展示时长" }]}>
<InputNumber min={3} max={3600} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
{isAdmin ? (
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="scopeType" label="作用域" rules={[{ required: true, message: "请选择作用域" }]}>
<Select
options={[
{ label: "平台级(全平台共用)", value: "PLATFORM" },
{ label: "用户级(当前用户自己使用)", value: "USER" },
]}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="归属用户">
<Input
value={currentScopeType === "USER" ? normalizeOwnerLabel(userMap.get(currentUserId)) : "平台级无需选择"}
readOnly
/>
</Form.Item>
</Col>
</Row>
) : (
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label="作用域">
<Input value="个人级" readOnly />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="归属用户">
<Input value={normalizeOwnerLabel(userMap.get(currentUserId))} readOnly />
</Form.Item>
</Col>
</Row>
)}
<Card
className="screen-saver-preview-card"
style={{ marginBottom: 18 }}
styles={{ body: { padding: 18 } }}
>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
<div>
<Title level={5} style={{ margin: 0 }}></Title>
<Text type="secondary"> 8:5 </Text>
</div>
<Upload {...uploadProps}>
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
</Button>
</Upload>
</Space>
<div className="screen-saver-preview-stage">
{currentImageUrl ? (
<img src={currentImageUrl} alt="屏保预览" />
) : (
<div style={{ height: "100%", display: "grid", placeItems: "center", color: "rgba(235,244,255,.72)" }}>
<Space direction="vertical" align="center" size={10}>
<ScissorOutlined style={{ fontSize: 26 }} />
<span></span>
</Space>
</div>
)}
</div>
<Space wrap>
<span className="screen-saver-preview-pill"> {currentWidth} × {currentHeight}</span>
<span className="screen-saver-preview-pill"> {currentScopeType === "USER" ? "用户级" : "平台级"}</span>
{currentScopeType === "USER" ? (
<span className="screen-saver-preview-pill">
{normalizeOwnerLabel(userMap.get(currentUserId))}
</span>
) : null}
</Space>
</div>
</Card>
<Row gutter={16}>
<Col xs={24} md={8}>
<Form.Item name="imageFormat" label="图片格式" rules={[{ required: true, message: "请先上传图片" }]}>
<Input disabled />
</Form.Item>
</Col>
<Col xs={12} md={8}>
<Form.Item name="imageWidth" label="宽度" rules={[{ required: true, message: "请输入宽度" }]}>
<InputNumber min={100} max={4096} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col xs={12} md={8}>
<Form.Item name="imageHeight" label="高度" rules={[{ required: true, message: "请输入高度" }]}>
<InputNumber min={100} max={4096} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Form.Item name="imageUrl" label="图片地址" rules={[{ required: true, message: "请先上传裁剪后的屏保图片" }]}>
<Input placeholder="上传完成后自动回填" readOnly />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={3} placeholder="描述这张屏保用于什么场景、展示什么信息。" />
</Form.Item>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="sortOrder" label="排序值">
<InputNumber min={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="remark" label="备注">
<Input placeholder="例如:大厅屏、品牌发布期、个人欢迎页" />
</Form.Item>
</Col>
</Row>
{(isAdmin || currentScopeType === "USER") ? (
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked">
<Switch />
</Form.Item>
) : null}
</Form>
</Drawer>
<ScreenSaverCropDialog
state={cropState}
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
onConfirm={handleUploadCroppedImage}
/>
</div>
);
}