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

905 lines
30 KiB
TypeScript
Raw Normal View History

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>
);
}