feat: 界面优化
parent
77879f4e0e
commit
7671f94fcd
|
|
@ -119,21 +119,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.meetings-card-scroll .ant-list-items {
|
.meetings-card-scroll .ant-list-items {
|
||||||
flex: 1 1 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 100%;
|
min-height: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--meeting-card-columns), minmax(0, 1fr));
|
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);
|
gap: var(--meeting-card-grid-gap);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
align-content: stretch;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meetings-card-scroll--full-page .ant-list-items {
|
.meetings-card-scroll--full-page .ant-list-items {
|
||||||
|
flex: 1 1 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
grid-template-rows: repeat(var(--meeting-card-rows), minmax(var(--meeting-card-grid-min-height), 1fr));
|
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 {
|
.meeting-card-list-item.ant-list-item {
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,23 @@
|
||||||
min-width: 0;
|
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 {
|
.prompt-template-editor {
|
||||||
height: calc(100vh - 400px);
|
height: calc(100vh - 400px);
|
||||||
}
|
}
|
||||||
|
|
@ -142,10 +159,25 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-template-editor__preview-meta {
|
.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) {
|
@media (max-width: 1200px) {
|
||||||
|
.prompt-template-editor__preview-meta {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-template-editor {
|
.prompt-template-editor {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import {
|
||||||
App,
|
App,
|
||||||
Button,
|
Button,
|
||||||
Col,
|
Col,
|
||||||
Divider,
|
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
|
@ -562,7 +561,25 @@ const PromptTemplates: React.FC = () => {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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">
|
<Row gutter={[12, 16]} className="prompt-template-editor">
|
||||||
<Col xs={24} xl={12} className="prompt-template-editor__col">
|
<Col xs={24} xl={12} className="prompt-template-editor__col">
|
||||||
<Form.Item name="promptContent" noStyle rules={[{ required: true }]}>
|
<Form.Item name="promptContent" noStyle rules={[{ required: true }]}>
|
||||||
|
|
@ -574,16 +591,6 @@ const PromptTemplates: React.FC = () => {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} xl={12} className="prompt-template-editor__preview-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>
|
<div className="prompt-template-editor__preview markdown-preview"><ReactMarkdown>{previewContent}</ReactMarkdown></div>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@
|
||||||
background: #f9fafe;
|
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 {
|
.screen-saver-page .screen-saver-table-thumb img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -83,6 +89,19 @@
|
||||||
object-fit: cover;
|
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 {
|
.screen-saver-page .screen-saver-table-thumb__empty {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -124,15 +143,33 @@
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
aspect-ratio: 8 / 5;
|
aspect-ratio: 8 / 5;
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #f5f6fa;
|
background: #f5f6fa;
|
||||||
border: 1px solid #e6e6e6;
|
border: 1px solid #e6e6e6;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen-saver-drawer .screen-saver-preview-stage::after {
|
.screen-saver-drawer .screen-saver-preview-stage::after {
|
||||||
content: none;
|
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 {
|
.screen-saver-drawer .screen-saver-preview-stage img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -147,6 +184,32 @@
|
||||||
color: #8c9bb3;
|
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 {
|
.screen-saver-crop-modal .ant-modal-content {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Drawer,
|
Drawer,
|
||||||
Empty,
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
|
Image as AntImage,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
|
@ -618,7 +619,12 @@ export default function ScreenSaverManagement() {
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<div className="screen-saver-table-thumb">
|
<div className="screen-saver-table-thumb">
|
||||||
{record.imageUrl ? (
|
{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">
|
<div className="screen-saver-table-thumb__empty">
|
||||||
<PictureOutlined style={{ color: '#ccc', fontSize: 20 }} />
|
<PictureOutlined style={{ color: '#ccc', fontSize: 20 }} />
|
||||||
|
|
@ -876,24 +882,30 @@ export default function ScreenSaverManagement() {
|
||||||
<Title level={5} style={{ margin: 0 }}>屏保成片预览</Title>
|
<Title level={5} style={{ margin: 0 }}>屏保成片预览</Title>
|
||||||
<Text type="secondary">固定 8:5 构图,根据设定尺寸导出。上传后后端只做校验与存储。</Text>
|
<Text type="secondary">固定 8:5 构图,根据设定尺寸导出。上传后后端只做校验与存储。</Text>
|
||||||
</div>
|
</div>
|
||||||
<Upload {...uploadProps}>
|
|
||||||
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
|
|
||||||
选择图片并裁剪
|
|
||||||
</Button>
|
|
||||||
</Upload>
|
|
||||||
</Space>
|
</Space>
|
||||||
<div className="screen-saver-preview-stage">
|
<Upload {...uploadProps}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="screen-saver-preview-stage"
|
||||||
|
disabled={uploading}
|
||||||
|
aria-label={currentImageUrl ? "更换屏保图片并裁剪" : "选择屏保图片并裁剪"}
|
||||||
|
>
|
||||||
{currentImageUrl ? (
|
{currentImageUrl ? (
|
||||||
<img src={currentImageUrl} alt="屏保预览" />
|
<img src={currentImageUrl} alt="屏保预览" />
|
||||||
) : (
|
) : (
|
||||||
<div className="screen-saver-preview-stage__empty">
|
<span className="screen-saver-preview-stage__empty">
|
||||||
<Space direction="vertical" align="center" size={10}>
|
<Space direction="vertical" align="center" size={10}>
|
||||||
<ScissorOutlined style={{ fontSize: 26 }} />
|
<ScissorOutlined style={{ fontSize: 26 }} />
|
||||||
<span>请选择图片并完成裁剪后上传</span>
|
<span>请选择图片并完成裁剪后上传</span>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<span className="screen-saver-preview-stage__action" aria-hidden="true">
|
||||||
|
<UploadOutlined />
|
||||||
|
<span>{currentImageUrl ? "更换图片并裁剪" : "选择图片并裁剪"}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Upload>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<span className="screen-saver-preview-pill">输出规格 {currentWidth} × {currentHeight}</span>
|
<span className="screen-saver-preview-pill">输出规格 {currentWidth} × {currentHeight}</span>
|
||||||
<span className="screen-saver-preview-pill">当前作用域 {currentScopeType === "USER" ? "用户级" : "平台级"}</span>
|
<span className="screen-saver-preview-pill">当前作用域 {currentScopeType === "USER" ? "用户级" : "平台级"}</span>
|
||||||
|
|
@ -924,7 +936,7 @@ export default function ScreenSaverManagement() {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="imageUrl" label="图片地址" rules={[{ required: true, message: "请先上传裁剪后的屏保图片" }]}>
|
<Form.Item name="imageUrl" hidden rules={[{ required: true, message: "请先上传裁剪后的屏保图片" }]}>
|
||||||
<Input placeholder="上传完成后自动回填" readOnly />
|
<Input placeholder="上传完成后自动回填" readOnly />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,85 @@
|
||||||
border-radius: 4px;
|
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 {
|
.param-boolean-segmented {
|
||||||
width: 176px;
|
width: 176px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Button, Col, Drawer, Form, Input, Popconfirm, Row, Segmented, Select, Space, Switch, Tag, Tooltip, Typography, message } from "antd";
|
import { Button, Col, Drawer, Form, Input, Popconfirm, Popover, Row, Segmented, Select, Space, Switch, Tag, Tooltip, Typography, message } from "antd";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
|
import { DeleteOutlined, EditOutlined, InfoCircleOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from "@ant-design/icons";
|
||||||
import { createParam, deleteParam, pageParams, updateParam } from "@/api";
|
import { createParam, deleteParam, pageParams, updateParam } from "@/api";
|
||||||
|
|
@ -14,12 +14,176 @@ import LongTextPreview from "@/components/shared/LongTextPreview";
|
||||||
import type { SysParamQuery, SysParamVO } from "@/types";
|
import type { SysParamQuery, SysParamVO } from "@/types";
|
||||||
import "./index.less";
|
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 */
|
/** Normalize paramType to title-case for case-insensitive comparison */
|
||||||
const isType = (actual: string | undefined, expected: string) =>
|
const isType = (actual: string | undefined, expected: string) =>
|
||||||
(actual || "").toLowerCase() === expected.toLowerCase();
|
(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() {
|
export default function SysParams() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
|
|
@ -173,7 +337,7 @@ export default function SysParams() {
|
||||||
title: t("sysParams.paramValue"),
|
title: t("sysParams.paramValue"),
|
||||||
dataIndex: "paramValue",
|
dataIndex: "paramValue",
|
||||||
key: "paramValue",
|
key: "paramValue",
|
||||||
width: 360,
|
width: 520,
|
||||||
ellipsis: { showTitle: false },
|
ellipsis: { showTitle: false },
|
||||||
render: (text: string, record: SysParamVO) => {
|
render: (text: string, record: SysParamVO) => {
|
||||||
const type = record.paramType;
|
const type = record.paramType;
|
||||||
|
|
@ -184,10 +348,10 @@ export default function SysParams() {
|
||||||
}
|
}
|
||||||
// JSON: 等宽字体,以纯字符串显示
|
// JSON: 等宽字体,以纯字符串显示
|
||||||
if (isType(type, "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}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: "max(100%, 1350px)", y: "100%" }}
|
scroll={{ x: "max(100%, 1510px)", y: "100%" }}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
/>
|
/>
|
||||||
</DataListPanel>
|
</DataListPanel>
|
||||||
|
|
@ -338,16 +502,14 @@ export default function SysParams() {
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
) : isType(paramType, "JSON") ? (
|
) : isType(paramType, "JSON") ? (
|
||||||
<Input.TextArea
|
<TemplateParamValueEditor
|
||||||
rows={6}
|
rows={6}
|
||||||
style={{ fontFamily: "'SFMono-Regular', Consolas, Menlo, monospace" }}
|
|
||||||
placeholder={t("sysParamsExt.jsonPlaceholder")}
|
placeholder={t("sysParamsExt.jsonPlaceholder")}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
const v = drawerForm.getFieldValue("paramValue");
|
const v = drawerForm.getFieldValue("paramValue");
|
||||||
if (!v) drawerForm.setFieldValue("paramValue", JSON.stringify({ key: "value" }));
|
if (!v) drawerForm.setFieldValue("paramValue", JSON.stringify({ key: "value" }));
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onTextChange={(v) => {
|
||||||
const v = e.target.value;
|
|
||||||
if (!v) {
|
if (!v) {
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
return;
|
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>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue