905 lines
30 KiB
TypeScript
905 lines
30 KiB
TypeScript
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>
|
||
);
|
||
}
|