feat: 界面优化

dev_na
puz 2026-07-08 16:31:28 +08:00
parent 77879f4e0e
commit 7671f94fcd
7 changed files with 405 additions and 48 deletions

View File

@ -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 {

View File

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

View File

@ -3,7 +3,6 @@ import {
App,
Button,
Col,
Divider,
Empty,
Form,
Input,
@ -562,7 +561,25 @@ const PromptTemplates: React.FC = () => {
</Col>
</Row>
<Divider orientation="left"> (Markdown )</Divider>
<Row gutter={[12, 16]} className="prompt-template-editor-header">
<Col xs={24} xl={12} className="prompt-template-editor-header__col">
<div className="prompt-template-editor-title">
(Markdown )
</div>
</Col>
<Col xs={24} xl={12} className="prompt-template-editor-header__col">
<div className="prompt-template-editor__preview-meta">
{selectedHotWordGroupId ? (
<Tag color="blue">
{groupOptions.find((item) => item.id === selectedHotWordGroupId)?.groupName || '已选择'}
</Tag>
) : (
<Tag></Tag>
)}
</div>
</Col>
</Row>
<Row gutter={[12, 16]} className="prompt-template-editor">
<Col xs={24} xl={12} className="prompt-template-editor__col">
<Form.Item name="promptContent" noStyle rules={[{ required: true }]}>
@ -574,16 +591,6 @@ const PromptTemplates: React.FC = () => {
</Form.Item>
</Col>
<Col xs={24} xl={12} className="prompt-template-editor__preview-col">
<div className="prompt-template-editor__preview-meta">
{selectedHotWordGroupId ? (
<Tag color="blue">
{groupOptions.find((item) => item.id === selectedHotWordGroupId)?.groupName || '已选择'}
</Tag>
) : (
<Tag></Tag>
)}
</div>
<div className="prompt-template-editor__preview markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
</Col>
</Row>

View File

@ -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;

View File

@ -8,6 +8,7 @@ import {
Drawer,
Empty,
Form,
Image as AntImage,
Input,
InputNumber,
Modal,
@ -618,7 +619,12 @@ export default function ScreenSaverManagement() {
render: (_, record) => (
<div className="screen-saver-table-thumb">
{record.imageUrl ? (
<img src={record.imageUrl} alt={record.name} />
<AntImage
src={record.imageUrl}
alt={record.name}
className="screen-saver-table-thumb__image"
preview={{ mask: "查看大图" }}
/>
) : (
<div className="screen-saver-table-thumb__empty">
<PictureOutlined style={{ color: '#ccc', fontSize: 20 }} />
@ -876,24 +882,30 @@ export default function ScreenSaverManagement() {
<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 className="screen-saver-preview-stage__empty">
<Space direction="vertical" align="center" size={10}>
<ScissorOutlined style={{ fontSize: 26 }} />
<span></span>
</Space>
</div>
)}
</div>
<Upload {...uploadProps}>
<button
type="button"
className="screen-saver-preview-stage"
disabled={uploading}
aria-label={currentImageUrl ? "更换屏保图片并裁剪" : "选择屏保图片并裁剪"}
>
{currentImageUrl ? (
<img src={currentImageUrl} alt="屏保预览" />
) : (
<span className="screen-saver-preview-stage__empty">
<Space direction="vertical" align="center" size={10}>
<ScissorOutlined style={{ fontSize: 26 }} />
<span></span>
</Space>
</span>
)}
<span className="screen-saver-preview-stage__action" aria-hidden="true">
<UploadOutlined />
<span>{currentImageUrl ? "更换图片并裁剪" : "选择图片并裁剪"}</span>
</span>
</button>
</Upload>
<Space wrap>
<span className="screen-saver-preview-pill"> {currentWidth} × {currentHeight}</span>
<span className="screen-saver-preview-pill"> {currentScopeType === "USER" ? "用户级" : "平台级"}</span>
@ -924,7 +936,7 @@ export default function ScreenSaverManagement() {
</Col>
</Row>
<Form.Item name="imageUrl" label="图片地址" rules={[{ required: true, message: "请先上传裁剪后的屏保图片" }]}>
<Form.Item name="imageUrl" hidden rules={[{ required: true, message: "请先上传裁剪后的屏保图片" }]}>
<Input placeholder="上传完成后自动回填" readOnly />
</Form.Item>

View File

@ -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;

View File

@ -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(
<span className="param-value-template-token" key={`${match}-${offset}`}>
{match}
</span>
);
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<HTMLDivElement>(null);
const pendingCaretOffsetRef = useRef<number | null>(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 (
<div
ref={editorRef}
className="param-value-rich-editor"
contentEditable
data-placeholder={placeholder}
role="textbox"
aria-multiline="true"
spellCheck={false}
style={editorStyle}
suppressContentEditableWarning
tabIndex={0}
onFocus={onFocus}
onInput={handleInput}
onPaste={(event) => {
event.preventDefault();
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
}}
>
{text ? renderTemplateParamSegments(text) : null}
</div>
);
}
function TemplateParamValuePreview({ value }: { value?: string | number | null }) {
const text = value === undefined || value === null || value === "" ? "-" : String(value);
if (!hasTemplateParam(text)) {
return <LongTextPreview value={value} code />;
}
return (
<Popover
overlayClassName="param-value-template-popover"
placement="topLeft"
content={
<Paragraph className="param-value-template-popover__content" copyable={{ text }}>
{renderTemplateParamSegments(text)}
</Paragraph>
}
>
<span className="param-value-template-preview">{renderTemplateParamSegments(text)}</span>
</Popover>
);
}
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 <LongTextPreview value={text} code />;
return <TemplateParamValuePreview value={text} />;
}
// 数字和字符串:等宽代码文本,悬浮查看完整内容。
return <LongTextPreview value={text} code />;
return <TemplateParamValuePreview value={text} />;
}
},
{
@ -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}
/>
</DataListPanel>
@ -338,16 +502,14 @@ export default function SysParams() {
]}
/>
) : isType(paramType, "JSON") ? (
<Input.TextArea
<TemplateParamValueEditor
rows={6}
style={{ fontFamily: "'SFMono-Regular', Consolas, Menlo, monospace" }}
placeholder={t("sysParamsExt.jsonPlaceholder")}
onFocus={() => {
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() {
}}
/>
) : (
<Input.TextArea rows={3} placeholder={t("sysParamsExt.paramValuePlaceholder")} />
<TemplateParamValueEditor rows={8} placeholder={t("sysParamsExt.paramValuePlaceholder")} />
)}
</Form.Item>
<Form.Item