From 7671f94fcd6068e48c60a231ef3c1fd05f479a94 Mon Sep 17 00:00:00 2001 From: puz <13060209078@163.com> Date: Wed, 8 Jul 2026 16:31:28 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=20=E7=95=8C=E9=9D=A2=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/business/Meetings.css | 10 +- .../src/pages/business/PromptTemplates.css | 34 +++- .../src/pages/business/PromptTemplates.tsx | 31 +-- .../pages/business/ScreenSaverManagement.css | 63 ++++++ .../pages/business/ScreenSaverManagement.tsx | 50 +++-- .../src/pages/system/sys-params/index.less | 79 ++++++++ .../src/pages/system/sys-params/index.tsx | 186 ++++++++++++++++-- 7 files changed, 405 insertions(+), 48 deletions(-) diff --git a/frontend/src/pages/business/Meetings.css b/frontend/src/pages/business/Meetings.css index 1b6c8e6..1543219 100644 --- a/frontend/src/pages/business/Meetings.css +++ b/frontend/src/pages/business/Meetings.css @@ -119,21 +119,23 @@ } .meetings-card-scroll .ant-list-items { - flex: 1 1 auto; - min-height: 100%; + flex: 0 0 auto; + min-height: 0; height: auto; display: grid; grid-template-columns: repeat(var(--meeting-card-columns), minmax(0, 1fr)); - grid-auto-rows: minmax(var(--meeting-card-grid-min-height), 1fr); + grid-auto-rows: var(--meeting-card-grid-min-height); gap: var(--meeting-card-grid-gap); align-items: stretch; - align-content: stretch; + align-content: start; } .meetings-card-scroll--full-page .ant-list-items { + flex: 1 1 auto; height: 100%; min-height: 0; grid-template-rows: repeat(var(--meeting-card-rows), minmax(var(--meeting-card-grid-min-height), 1fr)); + align-content: stretch; } .meeting-card-list-item.ant-list-item { diff --git a/frontend/src/pages/business/PromptTemplates.css b/frontend/src/pages/business/PromptTemplates.css index e00e87a..149b835 100644 --- a/frontend/src/pages/business/PromptTemplates.css +++ b/frontend/src/pages/business/PromptTemplates.css @@ -111,6 +111,23 @@ min-width: 0; } +.prompt-template-editor-header { + align-items: center; + margin: 24px 0 20px; +} + +.prompt-template-editor-header__col { + display: flex; + align-items: center; + min-height: 25px; +} + +.prompt-template-editor-title { + min-width: 0; + font-weight: 600; + line-height: 22px; +} + .prompt-template-editor { height: calc(100vh - 400px); } @@ -142,10 +159,25 @@ } .prompt-template-editor__preview-meta { - margin-bottom: 12px; + display: flex; + align-items: center; + min-width: 0; + max-width: min(420px, 40vw); +} + +.prompt-template-editor__preview-meta .ant-tag { + max-width: 100%; + margin-inline-end: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } @media (max-width: 1200px) { + .prompt-template-editor__preview-meta { + max-width: 100%; + } + .prompt-template-editor { height: auto; } diff --git a/frontend/src/pages/business/PromptTemplates.tsx b/frontend/src/pages/business/PromptTemplates.tsx index 8873aa0..b2d783a 100644 --- a/frontend/src/pages/business/PromptTemplates.tsx +++ b/frontend/src/pages/business/PromptTemplates.tsx @@ -3,7 +3,6 @@ import { App, Button, Col, - Divider, Empty, Form, Input, @@ -562,7 +561,25 @@ const PromptTemplates: React.FC = () => { - 提示词编辑器 (Markdown 实时预览) + + +
+ 提示词编辑器 (Markdown 实时预览) +
+ + +
+ {selectedHotWordGroupId ? ( + + 绑定热词组: + {groupOptions.find((item) => item.id === selectedHotWordGroupId)?.groupName || '已选择'} + + ) : ( + 未绑定热词组 + )} +
+ +
@@ -574,16 +591,6 @@ const PromptTemplates: React.FC = () => { -
- {selectedHotWordGroupId ? ( - - 绑定热词组: - {groupOptions.find((item) => item.id === selectedHotWordGroupId)?.groupName || '已选择'} - - ) : ( - 未绑定热词组 - )} -
{previewContent}
diff --git a/frontend/src/pages/business/ScreenSaverManagement.css b/frontend/src/pages/business/ScreenSaverManagement.css index 31cfe2b..e67267d 100644 --- a/frontend/src/pages/business/ScreenSaverManagement.css +++ b/frontend/src/pages/business/ScreenSaverManagement.css @@ -76,6 +76,12 @@ background: #f9fafe; } +.screen-saver-page .screen-saver-table-thumb .ant-image { + display: block; + width: 100%; + height: 100%; +} + .screen-saver-page .screen-saver-table-thumb img { display: block; width: 100%; @@ -83,6 +89,19 @@ object-fit: cover; } +.screen-saver-page .screen-saver-table-thumb__image { + cursor: zoom-in; + transition: transform 0.2s ease; +} + +.screen-saver-page .screen-saver-table-thumb:hover .screen-saver-table-thumb__image { + transform: scale(1.03); +} + +.screen-saver-page .screen-saver-table-thumb .ant-image-mask { + border-radius: 4px; +} + .screen-saver-page .screen-saver-table-thumb__empty { width: 100%; height: 100%; @@ -124,15 +143,33 @@ max-width: 420px; margin: 0 auto; aspect-ratio: 8 / 5; + display: block; + padding: 0; border-radius: 4px; background: #f5f6fa; border: 1px solid #e6e6e6; + cursor: pointer; + color: inherit; } .screen-saver-drawer .screen-saver-preview-stage::after { content: none; } +.screen-saver-drawer .screen-saver-preview-stage:hover, +.screen-saver-drawer .screen-saver-preview-stage:focus-visible { + border-color: #3c70f5; +} + +.screen-saver-drawer .screen-saver-preview-stage:focus-visible { + outline: 2px solid rgba(60, 112, 245, 0.28); + outline-offset: 2px; +} + +.screen-saver-drawer .screen-saver-preview-stage:disabled { + cursor: wait; +} + .screen-saver-drawer .screen-saver-preview-stage img { display: block; width: 100%; @@ -147,6 +184,32 @@ color: #8c9bb3; } +.screen-saver-drawer .screen-saver-preview-stage__action { + position: absolute; + inset: auto 12px 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + padding: 6px 10px; + border-radius: 4px; + background: rgba(16, 35, 63, 0.74); + color: #ffffff; + font-size: 13px; + font-weight: 600; + opacity: 0; + transform: translateY(4px); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.screen-saver-drawer .screen-saver-preview-stage:hover .screen-saver-preview-stage__action, +.screen-saver-drawer .screen-saver-preview-stage:focus-visible .screen-saver-preview-stage__action, +.screen-saver-drawer .screen-saver-preview-stage__empty + .screen-saver-preview-stage__action { + opacity: 1; + transform: translateY(0); +} + .screen-saver-crop-modal .ant-modal-content { overflow: hidden; border-radius: 4px; diff --git a/frontend/src/pages/business/ScreenSaverManagement.tsx b/frontend/src/pages/business/ScreenSaverManagement.tsx index 15496d8..632b062 100644 --- a/frontend/src/pages/business/ScreenSaverManagement.tsx +++ b/frontend/src/pages/business/ScreenSaverManagement.tsx @@ -8,6 +8,7 @@ import { Drawer, Empty, Form, + Image as AntImage, Input, InputNumber, Modal, @@ -618,7 +619,12 @@ export default function ScreenSaverManagement() { render: (_, record) => (
{record.imageUrl ? ( - {record.name} + ) : (
@@ -876,24 +882,30 @@ export default function ScreenSaverManagement() { 屏保成片预览 固定 8:5 构图,根据设定尺寸导出。上传后后端只做校验与存储。
- - - -
- {currentImageUrl ? ( - 屏保预览 - ) : ( -
- - - 请选择图片并完成裁剪后上传 - -
- )} -
+ + + 输出规格 {currentWidth} × {currentHeight} 当前作用域 {currentScopeType === "USER" ? "用户级" : "平台级"} @@ -924,7 +936,7 @@ export default function ScreenSaverManagement() { - + diff --git a/frontend/src/pages/system/sys-params/index.less b/frontend/src/pages/system/sys-params/index.less index bd1c202..32cb1b2 100644 --- a/frontend/src/pages/system/sys-params/index.less +++ b/frontend/src/pages/system/sys-params/index.less @@ -32,6 +32,85 @@ border-radius: 4px; } +.param-value-template-preview { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; +} + +.param-value-template-token { + display: inline-flex; + align-items: center; + max-width: 100%; + margin: 0 2px; + padding: 0 5px; + border: 1px solid #91caff; + border-radius: 4px; + background: #e6f4ff; + color: #1677ff; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 18px; + vertical-align: baseline; +} + +.param-value-template-popover { + max-width: min(720px, 70vw); +} + +.param-value-template-popover__content { + max-width: min(680px, 66vw); + max-height: 360px; + margin: 0 !important; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + user-select: text; + line-height: 1.6; +} + +.param-value-rich-editor { + min-height: var(--param-value-editor-min-height); + max-height: 260px; + padding: 6px 11px; + overflow: auto; + border: 1px solid #d9d9d9; + border-radius: 6px; + background: #fff; + color: rgba(0, 0, 0, 0.88); + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + font-size: 14px; + line-height: 1.5715; + outline: none; + white-space: pre-wrap; + word-break: break-word; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.param-value-rich-editor:hover { + border-color: #4096ff; +} + +.param-value-rich-editor:focus { + border-color: #1677ff; + box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1); +} + +.param-value-rich-editor:empty::before { + color: rgba(0, 0, 0, 0.25); + content: attr(data-placeholder); +} + +.param-value-rich-editor .param-value-template-token { + margin: 0 2px; + vertical-align: 1px; +} + .param-boolean-segmented { width: 176px; padding: 2px; diff --git a/frontend/src/pages/system/sys-params/index.tsx b/frontend/src/pages/system/sys-params/index.tsx index 7f6be60..cd2334c 100644 --- a/frontend/src/pages/system/sys-params/index.tsx +++ b/frontend/src/pages/system/sys-params/index.tsx @@ -1,5 +1,5 @@ -import { Button, Col, Drawer, Form, Input, Popconfirm, Row, Segmented, Select, Space, Switch, Tag, Tooltip, Typography, message } from "antd"; -import { useCallback, useEffect, useState } from "react"; +import { Button, Col, Drawer, Form, Input, Popconfirm, Popover, Row, Segmented, Select, Space, Switch, Tag, Tooltip, Typography, message } from "antd"; +import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, 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"; @@ -14,12 +14,176 @@ import LongTextPreview from "@/components/shared/LongTextPreview"; import type { SysParamQuery, SysParamVO } from "@/types"; import "./index.less"; -const { Text } = Typography; +const { Paragraph, Text } = Typography; +const TEMPLATE_PARAM_PATTERN = /\{\{[^{}\s]+\}\}/g; /** Normalize paramType to title-case for case-insensitive comparison */ const isType = (actual: string | undefined, expected: string) => (actual || "").toLowerCase() === expected.toLowerCase(); +const hasTemplateParam = (text: string) => { + TEMPLATE_PARAM_PATTERN.lastIndex = 0; + const matched = TEMPLATE_PARAM_PATTERN.test(text); + TEMPLATE_PARAM_PATTERN.lastIndex = 0; + return matched; +}; + +const renderTemplateParamSegments = (text: string) => { + const segments: ReactNode[] = []; + let lastIndex = 0; + + TEMPLATE_PARAM_PATTERN.lastIndex = 0; + text.replace(TEMPLATE_PARAM_PATTERN, (match: string, offset: number) => { + if (offset > lastIndex) { + segments.push(text.slice(lastIndex, offset)); + } + segments.push( + + {match} + + ); + lastIndex = offset + match.length; + return match; + }); + TEMPLATE_PARAM_PATTERN.lastIndex = 0; + + if (lastIndex < text.length) { + segments.push(text.slice(lastIndex)); + } + + return segments; +}; + +const readEditorText = (element: HTMLElement) => element.innerText.replace(/\r\n/g, "\n"); + +const getCaretTextOffset = (element: HTMLElement) => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !selection.anchorNode || !element.contains(selection.anchorNode)) { + return null; + } + + const range = selection.getRangeAt(0); + const prefixRange = range.cloneRange(); + prefixRange.selectNodeContents(element); + prefixRange.setEnd(range.endContainer, range.endOffset); + return prefixRange.toString().length; +}; + +const restoreCaretTextOffset = (element: HTMLElement, offset: number) => { + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let remaining = offset; + let node = walker.nextNode(); + + while (node) { + const textLength = node.textContent?.length ?? 0; + if (remaining <= textLength) { + range.setStart(node, remaining); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return; + } + remaining -= textLength; + node = walker.nextNode(); + } + + range.selectNodeContents(element); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +}; + +function TemplateParamValueEditor({ + value, + onChange, + onFocus, + onTextChange, + placeholder, + rows = 8 +}: { + value?: string | number | null; + onChange?: (value: string) => void; + onFocus?: () => void; + onTextChange?: (value: string) => void; + placeholder?: string; + rows?: number; +}) { + const editorRef = useRef(null); + const pendingCaretOffsetRef = useRef(null); + const text = value === undefined || value === null ? "" : String(value); + const editorStyle = { "--param-value-editor-min-height": `${rows * 22 + 20}px` } as CSSProperties; + + useLayoutEffect(() => { + const editor = editorRef.current; + const caretOffset = pendingCaretOffsetRef.current; + if (!editor || caretOffset === null || document.activeElement !== editor) { + return; + } + + restoreCaretTextOffset(editor, caretOffset); + pendingCaretOffsetRef.current = null; + }, [text]); + + const handleInput = () => { + const editor = editorRef.current; + if (!editor) return; + + pendingCaretOffsetRef.current = getCaretTextOffset(editor); + const nextValue = readEditorText(editor); + onChange?.(nextValue); + onTextChange?.(nextValue); + }; + + return ( +
{ + event.preventDefault(); + document.execCommand("insertText", false, event.clipboardData.getData("text/plain")); + }} + > + {text ? renderTemplateParamSegments(text) : null} +
+ ); +} + +function TemplateParamValuePreview({ value }: { value?: string | number | null }) { + const text = value === undefined || value === null || value === "" ? "-" : String(value); + + if (!hasTemplateParam(text)) { + return ; + } + + return ( + + {renderTemplateParamSegments(text)} + + } + > + {renderTemplateParamSegments(text)} + + ); +} + export default function SysParams() { const { t } = useTranslation(); const { can } = usePermission(); @@ -173,7 +337,7 @@ export default function SysParams() { title: t("sysParams.paramValue"), dataIndex: "paramValue", key: "paramValue", - width: 360, + width: 520, ellipsis: { showTitle: false }, render: (text: string, record: SysParamVO) => { const type = record.paramType; @@ -184,10 +348,10 @@ export default function SysParams() { } // JSON: 等宽字体,以纯字符串显示 if (isType(type, "JSON")) { - return ; + return ; } // 数字和字符串:等宽代码文本,悬浮查看完整内容。 - return ; + return ; } }, { @@ -271,7 +435,7 @@ export default function SysParams() { columns={columns} dataSource={data} loading={loading} - scroll={{ x: "max(100%, 1350px)", y: "100%" }} + scroll={{ x: "max(100%, 1510px)", y: "100%" }} pagination={false} /> @@ -338,16 +502,14 @@ export default function SysParams() { ]} /> ) : isType(paramType, "JSON") ? ( - { const v = drawerForm.getFieldValue("paramValue"); if (!v) drawerForm.setFieldValue("paramValue", JSON.stringify({ key: "value" })); }} - onChange={(e) => { - const v = e.target.value; + onTextChange={(v) => { if (!v) { setJsonError(null); return; @@ -361,7 +523,7 @@ export default function SysParams() { }} /> ) : ( - + )}