|
|
@@ -0,0 +1,1662 @@
|
|
|
+<template>
|
|
|
+ <div class="rich-text-editor" ref="containerRef">
|
|
|
+ <!-- 工具栏:左侧格式按钮 + 右侧插入功能 -->
|
|
|
+ <div class="toolbar">
|
|
|
+ <div class="toolbar_left">
|
|
|
+ <!-- 撤销 / 重做 -->
|
|
|
+ <a-button class="btn" type="text" @click="undo" :disabled="historyIndex <= 0" title="撤销">
|
|
|
+ <img class="btn-icon" :src="UndoIcon" alt="撤销" />
|
|
|
+ </a-button>
|
|
|
+ <a-button
|
|
|
+ class="btn"
|
|
|
+ type="text"
|
|
|
+ @click="redo"
|
|
|
+ :disabled="historyIndex >= historyStack.length - 1"
|
|
|
+ title="重做"
|
|
|
+ >
|
|
|
+ <img class="btn-icon" :src="RedoIcon" alt="重做" />
|
|
|
+ </a-button>
|
|
|
+
|
|
|
+ <!-- 加粗、斜体、下划线 -->
|
|
|
+ <a-button class="btn" type="text" :class="{ active: isBold }" @click="toggleFormat('bold')" title="加粗">
|
|
|
+ <icon-bold />
|
|
|
+ </a-button>
|
|
|
+ <a-button class="btn" type="text" :class="{ active: isItalic }" @click="toggleFormat('italic')" title="斜体">
|
|
|
+ <icon-italic />
|
|
|
+ </a-button>
|
|
|
+ <a-button
|
|
|
+ class="btn"
|
|
|
+ type="text"
|
|
|
+ :class="{ active: isUnderline }"
|
|
|
+ @click="toggleFormat('underline')"
|
|
|
+ title="下划线"
|
|
|
+ >
|
|
|
+ <icon-underline />
|
|
|
+ </a-button>
|
|
|
+
|
|
|
+ <!-- 标题选择下拉框 -->
|
|
|
+ <select v-model="headingLevel" @change="applyHeading">
|
|
|
+ <option value="">正文</option>
|
|
|
+ <option value="H1">标题 1</option>
|
|
|
+ <option value="H2">标题 2</option>
|
|
|
+ <option value="H3">标题 3</option>
|
|
|
+ <option value="H4">标题 4</option>
|
|
|
+ <option value="H5">标题 5</option>
|
|
|
+ <option value="H6">标题 6</option>
|
|
|
+ </select>
|
|
|
+
|
|
|
+ <!-- 字号选择下拉框 -->
|
|
|
+ <select v-model="fontSize" @change="applyFontSize">
|
|
|
+ <option value="">默认</option>
|
|
|
+ <option value="12">小 (12px)</option>
|
|
|
+ <option value="14">常规 (14px)</option>
|
|
|
+ <option value="16">中 (16px)</option>
|
|
|
+ <option value="18">大 (18px)</option>
|
|
|
+ <option value="20">更大 (20px)</option>
|
|
|
+ <option value="24">超大 (24px)</option>
|
|
|
+ <option value="28">特大 (28px)</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧:插入图表等扩展功能 -->
|
|
|
+ <div class="toolbar_right">
|
|
|
+ <div class="chat-select" ref="chartButtonRef">
|
|
|
+ <a-button class="insert_btn" v-if="false" @click="toggleChartPanelDropdown">
|
|
|
+ <img class="btn-icon" :src="ChatICON_a" alt="" />
|
|
|
+ 插入图表 <icon-down size="16" v-if="!showChartPanelDropdown" />
|
|
|
+ <icon-up size="16" v-else />
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
+ <!-- <div>
|
|
|
+ <button @click="insertData">插入数据</button>
|
|
|
+ </div> -->
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="common-config">
|
|
|
+ <CommonDimension :common_config="common_config" @content-changed="handleContentChanged"></CommonDimension>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 可编辑区域 -->
|
|
|
+ <div
|
|
|
+ ref="editorRef"
|
|
|
+ class="editor"
|
|
|
+ :contenteditable="true"
|
|
|
+ @dragover="onDragOverMetric"
|
|
|
+ @drop="onDropMetric"
|
|
|
+ @input="
|
|
|
+ (e) => {
|
|
|
+ onInput(e);
|
|
|
+ handleInputForMention(e);
|
|
|
+ }
|
|
|
+ "
|
|
|
+ @blur="flushPendingSave"
|
|
|
+ @mouseup="updateToolbar"
|
|
|
+ @keyup="updateToolbar"
|
|
|
+ @keydown="handleKeydownForMention"
|
|
|
+ @click="handleEditorClick"
|
|
|
+ ></div>
|
|
|
+
|
|
|
+ <!-- @mention 下拉菜单 -->
|
|
|
+ <MentionDropdown
|
|
|
+ :visible="showDropdown"
|
|
|
+ :position="dropdownPosition"
|
|
|
+ :options="metrics_list"
|
|
|
+ @select="insertOption"
|
|
|
+ ref="mentionDropdownRef"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- mention 操作菜单 -->
|
|
|
+ <MentionActionMenu
|
|
|
+ ref="actionMenuRef"
|
|
|
+ :visible="showActionMenu"
|
|
|
+ :position="actionMenuPosition"
|
|
|
+ :actions="target_action"
|
|
|
+ :actionConfig="current_action_config"
|
|
|
+ :direction="actionMenuDirection"
|
|
|
+ @action="handleAction"
|
|
|
+ />
|
|
|
+ <ChartPanelDropdown
|
|
|
+ ref="chartPanelDropdownRef"
|
|
|
+ :visible="showChartPanelDropdown"
|
|
|
+ :position="chartPanelDropdownPosition"
|
|
|
+ />
|
|
|
+ <!-- <ChartPanelDropdown
|
|
|
+ ref="chartPanelDropdownRef"
|
|
|
+ :visible="showChartPanelDropdown"
|
|
|
+ :position="chartPanelDropdownPosition"
|
|
|
+ @chart-click="handleChartPanelDropdownClick"
|
|
|
+ /> -->
|
|
|
+ </div>
|
|
|
+ <TipsModel v-model:visible="showTips" @ok="toSaveSetting" @cancel="toNotSaveSetting" />
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+/**
|
|
|
+ * 富文本编辑器组件(基于 contenteditable + document.execCommand)
|
|
|
+ * 支持:撤销/重做、加粗/斜体/下划线、标题、字号、光标位置记忆 + @mention 功能
|
|
|
+ */
|
|
|
+
|
|
|
+import { ref, onMounted, onUnmounted, nextTick, watch, computed, createApp, provide } from 'vue';
|
|
|
+import TableChart from '../editor-chart/TableChart.vue';
|
|
|
+
|
|
|
+import { generateUID } from './common-tools';
|
|
|
+
|
|
|
+import RedoIcon from '../../../assets/report-template/redo.svg';
|
|
|
+import UndoIcon from '../../../assets/report-template/undo.svg';
|
|
|
+import ChatICON_a from '../../../assets/report-template/chat_icon_a.svg';
|
|
|
+
|
|
|
+import MentionDropdown from '../editor-select/MentionDropdown.vue';
|
|
|
+import MentionActionMenu from '../editor-select/MentionActionMenu.vue';
|
|
|
+import ChartPanelDropdown from '../editor-select/ChartPanelDropdown.vue';
|
|
|
+import CommonDimension from './CommonDimension.vue';
|
|
|
+
|
|
|
+import TipsModel from './TipsModel.vue';
|
|
|
+
|
|
|
+import { useReportEditor } from '../../../base/store/use-report-editor';
|
|
|
+
|
|
|
+const reportEditor = useReportEditor();
|
|
|
+console.log(reportEditor);
|
|
|
+
|
|
|
+// ============ Props & Emits ============
|
|
|
+const props = defineProps({
|
|
|
+ modelValue: {
|
|
|
+ type: String,
|
|
|
+ default: ''
|
|
|
+ },
|
|
|
+ metrics_list: Array,
|
|
|
+ extracted_metrics: Array,
|
|
|
+ common_config: Object
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits([
|
|
|
+ 'update:modelValue',
|
|
|
+ 'mention-click',
|
|
|
+ 'close-filter-model',
|
|
|
+ 'to-save-setting',
|
|
|
+ 'submitHtml',
|
|
|
+ 'chart-click',
|
|
|
+ 'content-changed'
|
|
|
+]);
|
|
|
+
|
|
|
+// ============ Refs ============
|
|
|
+const editorRef = ref(null);
|
|
|
+const containerRef = ref(null); // 👈 新增:用于定位浮层
|
|
|
+const mentionDropdownRef = ref(null);
|
|
|
+
|
|
|
+// 图表数据存储,使用chartId作为唯一标识
|
|
|
+const chartDataStore = ref({});
|
|
|
+
|
|
|
+// 提供图表数据存储给子组件
|
|
|
+provide('chartDataStore', chartDataStore);
|
|
|
+
|
|
|
+// 工具栏状态
|
|
|
+const headingLevel = ref(''); // 当前段落是否为 H1-H6
|
|
|
+const fontSize = ref(''); // 当前选中字号(如 "14")
|
|
|
+const isBold = ref(false);
|
|
|
+const isItalic = ref(false);
|
|
|
+const isUnderline = ref(false);
|
|
|
+
|
|
|
+// ============ 历史栈(用于撤销/重做)===========
|
|
|
+const historyStack = ref([]); // 存储 { html, cursor } 快照
|
|
|
+const historyIndex = ref(-1); // 当前历史指针
|
|
|
+let isRestoring = false; // 是否正在恢复历史(避免重复保存)
|
|
|
+let pendingSaveTimeout = null; // 防抖定时器
|
|
|
+const SAVE_DELAY = 500; // 输入后延迟 500ms 保存
|
|
|
+
|
|
|
+// ========== Mention 功能(增量添加)==========
|
|
|
+const dropdownRef = ref(null);
|
|
|
+const actionMenuRef = ref(null);
|
|
|
+// ========= 图表面板功能 ==========
|
|
|
+const chartPanelDropdownRef = ref(null);
|
|
|
+const chartButtonRef = ref(null);
|
|
|
+
|
|
|
+const showDropdown = ref(false);
|
|
|
+const dropdownPosition = ref({ top: 0, left: 0 });
|
|
|
+const chartPanelDropdownPosition = ref({ top: 0, left: 0 });
|
|
|
+// 保存当前光标位置,用于处理点击插入图表按钮时光标丢失的问题
|
|
|
+const savedCursorPosition = ref(null);
|
|
|
+// 原始数据
|
|
|
+// const options = [
|
|
|
+// {
|
|
|
+// id: 'oU12ESIeGAD5qCw',
|
|
|
+// metric_name: '管控指标',
|
|
|
+// data_source_type: 1,
|
|
|
+// api_description: '决策平台-管控指标',
|
|
|
+// table_metadata: null,
|
|
|
+// is_supports_top_bottos: 0,
|
|
|
+// default_top_n: 0,
|
|
|
+// default_bottom_n: 0,
|
|
|
+// default_sort_order: 'desc',
|
|
|
+// created_at: '1995-07-18T16:22:15',
|
|
|
+// updated_at: '1995-07-18T16:22:15',
|
|
|
+// flexible_template_metric_fields: [
|
|
|
+// {
|
|
|
+// id: 'r67yzUE8c7lfhjE',
|
|
|
+// metric_id: 'oU12ESIeGAD5qCw',
|
|
|
+// field_name: '组织机构',
|
|
|
+// field_description: '组织机构',
|
|
|
+// field_type: 'str',
|
|
|
+// is_aggregatable: 0,
|
|
|
+// is_enum_filterable: 1,
|
|
|
+// enum_type_id: '2',
|
|
|
+// is_multi_select: 0,
|
|
|
+// is_and_or_condition: 0,
|
|
|
+// created_at: '2019-01-23T20:40:12',
|
|
|
+// updated_at: '1980-11-02T04:09:49'
|
|
|
+// },
|
|
|
+// {
|
|
|
+// id: '6Pva5pKIyaJn25P',
|
|
|
+// metric_id: 'oU12ESIeGAD5qCw',
|
|
|
+// field_name: '开始时间',
|
|
|
+// field_description: '开始时间',
|
|
|
+// field_type: 'date',
|
|
|
+// is_aggregatable: 0,
|
|
|
+// is_enum_filterable: 0,
|
|
|
+// enum_type_id: '0',
|
|
|
+// is_multi_select: 0,
|
|
|
+// is_and_or_condition: 0,
|
|
|
+// created_at: '1985-11-06T11:01:42',
|
|
|
+// updated_at: '2025-05-17T10:43:39'
|
|
|
+// },
|
|
|
+// {
|
|
|
+// id: '10qOzrfpUnIgxt9',
|
|
|
+// metric_id: 'oU12ESIeGAD5qCw',
|
|
|
+// field_name: '结束时间',
|
|
|
+// field_description: '结束时间',
|
|
|
+// field_type: 'date',
|
|
|
+// is_aggregatable: 0,
|
|
|
+// is_enum_filterable: 0,
|
|
|
+// enum_type_id: '0',
|
|
|
+// is_multi_select: 0,
|
|
|
+// is_and_or_condition: 0,
|
|
|
+// created_at: '1987-03-29T09:04:29',
|
|
|
+// updated_at: '1980-08-31T16:07:42'
|
|
|
+// },
|
|
|
+// {
|
|
|
+// id: 'i2P3lbpYvfPZeYT',
|
|
|
+// metric_id: 'oU12ESIeGAD5qCw',
|
|
|
+// field_name: '查询指标',
|
|
|
+// field_description: '需要查询的指标-提供枚举',
|
|
|
+// field_type: 'str',
|
|
|
+// is_aggregatable: 0,
|
|
|
+// is_enum_filterable: 1,
|
|
|
+// enum_type_id: '3',
|
|
|
+// is_multi_select: 1,
|
|
|
+// is_and_or_condition: 0,
|
|
|
+// created_at: '2017-06-09T20:49:27',
|
|
|
+// updated_at: '2024-11-14T07:21:16'
|
|
|
+// }
|
|
|
+// ]
|
|
|
+// }
|
|
|
+// ];
|
|
|
+// 替换为你的用户列表
|
|
|
+
|
|
|
+const mentionsData = ref(props.extracted_metrics || []); // 格式为 { uid: 'mention_xxx', option }
|
|
|
+const mentions_metrics_Data = computed(() => {
|
|
|
+ return reportEditor.metrics_list;
|
|
|
+}); // 格式为 { uid: 'mention_xxx', option }
|
|
|
+
|
|
|
+const showActionMenu = ref(false);
|
|
|
+const actionMenuPosition = ref({ top: 0, left: 0 });
|
|
|
+const actionMenuDirection = ref('down'); // 'down' 或 'up',控制菜单方向
|
|
|
+const currentMentionElement = ref(null);
|
|
|
+const target_action = ref(null); //当前点击的指标的advanced_computing_config配置项
|
|
|
+// 图表面板展示
|
|
|
+const showChartPanelDropdown = ref(false);
|
|
|
+
|
|
|
+const showTips = ref(false);
|
|
|
+
|
|
|
+//当前指标的advanced_computing_config配置
|
|
|
+const current_action_config = computed(() => {
|
|
|
+ return reportEditor.QueryTargetMetricConfig;
|
|
|
+});
|
|
|
+
|
|
|
+// ============ 光标位置管理 ============
|
|
|
+function getCursorPath() {
|
|
|
+ const selection = window.getSelection();
|
|
|
+ if (!selection.rangeCount || !editorRef.value) return null;
|
|
|
+
|
|
|
+ const range = selection.getRangeAt(0);
|
|
|
+ const startContainer = range.startContainer;
|
|
|
+ if (!editorRef.value.contains(startContainer)) return null;
|
|
|
+
|
|
|
+ const path = [];
|
|
|
+ let node = startContainer;
|
|
|
+ while (node !== editorRef.value) {
|
|
|
+ const parent = node.parentNode;
|
|
|
+ if (!parent) break;
|
|
|
+ let index = 0;
|
|
|
+ let sibling = node.previousSibling;
|
|
|
+ while (sibling) {
|
|
|
+ index++;
|
|
|
+ sibling = sibling.previousSibling;
|
|
|
+ }
|
|
|
+ path.unshift(index);
|
|
|
+ node = parent;
|
|
|
+ }
|
|
|
+ return { path, offset: range.startOffset };
|
|
|
+}
|
|
|
+
|
|
|
+function setCursorPath(saved) {
|
|
|
+ if (!saved || !editorRef.value) return;
|
|
|
+ const { path, offset } = saved;
|
|
|
+ let node = editorRef.value;
|
|
|
+
|
|
|
+ for (let i = 0; i < path.length; i++) {
|
|
|
+ const idx = path[i];
|
|
|
+ if (idx < node.childNodes.length) {
|
|
|
+ node = node.childNodes[idx];
|
|
|
+ } else {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (node.nodeType === Node.ELEMENT_NODE) {
|
|
|
+ const textNode = findFirstTextNode(node);
|
|
|
+ if (textNode) {
|
|
|
+ setSelection(textNode, Math.min(offset, textNode.textContent.length));
|
|
|
+ } else {
|
|
|
+ setSelection(node, node.childNodes.length);
|
|
|
+ }
|
|
|
+ } else if (node.nodeType === Node.TEXT_NODE) {
|
|
|
+ setSelection(node, Math.min(offset, node.textContent.length));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function findFirstTextNode(el) {
|
|
|
+ for (const child of el.childNodes) {
|
|
|
+ if (child.nodeType === Node.TEXT_NODE && child.textContent.trim() !== '') {
|
|
|
+ return child;
|
|
|
+ }
|
|
|
+ if (child.nodeType === Node.ELEMENT_NODE) {
|
|
|
+ const found = findFirstTextNode(child);
|
|
|
+ if (found) return found;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+function setSelection(node, offset) {
|
|
|
+ const range = document.createRange();
|
|
|
+ const sel = window.getSelection();
|
|
|
+ try {
|
|
|
+ range.setStart(node, offset);
|
|
|
+ range.collapse(true);
|
|
|
+ sel.removeAllRanges();
|
|
|
+ sel.addRange(range);
|
|
|
+ } catch (e) {
|
|
|
+ // 容错:节点可能已被移除
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ============ 历史保存逻辑 ============
|
|
|
+function debouncedSave() {
|
|
|
+ if (isRestoring) return;
|
|
|
+ if (pendingSaveTimeout) clearTimeout(pendingSaveTimeout);
|
|
|
+ pendingSaveTimeout = setTimeout(() => {
|
|
|
+ const content = editorRef.value.innerHTML;
|
|
|
+ const cursor = getCursorPath();
|
|
|
+ const mentionsDataSnapshot = JSON.parse(JSON.stringify(mentionsData.value));
|
|
|
+ saveToHistory(content, cursor, mentionsDataSnapshot);
|
|
|
+ pendingSaveTimeout = null;
|
|
|
+ }, SAVE_DELAY);
|
|
|
+}
|
|
|
+
|
|
|
+function immediateSave() {
|
|
|
+ if (isRestoring) return;
|
|
|
+ if (pendingSaveTimeout) {
|
|
|
+ clearTimeout(pendingSaveTimeout);
|
|
|
+ pendingSaveTimeout = null;
|
|
|
+ }
|
|
|
+ const content = editorRef.value.innerHTML;
|
|
|
+ const cursor = getCursorPath();
|
|
|
+ const mentionsDataSnapshot = JSON.parse(JSON.stringify(mentionsData.value));
|
|
|
+ saveToHistory(content, cursor, mentionsDataSnapshot);
|
|
|
+ syncMentionsData();
|
|
|
+}
|
|
|
+
|
|
|
+function saveToHistory(content, cursor, mentionsDataSnapshot) {
|
|
|
+ const current = historyStack.value[historyIndex.value];
|
|
|
+ if (current && current.html === content) return;
|
|
|
+
|
|
|
+ if (historyIndex.value < historyStack.value.length - 1) {
|
|
|
+ historyStack.value = historyStack.value.slice(0, historyIndex.value + 1);
|
|
|
+ }
|
|
|
+ historyStack.value.push({
|
|
|
+ html: content,
|
|
|
+ cursor,
|
|
|
+ mentionsData: mentionsDataSnapshot ? JSON.parse(JSON.stringify(mentionsDataSnapshot)) : null
|
|
|
+ });
|
|
|
+ historyIndex.value = historyStack.value.length - 1;
|
|
|
+}
|
|
|
+
|
|
|
+// ============ 撤销 / 重做 ============
|
|
|
+function undo() {
|
|
|
+ if (historyIndex.value <= 0 || !editorRef.value) return;
|
|
|
+ isRestoring = true;
|
|
|
+ historyIndex.value--;
|
|
|
+ const { html, cursor, mentionsData: savedMentionsData } = historyStack.value[historyIndex.value];
|
|
|
+ editorRef.value.innerHTML = html;
|
|
|
+ emit('submitHtml', html);
|
|
|
+ emit('update:modelValue', html);
|
|
|
+ nextTick(() => {
|
|
|
+ updateToolbar();
|
|
|
+ setCursorPath(cursor);
|
|
|
+ if (savedMentionsData) {
|
|
|
+ mentionsData.value = JSON.parse(JSON.stringify(savedMentionsData));
|
|
|
+ } else {
|
|
|
+ syncMentionsData();
|
|
|
+ }
|
|
|
+ isRestoring = false;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function redo() {
|
|
|
+ if (historyIndex.value >= historyStack.value.length - 1 || !editorRef.value) return;
|
|
|
+ isRestoring = true;
|
|
|
+ historyIndex.value++;
|
|
|
+ const { html, cursor, mentionsData: savedMentionsData } = historyStack.value[historyIndex.value];
|
|
|
+ editorRef.value.innerHTML = html;
|
|
|
+ emit('submitHtml', html);
|
|
|
+ emit('update:modelValue', html);
|
|
|
+ nextTick(() => {
|
|
|
+ updateToolbar();
|
|
|
+ setCursorPath(cursor);
|
|
|
+ if (savedMentionsData) {
|
|
|
+ mentionsData.value = JSON.parse(JSON.stringify(savedMentionsData));
|
|
|
+ } else {
|
|
|
+ syncMentionsData();
|
|
|
+ }
|
|
|
+ isRestoring = false;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function flushPendingSave() {
|
|
|
+ if (pendingSaveTimeout) {
|
|
|
+ clearTimeout(pendingSaveTimeout);
|
|
|
+ pendingSaveTimeout = null;
|
|
|
+ const content = editorRef.value.innerHTML;
|
|
|
+ const cursor = getCursorPath();
|
|
|
+ saveToHistory(content, cursor);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ============ 生命周期 ============
|
|
|
+onMounted(() => {
|
|
|
+ if (editorRef.value) {
|
|
|
+ const initialContent = props.modelValue || '';
|
|
|
+ editorRef.value.innerHTML = initialContent;
|
|
|
+ saveToHistory(initialContent, null);
|
|
|
+ updateToolbar();
|
|
|
+ syncMentionsData(); // 👈
|
|
|
+ }
|
|
|
+
|
|
|
+ document.addEventListener('selectionchange', handleSelectionChange);
|
|
|
+ document.addEventListener('click', handleDocumentClick);
|
|
|
+ mentionsData.value = props.extracted_metrics;
|
|
|
+ emit('submitHtml', editorRef.value.innerHTML);
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ if (pendingSaveTimeout) clearTimeout(pendingSaveTimeout);
|
|
|
+ document.removeEventListener('selectionchange', handleSelectionChange);
|
|
|
+ document.removeEventListener('click', handleDocumentClick);
|
|
|
+});
|
|
|
+
|
|
|
+// 处理document点击事件,关闭图表面板下拉框
|
|
|
+function handleDocumentClick(e) {
|
|
|
+ if (
|
|
|
+ showChartPanelDropdown.value &&
|
|
|
+ chartPanelDropdownRef.value &&
|
|
|
+ !chartPanelDropdownRef.value.$el?.contains(e.target) &&
|
|
|
+ !chartButtonRef.value?.contains(e.target)
|
|
|
+ ) {
|
|
|
+ showChartPanelDropdown.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleSelectionChange() {
|
|
|
+ if (editorRef.value && document.activeElement === editorRef.value) {
|
|
|
+ updateToolbar();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ============ 工具栏状态同步 ============
|
|
|
+const updateToolbar = () => {
|
|
|
+ const selection = window.getSelection();
|
|
|
+ if (!selection.rangeCount || !editorRef.value) return;
|
|
|
+
|
|
|
+ const range = selection.getRangeAt(0);
|
|
|
+ let node = range.commonAncestorContainer;
|
|
|
+ if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
|
|
+
|
|
|
+ isBold.value = document.queryCommandState('bold');
|
|
|
+ isItalic.value = document.queryCommandState('italic');
|
|
|
+ isUnderline.value = document.queryCommandState('underline');
|
|
|
+
|
|
|
+ let headingNode = node;
|
|
|
+ while (headingNode && headingNode !== editorRef.value) {
|
|
|
+ if (headingNode.nodeType === Node.ELEMENT_NODE) {
|
|
|
+ const tagName = headingNode.tagName;
|
|
|
+ if (tagName === 'P' || (tagName.startsWith('H') && /^[1-6]$/.test(tagName.slice(1)))) {
|
|
|
+ headingLevel.value = tagName === 'P' ? '' : tagName;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ headingNode = headingNode.parentElement;
|
|
|
+ }
|
|
|
+ if (!headingNode || headingNode === editorRef.value) {
|
|
|
+ headingLevel.value = '';
|
|
|
+ }
|
|
|
+
|
|
|
+ let fontSizeNode = node;
|
|
|
+ let detectedSize = '';
|
|
|
+ while (fontSizeNode && fontSizeNode !== editorRef.value) {
|
|
|
+ if (fontSizeNode.nodeType === Node.ELEMENT_NODE) {
|
|
|
+ const style = window.getComputedStyle(fontSizeNode);
|
|
|
+ const fs = style.fontSize;
|
|
|
+ if (fs && fs !== '16px') {
|
|
|
+ detectedSize = parseInt(fs, 10).toString();
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ fontSizeNode = fontSizeNode.parentElement;
|
|
|
+ }
|
|
|
+ fontSize.value = detectedSize;
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 格式操作 ============
|
|
|
+const toggleFormat = (command) => {
|
|
|
+ if (!editorRef.value) return;
|
|
|
+ editorRef.value.focus();
|
|
|
+ flushPendingSave();
|
|
|
+
|
|
|
+ document.execCommand(command);
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ const content = editorRef.value.innerHTML;
|
|
|
+ emit('submitHtml', content);
|
|
|
+ emit('update:modelValue', content);
|
|
|
+ emit('content-changed', content);
|
|
|
+ immediateSave();
|
|
|
+ updateToolbar();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const applyHeading = () => {
|
|
|
+ if (!editorRef.value) return;
|
|
|
+ editorRef.value.focus();
|
|
|
+ flushPendingSave();
|
|
|
+
|
|
|
+ const tagName = headingLevel.value || 'P';
|
|
|
+ document.execCommand('formatBlock', false, `<${tagName}>`);
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ const content = editorRef.value.innerHTML;
|
|
|
+ emit('submitHtml', content);
|
|
|
+ emit('update:modelValue', content);
|
|
|
+ emit('content-changed', content);
|
|
|
+ immediateSave();
|
|
|
+ updateToolbar();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const applyFontSize = () => {
|
|
|
+ if (!editorRef.value) return;
|
|
|
+ editorRef.value.focus();
|
|
|
+ flushPendingSave();
|
|
|
+
|
|
|
+ const size = fontSize.value;
|
|
|
+ const selection = window.getSelection();
|
|
|
+ if (selection.rangeCount === 0) return;
|
|
|
+
|
|
|
+ const range = selection.getRangeAt(0);
|
|
|
+ const extracted = range.extractContents();
|
|
|
+
|
|
|
+ function unwrapFontSizeSpans(parent) {
|
|
|
+ const walker = document.createTreeWalker(parent, NodeFilter.SHOW_ELEMENT, {
|
|
|
+ acceptNode(node) {
|
|
|
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SPAN' && node.style.fontSize) {
|
|
|
+ return NodeFilter.FILTER_ACCEPT;
|
|
|
+ }
|
|
|
+ return NodeFilter.FILTER_SKIP;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const nodesToRemove = [];
|
|
|
+ let node;
|
|
|
+ while ((node = walker.nextNode())) {
|
|
|
+ nodesToRemove.push(node);
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let i = nodesToRemove.length - 1; i >= 0; i--) {
|
|
|
+ const el = nodesToRemove[i];
|
|
|
+ const parent = el.parentNode;
|
|
|
+ while (el.firstChild) {
|
|
|
+ parent.insertBefore(el.firstChild, el);
|
|
|
+ }
|
|
|
+ parent.removeChild(el);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const fragment = document.createDocumentFragment();
|
|
|
+ fragment.appendChild(extracted);
|
|
|
+ unwrapFontSizeSpans(fragment);
|
|
|
+
|
|
|
+ if (size) {
|
|
|
+ const newSpan = document.createElement('span');
|
|
|
+ newSpan.style.fontSize = size + 'px';
|
|
|
+ newSpan.appendChild(fragment);
|
|
|
+ range.insertNode(newSpan);
|
|
|
+
|
|
|
+ const newRange = document.createRange();
|
|
|
+ newRange.selectNodeContents(newSpan);
|
|
|
+ selection.removeAllRanges();
|
|
|
+ selection.addRange(newRange);
|
|
|
+ } else {
|
|
|
+ range.insertNode(fragment);
|
|
|
+
|
|
|
+ const commonAncestor = range.commonAncestorContainer;
|
|
|
+ let container = commonAncestor.nodeType === Node.TEXT_NODE ? commonAncestor.parentElement : commonAncestor;
|
|
|
+
|
|
|
+ while (container && container !== editorRef.value) {
|
|
|
+ if (container.tagName && /^H[1-6]$/.test(container.tagName)) {
|
|
|
+ container.style.fontSize = '';
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ container = container.parentElement;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ const content = editorRef.value.innerHTML;
|
|
|
+ emit('submitHtml', content);
|
|
|
+ emit('update:modelValue', content);
|
|
|
+ emit('content-changed', content);
|
|
|
+ immediateSave();
|
|
|
+ updateToolbar();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 输入监听(原有)===========
|
|
|
+const onInput = () => {
|
|
|
+ if (editorRef.value && !isRestoring) {
|
|
|
+ emit('submitHtml', editorRef.value.innerHTML);
|
|
|
+ emit('update:modelValue', editorRef.value.innerHTML);
|
|
|
+ emit('content-changed', editorRef.value.innerHTML);
|
|
|
+ debouncedSave();
|
|
|
+ syncMentionsData();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleContentChanged = () => {
|
|
|
+ emit('content-changed');
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 外部数据更新监听 ============
|
|
|
+watch(
|
|
|
+ () => props.modelValue,
|
|
|
+ (newVal) => {
|
|
|
+ if (!editorRef.value) return;
|
|
|
+ const finalContent = newVal || '<p>请输入内容...</p>';
|
|
|
+ if (editorRef.value.innerHTML !== finalContent) {
|
|
|
+ editorRef.value.innerHTML = finalContent;
|
|
|
+ saveToHistory(finalContent, null);
|
|
|
+ updateToolbar();
|
|
|
+ syncMentionsData();
|
|
|
+ emit('submitHtml', finalContent);
|
|
|
+ }
|
|
|
+ }
|
|
|
+);
|
|
|
+watch(
|
|
|
+ () => props.extracted_metrics,
|
|
|
+ () => {
|
|
|
+ mentionsData.value = props.extracted_metrics;
|
|
|
+ }
|
|
|
+);
|
|
|
+watch(
|
|
|
+ () => mentionsData.value,
|
|
|
+ (list) => {
|
|
|
+ console.log('mentionsData', list);
|
|
|
+ reportEditor.updateMetricsList(list);
|
|
|
+ },
|
|
|
+ {
|
|
|
+ deep: true
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+// ============ 扩展功能占位 ============
|
|
|
+const handleSelect = (value) => {
|
|
|
+ console.log('Selected:', value);
|
|
|
+};
|
|
|
+
|
|
|
+function triggerDropdown() {
|
|
|
+ const range = window.getSelection()?.getRangeAt(0);
|
|
|
+ if (!range || !editorRef.value?.contains(range.startContainer)) return;
|
|
|
+
|
|
|
+ const rect = range.getBoundingClientRect();
|
|
|
+ const containerRect = containerRef.value.getBoundingClientRect();
|
|
|
+ const editorHeight = containerRef.value.offsetHeight;
|
|
|
+
|
|
|
+ // 预估下拉框高度(MentionDropdown 最大高度为 200px)
|
|
|
+ // 修复:先计算itemCount,再计算高度,确保运算符优先级正确
|
|
|
+ const itemCount = props.metrics_list?.length || 0;
|
|
|
+ const dropdownHeight = Math.min(itemCount * 30 + 20, 200);
|
|
|
+
|
|
|
+ let top = rect.bottom - containerRect.top;
|
|
|
+ let left = rect.left - containerRect.left;
|
|
|
+
|
|
|
+ // 检查是否超出编辑器下边界
|
|
|
+ if (top + dropdownHeight > editorHeight) {
|
|
|
+ // 显示在元素上方
|
|
|
+ top = rect.top - containerRect.top - dropdownHeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ dropdownPosition.value = {
|
|
|
+ top: top,
|
|
|
+ left: left
|
|
|
+ };
|
|
|
+
|
|
|
+ showDropdown.value = true;
|
|
|
+}
|
|
|
+
|
|
|
+function insertOption(opt) {
|
|
|
+ const sel = window.getSelection();
|
|
|
+ if (sel.rangeCount === 0 || !editorRef.value) return;
|
|
|
+
|
|
|
+ const range = sel.getRangeAt(0);
|
|
|
+
|
|
|
+ if (range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
|
|
+ const testRange = range.cloneRange();
|
|
|
+ testRange.setStart(range.startContainer, range.startOffset - 1);
|
|
|
+ const charBefore = testRange.toString();
|
|
|
+ if (charBefore === '@') {
|
|
|
+ range.setStart(range.startContainer, range.startOffset - 1);
|
|
|
+ range.deleteContents();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const uid = generateUID();
|
|
|
+
|
|
|
+ const placeholder = document.createElement('span');
|
|
|
+ // placeholder.textContent = `{{${opt.metric_name}}} `;
|
|
|
+ placeholder.setAttribute('contenteditable', 'false');
|
|
|
+ placeholder.setAttribute('data-mention', 'true');
|
|
|
+ placeholder.setAttribute('data-uid', uid);
|
|
|
+ placeholder.style.cssText = `
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ background-color: var(--question_bg);
|
|
|
+ border-radius: 4px;
|
|
|
+ padding: 0 4px;
|
|
|
+ margin: 0 2px;}
|
|
|
+ user-select: none;
|
|
|
+ -webkit-user-select: none;
|
|
|
+ cursor: pointer;
|
|
|
+ color: var(--primary-default);
|
|
|
+ `;
|
|
|
+ const svg_icon = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
|
+ fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_2_674"><rect x="0" y="0"
|
|
|
+ width="16" height="26" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_2_674)"><g>
|
|
|
+ <path d="M12.30009745625,5.94999981C12.15009785625,5.799999714,11.95009705625,5.75,11.75009725625,5.75L4.25009751625
|
|
|
+ ,5.800000191C4.05009770625,5.800000191,3.85009741625,5.85000038,3.70009755625,6C3.40009760825,6.30000019,3.40009760825,6.75,3.70009755625,
|
|
|
+ 7.0500001999999995L7.45009735625,10.8000002C7.50009725625,10.850000399999999,7.60009765625,10.9000006,7.65009685625,10.9499998C7.65009685625,
|
|
|
+ 10.9499998,7.70009705625,11,7.70009705625,11C7.95009705625,11.100000399999999,8.30009745625,11.0500002,8.500097256250001,10.850000399999999L12.25009725625,
|
|
|
+ 7.0500001999999995C12.60009765625,6.69999981,12.55009745625,6.25,12.30009745625,5.94999981Z" fill="var(--primary-default)" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>`;
|
|
|
+ placeholder.innerHTML = `{{${opt.field_name}}} ${svg_icon}`;
|
|
|
+ const zeroWidth = '\u200B';
|
|
|
+ const after = document.createTextNode(zeroWidth);
|
|
|
+ range.insertNode(after);
|
|
|
+ range.insertNode(placeholder);
|
|
|
+
|
|
|
+ const newRange = document.createRange();
|
|
|
+ newRange.setStartAfter(after);
|
|
|
+ newRange.collapse(true);
|
|
|
+ sel.removeAllRanges();
|
|
|
+ sel.addRange(newRange);
|
|
|
+ // 保存到MentionData列表中
|
|
|
+
|
|
|
+ mentionsData.value.push({
|
|
|
+ uid,
|
|
|
+ option: opt
|
|
|
+ });
|
|
|
+
|
|
|
+ hideDropdown();
|
|
|
+ hideActionMenu();
|
|
|
+ immediateSave(); // 👈 接入你的历史机制
|
|
|
+ emit('submitHtml', editorRef.value.innerHTML);
|
|
|
+ emit('content-changed', editorRef.value.innerHTML);
|
|
|
+}
|
|
|
+// 当编辑器内容改变时,同步数据
|
|
|
+function syncMentionsData() {
|
|
|
+ if (!editorRef.value) return;
|
|
|
+
|
|
|
+ // 获取当前 DOM 中所有 mention 元素的 UID 集合
|
|
|
+ const currentUIDs = new Set();
|
|
|
+ const mentionsInDOM = editorRef.value.querySelectorAll('[data-mention][data-uid]');
|
|
|
+ // 使用for循环替代forEach以提高性能
|
|
|
+ for (let i = 0, len = mentionsInDOM.length; i < len; i++) {
|
|
|
+ const uid = mentionsInDOM[i].getAttribute('data-uid');
|
|
|
+ if (uid) currentUIDs.add(uid);
|
|
|
+ }
|
|
|
+ // 过滤 mentionsData,只保留还在 DOM 中的
|
|
|
+ mentionsData.value = mentionsData.value.filter((item) => currentUIDs.has(item.uid));
|
|
|
+}
|
|
|
+function handleInputForMention(e) {
|
|
|
+ if (isRestoring) return;
|
|
|
+
|
|
|
+ const selection = window.getSelection();
|
|
|
+ if (!selection?.rangeCount || !editorRef.value) return;
|
|
|
+
|
|
|
+ const range = selection.getRangeAt(0);
|
|
|
+ if (!editorRef.value.contains(range.startContainer)) return;
|
|
|
+
|
|
|
+ let charBeforeCursor = '';
|
|
|
+ if (range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
|
|
+ const testRange = range.cloneRange();
|
|
|
+ testRange.setStart(range.startContainer, range.startOffset - 1);
|
|
|
+ charBeforeCursor = testRange.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (charBeforeCursor === '@') {
|
|
|
+ if (!showDropdown.value) {
|
|
|
+ triggerDropdown();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ hideDropdown();
|
|
|
+ hideActionMenu();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleKeydownForMention(e) {
|
|
|
+ if (showDropdown.value && mentionDropdownRef.value) {
|
|
|
+ mentionDropdownRef.value.handleKeydown(e);
|
|
|
+ if (e.key === 'Escape') {
|
|
|
+ hideDropdown();
|
|
|
+ hideActionMenu();
|
|
|
+ }
|
|
|
+ // 如果已处理(如 Enter),阻止默认
|
|
|
+ if (['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) {
|
|
|
+ e.preventDefault();
|
|
|
+ }
|
|
|
+ } else if (e.key === 'Escape') {
|
|
|
+ hideDropdown();
|
|
|
+ hideActionMenu();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function hideDropdown() {
|
|
|
+ showDropdown.value = false;
|
|
|
+}
|
|
|
+
|
|
|
+function hideActionMenu() {
|
|
|
+ showActionMenu.value = false;
|
|
|
+ currentMentionElement.value = null;
|
|
|
+}
|
|
|
+// 编辑器中的点击事件合集
|
|
|
+function handleEditorClick(e) {
|
|
|
+ console.log('handleEditorClick', e.target);
|
|
|
+ console.log('handleEditorClick', actionMenuRef.value.$el);
|
|
|
+ // console.log('handleEditorClick', actionMenuRef.value?.contains(e.target));
|
|
|
+ if (actionMenuRef.value && !actionMenuRef.value.$el?.contains(e.target)) {
|
|
|
+ hideActionMenu();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 关闭图表面板下拉框
|
|
|
+ if (
|
|
|
+ chartPanelDropdownRef.value &&
|
|
|
+ !chartPanelDropdownRef.value.$el?.contains(e.target) &&
|
|
|
+ !chartButtonRef.value?.contains(e.target)
|
|
|
+ ) {
|
|
|
+ showChartPanelDropdown.value = false;
|
|
|
+ }
|
|
|
+ // console.log('reportEditor.is_need_save', reportEditor.QueryNeedSave);
|
|
|
+ // if (reportEditor.QueryNeedSave) {
|
|
|
+ // showTips.value = true;
|
|
|
+ // return;
|
|
|
+ // }
|
|
|
+
|
|
|
+ // 移除所有已有的高亮样式
|
|
|
+ const allMentions = editorRef.value.querySelectorAll('[data-mention]');
|
|
|
+ const allCharts = editorRef.value.querySelectorAll('[data-chart]');
|
|
|
+ allMentions.forEach((mention) => {
|
|
|
+ mention.classList.remove('mention-highlighted');
|
|
|
+ });
|
|
|
+ allCharts.forEach((chart) => {
|
|
|
+ chart.classList.remove('chart-selected');
|
|
|
+ });
|
|
|
+ // 关闭过滤器
|
|
|
+ closeFilterModel();
|
|
|
+
|
|
|
+ // 如果点击的是提及
|
|
|
+ const mention = e.target.closest('[data-mention]');
|
|
|
+ if (mention) {
|
|
|
+ const uid = mention?.getAttribute('data-uid');
|
|
|
+ const option = mentionsData.value.find((item) => item.uid === uid);
|
|
|
+ reportEditor.setTargetMetricId(uid);
|
|
|
+
|
|
|
+ // 添加高亮样式
|
|
|
+ mention.classList.add('mention-highlighted');
|
|
|
+ // console.log(option.option.advanced_computing_config);
|
|
|
+ // console.log(JSON.parse(option.option.advanced_computing_config));
|
|
|
+ console.log(option.option, 999);
|
|
|
+ target_action.value = JSON.parse(option.option.source.advanced_computing_config);
|
|
|
+ // 保存当前指标引用,用于后续位置调整
|
|
|
+ currentMentionElement.value = mention;
|
|
|
+
|
|
|
+ // 给父组件一个触发一个事件,显示过滤器
|
|
|
+ emit('mention-click', option);
|
|
|
+
|
|
|
+ // 使用 setTimeout 确保过滤器组件已经显示,富文本宽度已经调整
|
|
|
+ setTimeout(() => {
|
|
|
+ if (!currentMentionElement.value) return;
|
|
|
+
|
|
|
+ // 重新获取指标元素
|
|
|
+ const mention = currentMentionElement.value;
|
|
|
+
|
|
|
+ // 1. 先滚动到指标位置
|
|
|
+ mention.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
+
|
|
|
+ // 2. 滚动后再重新计算位置和高度
|
|
|
+ setTimeout(() => {
|
|
|
+ // 重新获取元素位置,因为滚动后位置可能变化
|
|
|
+ const rect = mention.getBoundingClientRect();
|
|
|
+ const containerRect = containerRef.value.getBoundingClientRect();
|
|
|
+ const editorHeight = containerRef.value.offsetHeight;
|
|
|
+
|
|
|
+ // 预估菜单高度:一级菜单约 120px,二级菜单约 200px
|
|
|
+ const primaryMenuHeight = 120; // 一级菜单高度
|
|
|
+ const secondaryMenuHeight = 200; // 二级菜单最大高度
|
|
|
+
|
|
|
+ let top = rect.bottom - containerRect.top;
|
|
|
+ let direction = 'down';
|
|
|
+
|
|
|
+ // 使用二级菜单高度判断是否超出边界
|
|
|
+ if (top + secondaryMenuHeight > editorHeight) {
|
|
|
+ // 显示在元素上方,使用一级菜单高度计算位置
|
|
|
+ top = rect.top - containerRect.top - primaryMenuHeight - 10;
|
|
|
+ direction = 'up';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置菜单方向
|
|
|
+ actionMenuDirection.value = direction;
|
|
|
+
|
|
|
+ actionMenuPosition.value = {
|
|
|
+ top: top,
|
|
|
+ left: rect.left - containerRect.left
|
|
|
+ };
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ showActionMenu.value = true;
|
|
|
+ });
|
|
|
+ }, 300); // 增加延迟,确保滚动完成
|
|
|
+ }, 300); // 增加延迟,确保过滤器组件完全显示
|
|
|
+
|
|
|
+ e.stopPropagation();
|
|
|
+ }
|
|
|
+ // 如果点击的是图表
|
|
|
+ const chart = e.target.closest('[data-chart]');
|
|
|
+ if (chart) {
|
|
|
+ const chartId = chart.getAttribute('data-chart-id');
|
|
|
+ const chartType = chart.getAttribute('data-chart-type');
|
|
|
+
|
|
|
+ // 移除所有图表的选中状态
|
|
|
+ const allCharts = editorRef.value.querySelectorAll('[data-chart]');
|
|
|
+ allCharts.forEach((c) => {
|
|
|
+ c.classList.remove('chart-selected');
|
|
|
+ });
|
|
|
+
|
|
|
+ // 添加选中状态
|
|
|
+ chart.classList.add('chart-selected');
|
|
|
+
|
|
|
+ // 提交点击事件
|
|
|
+ emit('chart-click', {
|
|
|
+ chartId,
|
|
|
+ chartType,
|
|
|
+ chartData: chartDataStore.value[chartId]
|
|
|
+ });
|
|
|
+
|
|
|
+ e.stopPropagation();
|
|
|
+ }
|
|
|
+}
|
|
|
+const closeFilterModel = () => {
|
|
|
+ emit('close-filter-model');
|
|
|
+};
|
|
|
+
|
|
|
+const toSaveSetting = () => {
|
|
|
+ showTips.value = false;
|
|
|
+ emit('to-save-setting');
|
|
|
+};
|
|
|
+const toNotSaveSetting = () => {
|
|
|
+ showTips.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 检查是否在受限节点内(data-mention 或 data-chart 节点)
|
|
|
+function isInsideRestrictedNode(element) {
|
|
|
+ let current = element;
|
|
|
+ while (current && current !== editorRef.value) {
|
|
|
+ if (current.nodeType === Node.TEXT_NODE) {
|
|
|
+ current = current.parentElement;
|
|
|
+ if (!current) break;
|
|
|
+ }
|
|
|
+ if (current.dataset.mention === 'true' || current.dataset.chart === 'true') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ current = current.parentElement;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+// ============ 拖拽接收逻辑 ============
|
|
|
+const onDragOverMetric = (event) => {
|
|
|
+ // 可选:只允许特定类型拖入
|
|
|
+ event.preventDefault();
|
|
|
+ event.stopPropagation();
|
|
|
+
|
|
|
+ // 检查是否在受限节点内
|
|
|
+ if (isInsideRestrictedNode(event.target)) {
|
|
|
+ event.dataTransfer.dropEffect = 'none';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (editorRef.value && document.activeElement !== editorRef.value) {
|
|
|
+ editorRef.value.focus({ preventScroll: true });
|
|
|
+ }
|
|
|
+ event.dataTransfer.dropEffect = 'copy'; // 不再判断类型
|
|
|
+};
|
|
|
+
|
|
|
+// 根据 drop 坐标获取富文本内的有效 Range
|
|
|
+function getDropRangeInEditor(x, y) {
|
|
|
+ if (!editorRef.value) return null;
|
|
|
+
|
|
|
+ // 1. 使用标准 API(Chrome / Safari)
|
|
|
+ if (document.caretRangeFromPoint) {
|
|
|
+ const range = document.caretRangeFromPoint(x, y);
|
|
|
+ if (range && editorRef.value.contains(range.startContainer)) {
|
|
|
+ return range;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. Firefox 或 fallback:用 elementFromPoint
|
|
|
+ const el = document.elementFromPoint(x, y);
|
|
|
+ if (!el || !editorRef.value.contains(el)) return null;
|
|
|
+
|
|
|
+ // 如果点中的是 mention 元素(不可编辑),找最近的可插入位置
|
|
|
+ let target = el.closest('[contenteditable="false"]') ? el.parentElement : el;
|
|
|
+
|
|
|
+ // 确保 target 在 editor 内
|
|
|
+ while (target && target !== editorRef.value && !target.isEqualNode(editorRef.value)) {
|
|
|
+ if (target.nodeType === Node.ELEMENT_NODE) break;
|
|
|
+ target = target.parentNode;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!target || target === editorRef.value) {
|
|
|
+ // 直接插入到编辑器末尾
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const range = document.createRange();
|
|
|
+ try {
|
|
|
+ range.selectNodeContents(target);
|
|
|
+ range.collapse(false); // 插入到元素末尾
|
|
|
+ } catch (e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return range;
|
|
|
+}
|
|
|
+
|
|
|
+const onDropMetric = (event) => {
|
|
|
+ event.preventDefault();
|
|
|
+ event.stopPropagation();
|
|
|
+
|
|
|
+ // 检查是否在受限节点内
|
|
|
+ if (isInsideRestrictedNode(event.target)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 强制聚焦富文本(即使没光标)
|
|
|
+ if (editorRef.value && document.activeElement !== editorRef.value) {
|
|
|
+ editorRef.value.focus({ preventScroll: true });
|
|
|
+ }
|
|
|
+ const text = event.dataTransfer.getData('application/x-metric');
|
|
|
+ if (!text) return;
|
|
|
+ let item;
|
|
|
+ try {
|
|
|
+ item = JSON.parse(text);
|
|
|
+ console.log(item, 9999);
|
|
|
+ if (!item || !item.field_name) return;
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('Drop parse error:', e);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 👇 关键:根据鼠标位置获取插入点
|
|
|
+ let range = getDropRangeInEditor(event.clientX, event.clientY);
|
|
|
+
|
|
|
+ if (!range) {
|
|
|
+ // Fallback: 插入到富文本末尾
|
|
|
+ const editor = editorRef.value;
|
|
|
+ const lastChild = editor.lastChild;
|
|
|
+
|
|
|
+ range = document.createRange();
|
|
|
+ if (lastChild) {
|
|
|
+ range.selectNodeContents(lastChild);
|
|
|
+ range.collapse(false);
|
|
|
+ } else {
|
|
|
+ // 编辑器完全为空
|
|
|
+ range.selectNodeContents(editor);
|
|
|
+ range.collapse(true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置 selection 到目标位置
|
|
|
+ const sel = window.getSelection();
|
|
|
+ sel.removeAllRanges();
|
|
|
+ sel.addRange(range);
|
|
|
+
|
|
|
+ // 调用你已有的 insertOption(它会基于当前 selection 插入)
|
|
|
+ insertOption(item);
|
|
|
+};
|
|
|
+function handleAction(action) {
|
|
|
+ const mention = currentMentionElement.value;
|
|
|
+ if (!mention || !editorRef.value) return;
|
|
|
+
|
|
|
+ switch (action) {
|
|
|
+ case 'delete':
|
|
|
+ mention.remove();
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'edit':
|
|
|
+ const rawText = mention.textContent.slice(1, -1);
|
|
|
+ const textNode = document.createTextNode(`@${rawText}`);
|
|
|
+ mention.replaceWith(textNode);
|
|
|
+
|
|
|
+ const range = document.createRange();
|
|
|
+ range.setStartAfter(textNode);
|
|
|
+ range.collapse(true);
|
|
|
+ const sel = window.getSelection();
|
|
|
+ sel.removeAllRanges();
|
|
|
+ sel.addRange(range);
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ editorRef.value.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
+ });
|
|
|
+ return;
|
|
|
+
|
|
|
+ case 'view':
|
|
|
+ const name = mention.textContent.slice(1, -1);
|
|
|
+ alert(`查看详情: ${name}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ hideActionMenu();
|
|
|
+ immediateSave();
|
|
|
+ emit('submitHtml', editorRef.value.innerHTML);
|
|
|
+ emit('content-changed', editorRef.value.innerHTML);
|
|
|
+}
|
|
|
+
|
|
|
+// 处理图表面板下拉菜单点击-插入图表节点
|
|
|
+function handleChartPanelDropdownClick(item) {
|
|
|
+ showChartPanelDropdown.value = false;
|
|
|
+
|
|
|
+ if (!editorRef.value) return;
|
|
|
+
|
|
|
+ // 强制聚焦富文本
|
|
|
+ editorRef.value.focus({ preventScroll: true });
|
|
|
+
|
|
|
+ const selection = window.getSelection();
|
|
|
+ const editor = editorRef.value;
|
|
|
+ let insertRange;
|
|
|
+
|
|
|
+ // 优先使用保存的光标位置
|
|
|
+ if (savedCursorPosition.value && editor.contains(savedCursorPosition.value.startContainer)) {
|
|
|
+ insertRange = savedCursorPosition.value;
|
|
|
+ } else if (selection.rangeCount && editor.contains(selection.getRangeAt(0).startContainer)) {
|
|
|
+ const currentRange = selection.getRangeAt(0);
|
|
|
+
|
|
|
+ // 判断光标是否在编辑器开头
|
|
|
+ let isAtStart = false;
|
|
|
+
|
|
|
+ // 情况1:编辑器为空
|
|
|
+ if (editor.innerHTML.trim() === '') {
|
|
|
+ isAtStart = true;
|
|
|
+ } else {
|
|
|
+ // 情况2:光标在第一个节点的开头
|
|
|
+ const firstChild = editor.firstChild;
|
|
|
+ if (firstChild) {
|
|
|
+ // 获取编辑器的第一个文本节点
|
|
|
+ let firstTextNode = null;
|
|
|
+ let node = firstChild;
|
|
|
+
|
|
|
+ // 查找第一个文本节点
|
|
|
+ while (node && !firstTextNode) {
|
|
|
+ if (node.nodeType === Node.TEXT_NODE) {
|
|
|
+ firstTextNode = node;
|
|
|
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
|
+ if (node.firstChild) {
|
|
|
+ node = node.firstChild;
|
|
|
+ } else {
|
|
|
+ // 空元素,直接判断
|
|
|
+ firstTextNode = node;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ node = node.nextSibling;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 比较当前光标位置与第一个文本节点的位置
|
|
|
+ if (firstTextNode) {
|
|
|
+ isAtStart = currentRange.startContainer === firstTextNode && currentRange.startOffset === 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isAtStart) {
|
|
|
+ // 光标在开头,插入到末尾
|
|
|
+ insertRange = document.createRange();
|
|
|
+ const lastChild = editor.lastChild;
|
|
|
+
|
|
|
+ if (lastChild) {
|
|
|
+ insertRange.selectNodeContents(lastChild);
|
|
|
+ insertRange.collapse(false); // 折叠到末尾
|
|
|
+ } else {
|
|
|
+ insertRange.selectNodeContents(editor);
|
|
|
+ insertRange.collapse(true);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 光标不在开头,使用当前光标位置
|
|
|
+ insertRange = currentRange;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 没有有效光标位置,插入到末尾
|
|
|
+ insertRange = document.createRange();
|
|
|
+ const lastChild = editor.lastChild;
|
|
|
+
|
|
|
+ if (lastChild) {
|
|
|
+ insertRange.selectNodeContents(lastChild);
|
|
|
+ insertRange.collapse(false); // 折叠到末尾
|
|
|
+ } else {
|
|
|
+ insertRange.selectNodeContents(editor);
|
|
|
+ insertRange.collapse(true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建图表div元素
|
|
|
+ const chartDiv = document.createElement('div');
|
|
|
+ const chartId = generateUID();
|
|
|
+ chartDiv.className = 'chart-container';
|
|
|
+ chartDiv.setAttribute('data-chart-id', chartId);
|
|
|
+ chartDiv.setAttribute('data-chart-type', item.type);
|
|
|
+ chartDiv.setAttribute('contenteditable', false);
|
|
|
+ chartDiv.setAttribute('data-chart', true);
|
|
|
+ chartDiv.style.cssText = `
|
|
|
+ display: inline-block;
|
|
|
+ border: 1px dashed #d9d9d9;
|
|
|
+ border-radius: 4px;
|
|
|
+ padding: 20px;
|
|
|
+ margin: 10px 0;
|
|
|
+ background-color: #fafafa;
|
|
|
+ width: calc(99% - 40px);
|
|
|
+ min-height: 200px;
|
|
|
+ text-align: center;
|
|
|
+ cursor: pointer;
|
|
|
+ vertical-align: bottom;
|
|
|
+ `;
|
|
|
+
|
|
|
+ // 先插入图表div
|
|
|
+ insertRange.insertNode(chartDiv);
|
|
|
+
|
|
|
+ // 初始化图表数据并存储到chartDataStore中
|
|
|
+ chartDataStore.value[chartId] = {
|
|
|
+ type: item.type,
|
|
|
+ data: [],
|
|
|
+ columns: [],
|
|
|
+ isLoading: false
|
|
|
+ };
|
|
|
+
|
|
|
+ // 如果是表格类型,初始加载TableChart组件
|
|
|
+ if (item.type === 'table') {
|
|
|
+ // 创建表格组件实例
|
|
|
+ const app = createApp(TableChart, {
|
|
|
+ chartId: chartId
|
|
|
+ });
|
|
|
+ // 提供chartDataStore给子组件
|
|
|
+ app.provide('chartDataStore', chartDataStore);
|
|
|
+ // 挂载到chartDiv
|
|
|
+ app.mount(chartDiv);
|
|
|
+ } else {
|
|
|
+ // 其他图表类型显示默认内容
|
|
|
+ chartDiv.innerHTML = `
|
|
|
+ // <div style="font-size: 14px; color: #666; margin-bottom: 10px;">${item.name}</div>
|
|
|
+ // <div style="font-size: 12px; color: #999;">点击编辑图表</div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建零宽度空格节点,用于定位光标
|
|
|
+ // const zeroWidthSpace = document.createTextNode('\u200B');
|
|
|
+
|
|
|
+ // 然后在图表后面插入零宽度空格节点
|
|
|
+ // chartDiv.parentNode.insertBefore(zeroWidthSpace, chartDiv.nextSibling);
|
|
|
+
|
|
|
+ // 确保光标在零宽度空格后面
|
|
|
+ const newRange = document.createRange();
|
|
|
+ // newRange.setStartAfter(zeroWidthSpace);
|
|
|
+ newRange.collapse(true);
|
|
|
+
|
|
|
+ // 清除所有选择范围并添加新范围
|
|
|
+ selection.removeAllRanges();
|
|
|
+ selection.addRange(newRange);
|
|
|
+
|
|
|
+ // 滚动到插入的图表位置
|
|
|
+ chartDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
+
|
|
|
+ // 重置保存的光标位置
|
|
|
+ savedCursorPosition.value = null;
|
|
|
+
|
|
|
+ // 保存到历史记录
|
|
|
+ immediateSave();
|
|
|
+
|
|
|
+ // 更新父组件
|
|
|
+ emit('submitHtml', editor.innerHTML);
|
|
|
+ emit('update:modelValue', editor.innerHTML);
|
|
|
+ emit('content-changed', editor.innerHTML);
|
|
|
+}
|
|
|
+
|
|
|
+const insertData = () => {
|
|
|
+ const columns = [
|
|
|
+ {
|
|
|
+ title: 'Name',
|
|
|
+ dataIndex: 'name'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: 'Salary',
|
|
|
+ dataIndex: 'salary'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: 'Address',
|
|
|
+ dataIndex: 'address'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: 'Email',
|
|
|
+ dataIndex: 'email'
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ const data = [
|
|
|
+ {
|
|
|
+ key: '1',
|
|
|
+ name: 'Jane Doe',
|
|
|
+ salary: 23000,
|
|
|
+ address: '32 Park Road, London',
|
|
|
+ email: 'jane.doe@example.com'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: '2',
|
|
|
+ name: 'Alisa Ross',
|
|
|
+ salary: 25000,
|
|
|
+ address: '35 Park Road, London',
|
|
|
+ email: 'alisa.ross@example.com'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: '3',
|
|
|
+ name: 'Kevin Sandra',
|
|
|
+ salary: 22000,
|
|
|
+ address: '31 Park Road, London',
|
|
|
+ email: 'kevin.sandra@example.com'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: '4',
|
|
|
+ name: 'Ed Hellen',
|
|
|
+ salary: 17000,
|
|
|
+ address: '42 Park Road, London',
|
|
|
+ email: 'ed.hellen@example.com'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: '5',
|
|
|
+ name: 'William Smith',
|
|
|
+ salary: 27000,
|
|
|
+ address: '62 Park Road, London',
|
|
|
+ email: 'william.smith@example.com'
|
|
|
+ }
|
|
|
+ ];
|
|
|
+ for (const key in chartDataStore.value) {
|
|
|
+ setChartLoading(key, true);
|
|
|
+ setTimeout(() => {
|
|
|
+ updateChartData(key, data, columns);
|
|
|
+ }, 2000);
|
|
|
+ }
|
|
|
+};
|
|
|
+// 更新图表数据的示例函数
|
|
|
+function updateChartData(chartId, data, columns) {
|
|
|
+ if (chartDataStore.value[chartId]) {
|
|
|
+ chartDataStore.value[chartId].data = data;
|
|
|
+ chartDataStore.value[chartId].columns = columns;
|
|
|
+ chartDataStore.value[chartId].isLoading = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 设置图表加载状态的示例函数
|
|
|
+function setChartLoading(chartId, isLoading) {
|
|
|
+ if (chartDataStore.value[chartId]) {
|
|
|
+ chartDataStore.value[chartId].isLoading = isLoading;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 切换图表面板下拉框显示状态
|
|
|
+function toggleChartPanelDropdown() {
|
|
|
+ showChartPanelDropdown.value = !showChartPanelDropdown.value;
|
|
|
+ if (showChartPanelDropdown.value) {
|
|
|
+ // 保存当前光标位置,防止点击按钮后光标丢失
|
|
|
+ const selection = window.getSelection();
|
|
|
+ if (selection.rangeCount && editorRef.value.contains(selection.getRangeAt(0).startContainer)) {
|
|
|
+ savedCursorPosition.value = selection.getRangeAt(0).cloneRange();
|
|
|
+ }
|
|
|
+ calculateChartPanelPosition();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 计算图表面板下拉框位置
|
|
|
+function calculateChartPanelPosition() {
|
|
|
+ if (!chartButtonRef.value || !containerRef.value) return;
|
|
|
+
|
|
|
+ const buttonRect = chartButtonRef.value.getBoundingClientRect();
|
|
|
+ const containerRect = containerRef.value.getBoundingClientRect();
|
|
|
+
|
|
|
+ chartPanelDropdownPosition.value = {
|
|
|
+ top: buttonRect.bottom - containerRect.top,
|
|
|
+ left: buttonRect.left - containerRect.left
|
|
|
+ };
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+/* 原有样式保持不变 */
|
|
|
+.rich-text-editor {
|
|
|
+ border: 1px solid #d8d8d8;
|
|
|
+ border-radius: 20px;
|
|
|
+ width: calc(100% - 30px);
|
|
|
+ height: calc(100% - 72px);
|
|
|
+ font-family: sans-serif;
|
|
|
+ margin: 0px 10px 10px 20px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ position: relative; /* 👈 关键:使浮层定位基于此 */
|
|
|
+}
|
|
|
+.common-config {
|
|
|
+ height: 50px;
|
|
|
+ padding: 5px 20px;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar_left,
|
|
|
+.toolbar_right {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar .btn-icon {
|
|
|
+ width: 20px;
|
|
|
+}
|
|
|
+.toolbar .toolbar_left button {
|
|
|
+ border: none;
|
|
|
+ padding: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar .btn.active {
|
|
|
+ color: var(--primary-default);
|
|
|
+ background-color: rgba(0, 0, 0, 0.05);
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.editor {
|
|
|
+ height: calc(100% - 68px - 50px);
|
|
|
+ min-height: 150px;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 12px;
|
|
|
+ padding-top: 0px;
|
|
|
+ outline: none;
|
|
|
+ line-height: 1.5;
|
|
|
+ pointer-events: auto;
|
|
|
+ user-select: text;
|
|
|
+}
|
|
|
+
|
|
|
+.editor:focus {
|
|
|
+ outline: none;
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.editor img) {
|
|
|
+ width: 100% !important;
|
|
|
+ height: auto !important;
|
|
|
+ display: block;
|
|
|
+ max-width: 100%;
|
|
|
+ object-fit: contain;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar select {
|
|
|
+ padding: 6px 10px;
|
|
|
+ font-size: 14px;
|
|
|
+ border: 1px solid #ccc;
|
|
|
+ border-radius: 6px;
|
|
|
+ background-color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ outline: none;
|
|
|
+ appearance: none;
|
|
|
+ background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3e%3cpath d='M6 9l6 6 6-6'/%3e%3c/svg%3e");
|
|
|
+ background-repeat: no-repeat;
|
|
|
+ background-position: right 8px center;
|
|
|
+ background-size: 12px;
|
|
|
+ padding-right: 30px;
|
|
|
+ min-width: 90px;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar select:hover {
|
|
|
+ border-color: var(--bg-title);
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar select:focus {
|
|
|
+ border-color: var(--primary-default);
|
|
|
+ box-shadow: 0 0 0 2px var(--bg-title);
|
|
|
+}
|
|
|
+
|
|
|
+.toolbar .chat-select {
|
|
|
+ width: 120px;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-select .insert_btn {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.insert_btn .btn-icon {
|
|
|
+ width: 16px;
|
|
|
+ margin-right: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.arco-dropdown-open .arco-icon-down {
|
|
|
+ transform: rotate(180deg);
|
|
|
+}
|
|
|
+
|
|
|
+:deep(.editor h1) {
|
|
|
+ font-size: 28px;
|
|
|
+}
|
|
|
+:deep(.editor h2) {
|
|
|
+ font-size: 20px;
|
|
|
+}
|
|
|
+:deep(.editor h3) {
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+/* ========== Mention 样式 ==========
|
|
|
+.floating-dropdown {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 1000;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #d9d9d9;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
|
+ min-width: 120px;
|
|
|
+ max-height: 200px;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+/* 指标高亮样式 */
|
|
|
+:deep(.editor [data-mention].mention-highlighted) {
|
|
|
+ background-color: var(--primary-light-1);
|
|
|
+ border: 1px solid var(--primary-default);
|
|
|
+ box-shadow: 0 0 0 2px var(--primary-light-2);
|
|
|
+}
|
|
|
+
|
|
|
+/* 图表选中样式 */
|
|
|
+:deep(.editor [data-chart].chart-selected) {
|
|
|
+ border: 2px solid var(--primary-default) !important;
|
|
|
+ background-color: var(--primary-light-1) !important;
|
|
|
+ box-shadow: 0 0 0 3px var(--primary-light-2) !important;
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown-option {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ text-align: left;
|
|
|
+ padding: 6px 10px;
|
|
|
+ border: none;
|
|
|
+ background: white;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.dropdown-option:hover {
|
|
|
+ background-color: #f5f5f5;
|
|
|
+}
|
|
|
+
|
|
|
+/* .mention-action-menu {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 1001;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #ccc;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
|
|
+ min-width: 100px;
|
|
|
+}
|
|
|
+
|
|
|
+.mention-action-menu button {
|
|
|
+ display: block;
|
|
|
+ width: 100%;
|
|
|
+ padding: 6px 10px;
|
|
|
+ border: none;
|
|
|
+ background: white;
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.mention-action-menu button:hover {
|
|
|
+ background-color: #f0f0f0;
|
|
|
+} */
|
|
|
+</style>
|