vo
当前进度
- {isError ? 'ERROR' : `${percent}%`}
+
+ {isError ? 'ERROR' : `${percent}%`}
+
预计剩余
- {isError ? '--' : formatEta(progress?.eta)}
+
+ {isError ? '--' : formatEta(progress?.eta)}
+
任务状态
- {isError ? '已中断' : '正常'}
+
+ {isError ? '已中断' : '正常'}
+
@@ -611,6 +665,18 @@ const MeetingDetail: React.FC = () => {
const [showFloatingTranscriptPlayer, setShowFloatingTranscriptPlayer] = useState(false);
const [floatingTranscriptPlayerLayout, setFloatingTranscriptPlayerLayout] = useState<{ left: number; width: number } | null>(null);
+ const fetchData = useCallback(async (meetingId: number) => {
+ try {
+ const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
+ setMeeting(detailRes.data.data);
+ setTranscripts(transcriptRes.data.data || []);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ''),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
@@ -703,14 +769,14 @@ const MeetingDetail: React.FC = () => {
root?.removeEventListener('scroll', updateFloatingPlayerState);
resizeObserver?.disconnect();
};
- }, [meeting?.audioUrl]);
+ }, [meeting?.audioUrl, meeting?.status]);
useEffect(() => {
if (!id) return;
fetchData(Number(id));
loadAiConfigs();
loadUsers();
- }, [id]);
+ }, [id, fetchData]);
useEffect(() => {
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
@@ -731,7 +797,6 @@ const MeetingDetail: React.FC = () => {
setSharePasswordDraft(normalizedPassword);
}, [sharePopoverOpen, meeting?.accessPassword]);
-
useEffect(() => {
const audio = audioRef.current;
if (!audio) return undefined;
@@ -763,19 +828,7 @@ const MeetingDetail: React.FC = () => {
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('ended', handleEnded);
};
- }, [meeting?.audioUrl, audioPlaybackRate]);
-
- const fetchData = useCallback(async (meetingId: number) => {
- try {
- const [detailRes, transcriptRes] = await Promise.all([getMeetingDetail(meetingId), getTranscripts(meetingId)]);
- setMeeting(detailRes.data.data);
- setTranscripts(transcriptRes.data.data || []);
- } catch (error) {
- console.error(error);
- } finally {
- setLoading(false);
- }
- }, []);
+ }, [meeting?.audioUrl, audioPlaybackRate, meeting?.status]);
const loadAiConfigs = async () => {
try {
@@ -1354,8 +1407,16 @@ const MeetingDetail: React.FC = () => {
- {meeting.status === 1 || meeting.status === 2 ? (
-
fetchData(meeting.id)} />
+ {meeting.status === 1 ? (
+ fetchData(meeting.id)}
+ onProgressUpdate={(updated) => {
+ if (updated.status !== meeting.status) {
+ void fetchData(updated.id);
+ }
+ }}
+ />
) : (
@@ -1618,7 +1679,15 @@ const MeetingDetail: React.FC = () => {
styles={{ body: { padding: 24, height: '100%', overflowY: 'auto', overflowX: 'hidden', minWidth: 0 } }}
>
- {meeting.summaryContent ? (
+ {meeting.status === 2 ? (
+
+ fetchData(meeting.id)}
+ compact
+ />
+
+ ) : meeting.summaryContent ? (
isEditingSummary ? (
{
)
) : (
- {meeting.status === 2 ? (
-
-
- 正在重新总结...
-
- ) : (
-
- )}
+
)}
diff --git a/frontend/src/pages/business/ScreenSaverManagement.css b/frontend/src/pages/business/ScreenSaverManagement.css
new file mode 100644
index 0000000..bdd1705
--- /dev/null
+++ b/frontend/src/pages/business/ScreenSaverManagement.css
@@ -0,0 +1,333 @@
+.screen-saver-page {
+ --screen-saver-border: rgba(15, 23, 42, 0.08);
+ --screen-saver-shadow: 0 18px 45px rgba(15, 23, 42, 0.08);
+ --screen-saver-accent: #1677ff;
+ --screen-saver-dark: #10233f;
+ --screen-saver-muted: #5b6b84;
+}
+
+.screen-saver-page .screen-saver-table-card {
+ border: 1px solid var(--screen-saver-border);
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: var(--screen-saver-shadow);
+}
+
+.screen-saver-page .screen-saver-table-card .ant-card-head {
+ padding: 0 24px;
+ min-height: 72px;
+}
+
+.screen-saver-page .screen-saver-table-card .ant-card-head-wrapper {
+ align-items: flex-start;
+ gap: 16px;
+}
+
+.screen-saver-page .screen-saver-table-card .ant-card-head-title {
+ color: var(--screen-saver-dark);
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.screen-saver-page .screen-saver-table-card .ant-card-extra {
+ max-width: 100%;
+}
+
+.screen-saver-page .screen-saver-table-wrap {
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ padding: 24px 24px 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.screen-saver-page .screen-saver-table-wrap .ant-table-wrapper,
+.screen-saver-page .screen-saver-table-wrap .ant-spin-nested-loading,
+.screen-saver-page .screen-saver-table-wrap .ant-spin-container,
+.screen-saver-page .screen-saver-table-wrap .ant-table,
+.screen-saver-page .screen-saver-table-wrap .ant-table-container {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.screen-saver-page .screen-saver-table-wrap .ant-table-body {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto !important;
+}
+
+.screen-saver-page .screen-saver-preview-pill,
+.screen-saver-drawer .screen-saver-preview-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ background: rgba(22, 119, 255, 0.08);
+ color: var(--screen-saver-accent);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.screen-saver-page .screen-saver-table-visual {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.screen-saver-page .screen-saver-table-thumb {
+ width: 120px;
+ aspect-ratio: 8 / 5;
+ overflow: hidden;
+ border-radius: 14px;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ background: linear-gradient(135deg, rgba(222, 231, 245, 0.9), rgba(241, 245, 251, 0.92));
+}
+
+.screen-saver-page .screen-saver-table-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.screen-saver-drawer .screen-saver-drawer__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+}
+
+.screen-saver-drawer .screen-saver-preview-card .ant-card-body {
+ overflow: hidden;
+}
+
+.screen-saver-drawer .screen-saver-preview-card .ant-space {
+ width: 100%;
+}
+
+.screen-saver-drawer .screen-saver-preview-card .ant-space-item:first-child {
+ min-width: 0;
+}
+
+.screen-saver-drawer .screen-saver-preview-card {
+ overflow: hidden;
+ border-radius: 18px;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ background: linear-gradient(180deg, rgba(248, 250, 255, 0.98), rgba(255, 255, 255, 1));
+}
+
+.screen-saver-drawer .screen-saver-preview-stage {
+ position: relative;
+ overflow: hidden;
+ width: 100%;
+ max-width: 420px;
+ margin: 0 auto;
+ aspect-ratio: 8 / 5;
+ border-radius: 18px;
+ background:
+ linear-gradient(140deg, rgba(11, 24, 48, 0.92), rgba(34, 59, 102, 0.88));
+}
+
+.screen-saver-drawer .screen-saver-preview-stage::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.18));
+ pointer-events: none;
+}
+
+.screen-saver-drawer .screen-saver-preview-stage img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.screen-saver-crop-modal .ant-modal-content {
+ overflow: hidden;
+ border-radius: 26px;
+}
+
+.screen-saver-crop-modal .ant-modal-body {
+ padding: 0;
+}
+
+.screen-saver-crop-modal__layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) 320px;
+ min-height: 620px;
+}
+
+.screen-saver-crop-modal__stage {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 20px;
+ padding: 28px;
+ background:
+ radial-gradient(circle at top left, rgba(22, 119, 255, 0.18), transparent 28%),
+ linear-gradient(160deg, #081326, #12284b 55%, #17315b 100%);
+}
+
+.screen-saver-crop-modal__stage-head {
+ color: rgba(233, 242, 252, 0.92);
+}
+
+.screen-saver-crop-modal__stage-head h3 {
+ margin: 0 0 8px;
+ color: #f8fbff;
+ font-size: 24px;
+ font-weight: 800;
+ letter-spacing: -0.04em;
+}
+
+.screen-saver-crop-modal__stage-head p {
+ margin: 0;
+ color: rgba(223, 233, 247, 0.72);
+ line-height: 1.8;
+}
+
+.screen-saver-crop-modal__viewport {
+ position: relative;
+ width: min(100%, 640px);
+ aspect-ratio: 8 / 5;
+ align-self: center;
+ overflow: hidden;
+ border-radius: 30px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(6, 13, 28, 0.78);
+ box-shadow: 0 24px 50px rgba(5, 12, 25, 0.4);
+ touch-action: none;
+ cursor: grab;
+}
+
+.screen-saver-crop-modal__viewport.is-dragging {
+ cursor: grabbing;
+}
+
+.screen-saver-crop-modal__viewport::before,
+.screen-saver-crop-modal__viewport::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+
+.screen-saver-crop-modal__viewport::before {
+ border: 1px solid rgba(255, 255, 255, 0.16);
+}
+
+.screen-saver-crop-modal__viewport::after {
+ background-image:
+ linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px);
+ background-size: 33.333% 50%;
+ opacity: 0.36;
+}
+
+.screen-saver-crop-modal__image {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ max-width: none;
+ max-height: none;
+ user-select: none;
+ -webkit-user-drag: none;
+ transform-origin: center center;
+ will-change: transform;
+}
+
+.screen-saver-crop-modal__meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ color: rgba(232, 241, 252, 0.72);
+ font-size: 12px;
+}
+
+.screen-saver-crop-modal__sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ padding: 28px 24px;
+ background: linear-gradient(180deg, #ffffff, #f7f9fd);
+}
+
+.screen-saver-crop-modal__sidebar-card {
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 18px;
+ padding: 18px;
+ background: rgba(255, 255, 255, 0.95);
+ box-shadow: 0 12px 24px rgba(15, 23, 42, 0.06);
+}
+
+.screen-saver-crop-modal__sidebar-card h4 {
+ margin: 0 0 8px;
+ color: var(--screen-saver-dark);
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.screen-saver-crop-modal__sidebar-card p {
+ margin: 0;
+ color: var(--screen-saver-muted);
+ line-height: 1.75;
+}
+
+.screen-saver-crop-modal__footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: auto;
+}
+
+@media (max-width: 992px) {
+ .screen-saver-crop-modal__layout {
+ grid-template-columns: 1fr;
+ }
+
+ .screen-saver-crop-modal__stage {
+ padding: 24px;
+ }
+
+ .screen-saver-crop-modal__sidebar {
+ padding: 20px 24px 24px;
+ }
+}
+
+@media (max-width: 768px) {
+ .screen-saver-page .screen-saver-table-card .ant-card-head {
+ padding: 0 16px;
+ }
+
+ .screen-saver-page .screen-saver-table-card .ant-card-head-wrapper {
+ flex-direction: column;
+ }
+
+ .screen-saver-page .screen-saver-table-card .ant-card-extra {
+ margin-inline-start: 0;
+ width: 100%;
+ }
+
+ .screen-saver-page .screen-saver-table-card .ant-card-extra .ant-space {
+ width: 100%;
+ }
+
+ .screen-saver-page .screen-saver-table-wrap {
+ padding: 16px 16px 0;
+ }
+
+ .screen-saver-page .screen-saver-preview-stage {
+ width: min(100%, 360px);
+ max-height: 225px;
+ }
+
+ .screen-saver-page .screen-saver-table-visual {
+ align-items: flex-start;
+ }
+
+ .screen-saver-page .screen-saver-table-thumb {
+ width: 88px;
+ }
+}
diff --git a/frontend/src/pages/business/ScreenSaverManagement.tsx b/frontend/src/pages/business/ScreenSaverManagement.tsx
new file mode 100644
index 0000000..059040a
--- /dev/null
+++ b/frontend/src/pages/business/ScreenSaverManagement.tsx
@@ -0,0 +1,866 @@
+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 固定尺寸上传,后端仅做格式与尺寸校验,不再重新裁剪。
+
+
+
+ } loading={loading} onClick={() => void handleConfirm()}>
+ 生成并上传
+
+
+
+
+
+ );
+}
+
+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 ?

: 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) => (
+
+ } onClick={() => openEdit(record)} />
+ void handleDelete(record)}>
+ } />
+
+
+ ),
+ },
+ ];
+
+ const currentImageUrl = Form.useWatch("imageUrl", form);
+ const currentScopeType = Form.useWatch("scopeType", form) || "PLATFORM";
+ const currentOwnerUserId = Form.useWatch("ownerUserId", form);
+
+ return (
+
+
+ }
+ allowClear
+ style={{ width: 280 }}
+ value={searchValue}
+ onChange={(event) => setSearchValue(event.target.value)}
+ />
+
+
+
setDrawerOpen(false)}
+ width={760}
+ destroyOnHidden
+ forceRender
+ className="screen-saver-drawer"
+ styles={{ body: { padding: 24 } }}
+ footer={(
+
+
+ } loading={saving} onClick={() => void handleSubmit()}>
+ 保存
+
+
+ )}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setCropState({ open: false, src: "", fileName: "", mimeType: "image/jpeg" })}
+ onConfirm={handleUploadCroppedImage}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/organization/tenants/index.tsx b/frontend/src/pages/organization/tenants/index.tsx
index 4c6284a..f1a1b37 100644
--- a/frontend/src/pages/organization/tenants/index.tsx
+++ b/frontend/src/pages/organization/tenants/index.tsx
@@ -7,6 +7,7 @@ import { createTenant, deleteTenant, listTenants, updateTenant } from "@/api";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
+import AppPagination from "@/components/shared/AppPagination";
import { LOGIN_NAME_PATTERN, sanitizeLoginName } from "@/utils/loginName";
import type { SysTenant } from "@/types";
@@ -162,26 +163,28 @@ export default function Tenants() {
-
-
setParams({ ...params, current: page, size: size || params.size }),
- showSizeChanger: true,
- showQuickJumper: true,
- showTotal: (count) => t("common.total", { total: count }),
- pageSizeOptions: ["10", "20", "50", "100"],
- style: { marginTop: "24px", marginBottom: "24px" }
- }}
- locale={{ emptyText: }}
+
+
+
}}
+ />
+
+ setParams({ ...params, current: page, size: size || params.size })}
/>
-
+
{editing ? t("tenants.drawerTitleEdit") : t("tenants.drawerTitleCreate")}} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480} destroyOnClose footer={}>
- {editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnHidden forceRender footer={}>
+ {editingType ? t("dicts.drawerTitleTypeEdit") : t("dicts.drawerTitleTypeCreate")}} open={typeDrawerVisible} onClose={() => setTypeDrawerVisible(false)} width={400} destroyOnClose footer={}>
diff --git a/frontend/src/pages/system/logs/index.tsx b/frontend/src/pages/system/logs/index.tsx
index 6abdf53..1577aef 100644
--- a/frontend/src/pages/system/logs/index.tsx
+++ b/frontend/src/pages/system/logs/index.tsx
@@ -1,12 +1,12 @@
-import { Button, Card, DatePicker, Descriptions, Input, Modal, Select, Space, Tabs, Tag, Typography } from "antd";
+import { Button, Card, DatePicker, Descriptions, Input, Modal, Popconfirm, Select, Space, Tabs, Tag, Typography, message } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
-import { fetchLogModules, fetchLogs } from "@/api";
-import AppPagination from "@/components/shared/AppPagination";
+import { DeleteOutlined, EyeOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
+import { cleanLogs, fetchLogModules, fetchLogs } from "@/api";
import { useDict } from "@/hooks/useDict";
import PageHeader from "@/components/shared/PageHeader";
import ListTable from "@/components/shared/ListTable/ListTable";
+import AppPagination from "@/components/shared/AppPagination";
import type { SysLog, UserProfile } from "@/types";
const { RangePicker } = DatePicker;
@@ -21,6 +21,7 @@ export default function Logs() {
const [moduleOptions, setModuleOptions] = useState([]);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState(null);
+ const [cleaning, setCleaning] = useState(false);
const [params, setParams] = useState({
current: 1,
size: 20,
@@ -47,6 +48,11 @@ export default function Logs() {
}, []);
const isPlatformAdmin = Boolean(userProfile?.isPlatformAdmin);
+ const activeLogTypeLabel = useMemo(() => {
+ const dictLabel = logTypeDict.find((item) => item.itemValue === activeTab)?.itemLabel;
+ if (dictLabel) return dictLabel;
+ return activeTab === "OPERATION" ? t("logs.opLog") : t("logs.loginLog");
+ }, [activeTab, logTypeDict, t]);
const loadData = async (currentParams = params) => {
setLoading(true);
@@ -71,10 +77,11 @@ export default function Logs() {
fetchLogModules().then((items) => setModuleOptions(items || [])).catch(() => setModuleOptions([]));
}, [activeTab]);
- const handleTableChange = (_pagination: any, _filters: any, sorter: any) => {
+ const handleTableChange = (pagination: any, _filters: any, sorter: any) => {
setParams({
...params,
- current: 1,
+ current: pagination.current,
+ size: pagination.pageSize,
sortField: sorter.field || "createdAt",
sortOrder: sorter.order || "descend"
});
@@ -103,6 +110,19 @@ export default function Logs() {
loadData(resetParams);
};
+ const handleClean = async () => {
+ setCleaning(true);
+ try {
+ await cleanLogs(activeTab);
+ message.success(t("logsExt.cleanSuccess", { type: activeLogTypeLabel }));
+ const nextParams = { ...params, current: 1 };
+ setParams(nextParams);
+ await loadData(nextParams);
+ } finally {
+ setCleaning(false);
+ }
+ };
+
const renderDuration = (ms?: number) => {
if (!ms && ms !== 0) return "-";
let color = "";
@@ -189,16 +209,16 @@ export default function Logs() {
width: 180,
ellipsis: true,
render: (method: string) => (
-
{method}
@@ -261,18 +281,25 @@ export default function Logs() {
onChange={(key) => { setActiveTab(key); setParams((prev) => ({ ...prev, current: 1, moduleName: "" })); }}
size="large"
className="flex-shrink-0"
- items={
- logTypeDict.length > 0
- ? logTypeDict.map((item) => ({
- key: item.itemValue,
- label: {item.itemValue === "OPERATION" ? : }{item.itemLabel}
- }))
- : [
- { key: "OPERATION", label: {t("logs.opLog")} },
- { key: "LOGIN", label: {t("logs.loginLog")} }
- ]
- }
- />
+ tabBarExtraContent={(
+ void handleClean()}
+ >
+ } loading={cleaning}>
+ {t("logsExt.cleanCurrent", { type: activeLogTypeLabel })}
+
+
+ )}
+ >
+ {logTypeDict.length > 0
+ ? logTypeDict.map((item) => {item.itemValue === "OPERATION" ? : }{item.itemLabel}} key={item.itemValue} />)
+ : <>{t("logs.opLog")}} key="OPERATION" />{t("logs.loginLog")}} key="LOGIN" />>}
+
- setParams((prev) => ({ ...prev, current: page, size }))} />
+ {
+ setParams({ ...params, current: page, size: pageSize });
+ }}
+ />
setDetailModalVisible(false)} footer={[]} width={700}>
diff --git a/frontend/src/pages/system/platform-settings/index.tsx b/frontend/src/pages/system/platform-settings/index.tsx
index 1bc2a79..d3c695e 100644
--- a/frontend/src/pages/system/platform-settings/index.tsx
+++ b/frontend/src/pages/system/platform-settings/index.tsx
@@ -1,4 +1,4 @@
-import { Button, Card, Col, Form, Input, Row, Upload, App } from 'antd';
+import { Button, Card, Col, Form, Input, Row, Upload, message } from "antd";
import { FileTextOutlined, GlobalOutlined, PictureOutlined, SaveOutlined, UploadOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -25,7 +25,6 @@ function ImagePreview({ url, label, hint }: { url?: string; label: string; hint:
}
export default function PlatformSettings() {
- const { message } = App.useApp();
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -54,7 +53,7 @@ export default function PlatformSettings() {
form.setFieldValue(fieldName, url);
message.success(t("common.success"));
} catch (error) {
- // message.error(error instanceof Error ? error.message : t("common.error"));
+ message.error(error instanceof Error ? error.message : t("common.error"));
return Upload.LIST_IGNORE;
}
return false;
@@ -87,7 +86,13 @@ export default function PlatformSettings() {
{t("platformSettings.basicInfo")}>} className="app-page__content-card mb-6" loading={loading}>
-
+
diff --git a/frontend/src/pages/system/sys-params/index.tsx b/frontend/src/pages/system/sys-params/index.tsx
index bbfe5d7..c157115 100644
--- a/frontend/src/pages/system/sys-params/index.tsx
+++ b/frontend/src/pages/system/sys-params/index.tsx
@@ -1,12 +1,13 @@
-import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from "antd";
+import { Button, Card, Col, Drawer, Form, Input, Popconfirm, Row, Select, Space, Switch, Tag, Tooltip, Typography, message } from "antd";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
import { createParam, deleteParam, pageParams, updateParam } from "@/api";
-import AppPagination from "@/components/shared/AppPagination";
import { useDict } from "@/hooks/useDict";
import { usePermission } from "@/hooks/usePermission";
import PageHeader from "@/components/shared/PageHeader";
+import ListTable from "@/components/shared/ListTable/ListTable";
+import AppPagination from "@/components/shared/AppPagination";
import type { SysParamQuery, SysParamVO } from "@/types";
import "./index.less";
@@ -188,18 +189,25 @@ export default function SysParams() {
-
+
+
+ {
+ handlePageChange(page, pageSize);
+ }}
/>
-
-
import("@/pages/bindings/user-role"));
const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permission"));
const ClientManagement = lazy(() => import("@/pages/business/ClientManagement"));
const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement"));
+const ScreenSaverManagement = lazy(() => import("@/pages/business/ScreenSaverManagement"));
import SpeakerReg from "../pages/business/SpeakerReg";
const RealtimeAsrSession = lazy(async () => {
@@ -64,6 +65,7 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/aimodels", label: "模型配置", element: , perm: "menu:aimodel" },
{ path: "/clients", label: "客户端管理", element: , perm: "menu:clients" },
{ path: "/external-apps", label: "外部应用管理", element: , perm: "menu:external-apps" },
+ { path: "/screen-savers", label: "屏保管理", element: , perm: "menu:screen-savers" },
{ path: "/meetings", label: "会议中心", element: , perm: "menu:meeting" },
];
@@ -71,4 +73,4 @@ export const extraRoutes = [
{ path: "/dashboard-monitor", element: , perm: "menu:dashboard" },
{ path: "/meetings/:id", element: , perm: "menu:meeting" },
{ path: "/meeting-live-session/:id", element: , perm: "menu:meeting" },
-];
\ No newline at end of file
+];