yh-codemirror 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CodeMirror.vue CHANGED
@@ -1,283 +1,1164 @@
1
- <template>
2
- <div
3
- class="yh-code-mirror-container"
4
- ref="YhCodemirrorContainer"
5
- :style="codeMirrorStyle">
6
- <div class="code-slot">
7
- <slot name="pre-code"></slot>
8
- </div>
9
- <div
10
- class="codemirror-editor"
11
- ref="codeMirrorEditorRef"
12
- :style="codeMirrorEditorStyle"></div>
13
- <div class="code-slot">
14
- <slot name="next-code"></slot>
15
- </div>
16
- <div class="editor-operation-btn">
17
- <i
18
- title="全屏"
19
- class="iconfont icon-fullscreen"
20
- @click="fullscreen"></i>
21
- <i
22
- title="美化"
23
- class="iconfont icon-magic-stick"
24
- @click="onEditorBtnClick('beautify')"></i>
25
- <i
26
- title="全选"
27
- class="iconfont icon-check-square"
28
- @click="onEditorBtnClick('checkAll')"></i>
29
- <i
30
- title="复制"
31
- class="iconfont icon-document-copy"
32
- @click="onEditorBtnClick('copy')"></i>
33
- <i
34
- title="清空"
35
- class="iconfont icon-clear"
36
- @click="onEditorBtnClick('clear')"></i>
37
- <slot name="operation-btn"></slot>
38
- </div>
39
- </div>
40
- </template>
41
- <script setup lang="ts">
42
- import {ref, onMounted, toRaw} from 'vue';
43
- // @ts-ignore
44
- import type {Extension} from 'codemirror';
45
- import {EditorView, basicSetup} from 'codemirror';
46
- import {ViewUpdate} from '@codemirror/view';
47
- import {EditorState, EditorSelection} from '@codemirror/state';
48
- import {javascriptLanguage} from '@codemirror/lang-javascript';
49
- import {solarizedDark} from 'cm6-theme-solarized-dark';
50
- import {getCodeLanguagePlugin} from './util';
51
- import {watch, computed} from 'vue';
52
- // @ts-ignore
53
- import {js_beautify} from 'js-beautify';
54
- import {ElMessage} from 'element-plus';
55
-
56
- const YhCodemirrorContainer = ref();
57
- function fullscreen() {
58
- if (document.fullscreenElement && document.fullscreenElement === YhCodemirrorContainer.value) {
59
- document.exitFullscreen();
60
- } else {
61
- YhCodemirrorContainer.value.requestFullscreen();
62
- }
63
- }
64
-
65
- const codeMirrorEditorRef = ref();
66
-
67
- let view: null | EditorView = null;
68
- let state: null | EditorState = null;
69
- // js-beautify 自定义配置
70
- const codeBeautifyConfig = {
71
- indent_size: 2,
72
- };
73
-
74
- const props = withDefaults(
75
- defineProps<{
76
- modelValue: string;
77
- language?: string | string[];
78
- autocomplete?: any;
79
- height?: number;
80
- }>(),
81
- {
82
- modelValue: '',
83
- language: 'javascript',
84
- autocomplete: {},
85
- }
86
- );
87
-
88
- const codeMirrorStyle = computed(() => {
89
- let style: any = {};
90
- if (props.height !== undefined && !isNaN(props.height)) {
91
- style.height = `${props.height}px`;
92
- style.display = 'flex';
93
- style.flexDirection = 'column';
94
- }
95
- return style;
96
- });
97
-
98
- const codeMirrorEditorStyle = computed(() => {
99
- let style: any = {};
100
- if (props.height !== undefined && !isNaN(props.height)) {
101
- style.flex = `1`;
102
- style.overflow = 'auto';
103
- }
104
- return style;
105
- });
106
-
107
- watch(
108
- () => props.modelValue,
109
- (val) => {
110
- if (view && state && val != view.state.doc.toString()) {
111
- let end = view.state.doc.toString().length;
112
- view.dispatch({
113
- changes: [
114
- {
115
- from: 0,
116
- to: end,
117
- insert: val,
118
- },
119
- ],
120
- });
121
- }
122
- }
123
- );
124
-
125
- const emits = defineEmits(['update:modelValue']);
126
-
127
- // 代码编辑器操作按钮
128
- const onEditorBtnClick = (type) => {
129
- if (view) {
130
- const state = toRaw(view.state);
131
- const pos = state.doc.toString().length;
132
- if (type == 'beautify') {
133
- const beautifyCode = js_beautify(props.modelValue, codeBeautifyConfig);
134
- view.dispatch({
135
- changes: [
136
- {
137
- from: 0,
138
- to: pos,
139
- insert: beautifyCode,
140
- },
141
- ],
142
- });
143
- } else if (type == 'checkAll') {
144
- const tr = state.update({
145
- // selection: EditorSelection.cursor(pos), // 设置光标
146
- selection: EditorSelection.range(0, pos),
147
- scrollIntoView: true,
148
- });
149
- view.dispatch(tr);
150
- } else if (type == 'copy') {
151
- let selectedText = '';
152
- if (state.selection.ranges.some((range) => !range.empty)) {
153
- const selection = state.selection.ranges[0];
154
- selectedText = state.sliceDoc(selection.from, selection.to);
155
- } else {
156
- selectedText = state.doc.toString();
157
- }
158
- navigator.clipboard.writeText(selectedText);
159
- ElMessage({
160
- message: '复制成功!',
161
- type: 'success',
162
- });
163
- } else if (type == 'clear') {
164
- view.dispatch({
165
- changes: [
166
- {
167
- from: 0,
168
- to: pos,
169
- insert: '',
170
- },
171
- ],
172
- });
173
- }
174
- view.focus();
175
- }
176
- };
177
-
178
- onMounted(() => {
179
- const extensions: Extension[] = [
180
- basicSetup,
181
- solarizedDark,
182
- EditorView.lineWrapping,
183
- EditorView.updateListener.of((v: ViewUpdate) => {
184
- if (v.docChanged) {
185
- emits('update:modelValue', v.state.doc.toString());
186
- }
187
- }),
188
- ];
189
- const language = props.language;
190
- if (language) {
191
- if (Array.isArray(language)) {
192
- language.forEach((l) => {
193
- extensions.push(getCodeLanguagePlugin(l));
194
- if (props.autocomplete[l] && l === 'javascript') {
195
- extensions.push(
196
- javascriptLanguage.data.of({
197
- autocomplete: props.autocomplete[l],
198
- })
199
- );
200
- }
201
- });
202
- } else {
203
- extensions.push(getCodeLanguagePlugin(language));
204
- if (props.autocomplete[language] && language === 'javascript') {
205
- extensions.push(
206
- javascriptLanguage.data.of({
207
- autocomplete: props.autocomplete[language],
208
- })
209
- );
210
- }
211
- }
212
- }
213
-
214
- state = EditorState.create({
215
- doc: props.modelValue,
216
- extensions,
217
- });
218
- if (state) {
219
- view = new EditorView({
220
- state: state,
221
- parent: codeMirrorEditorRef.value,
222
- });
223
- }
224
- });
225
- </script>
226
- <style lang="scss">
227
- .yh-code-mirror-container {
228
- width: 100%;
229
- position: relative;
230
- background-color: #173541;
231
- .code-slot {
232
- width: 100%;
233
- font-family: 'Fira Code', 'Roboto Mono', Consolas, 'Microsoft Yahei';
234
- line-height: 25px;
235
- font-size: 12px;
236
- padding-left: 29.77px;
237
- color: #ffffff;
238
- }
239
- .cm-editor {
240
- .cm-line {
241
- font-size: 16px;
242
- line-height: 25px;
243
- font-family: 'Fira Code', 'Roboto Mono', Consolas, 'Microsoft Yahei';
244
- }
245
- }
246
- .codemirror-editor {
247
- &::-webkit-scrollbar {
248
- width: 6px;
249
- height: 6px;
250
- background-color: #173541;
251
- }
252
- &::-webkit-scrollbar-thumb {
253
- background-color: #f5f5f5;
254
- &:hover {
255
- background-color: #fff;
256
- }
257
- }
258
- }
259
- .editor-operation-btn {
260
- position: absolute;
261
- bottom: 1px;
262
- right: 1px;
263
- padding: 0 3px;
264
- display: flex;
265
- align-items: center;
266
- border-radius: 3px;
267
- opacity: 0.5;
268
- background: #409eff;
269
- transition: all 0.3s ease;
270
- i {
271
- font-size: 16px;
272
- color: #fff;
273
- margin: 0 5px;
274
- cursor: pointer;
275
- line-height: 24px;
276
- }
277
- &:hover {
278
- opacity: 1;
279
- background: #409eff;
280
- }
281
- }
282
- }
283
- </style>
1
+ <template>
2
+ <div
3
+ class="yh-code-mirror-container"
4
+ :class="{'is-light': props.theme === 'light', 'is-ai-large': props.aiPanelSize === 'large'}"
5
+ ref="YhCodemirrorContainer"
6
+ :style="codeMirrorStyle">
7
+ <div class="code-slot">
8
+ <slot name="pre-code"></slot>
9
+ </div>
10
+ <div
11
+ class="codemirror-editor"
12
+ ref="codeMirrorEditorRef"
13
+ :style="codeMirrorEditorStyle"></div>
14
+ <div class="code-slot">
15
+ <slot name="next-code"></slot>
16
+ </div>
17
+ <div v-if="aiEnabled && aiVisible" class="ai-prompt-panel">
18
+ <div class="ai-panel-head">
19
+ <span class="ai-tag">AI</span>
20
+ <span class="ai-scope">将修改{{ aiScopeText }}</span>
21
+ <span
22
+ class="ai-history-entry"
23
+ :class="{'is-active': aiHistoryVisible}"
24
+ title="查看历史问答"
25
+ @click="aiHistoryVisible = !aiHistoryVisible"
26
+ >历史{{ aiHistory.length ? `(${aiHistory.length})` : '' }}</span
27
+ >
28
+ <span
29
+ class="ai-close"
30
+ title="关闭"
31
+ @click="closeAi"
32
+ >×</span
33
+ >
34
+ </div>
35
+ <textarea
36
+ ref="aiInputRef"
37
+ v-model="aiPrompt"
38
+ class="ai-input"
39
+ :rows="aiInputRows"
40
+ :disabled="aiLoading"
41
+ :placeholder="aiPlaceholder"
42
+ @keydown.enter.exact.prevent="submitAi"
43
+ @keydown.esc.stop.prevent="closeAi"></textarea>
44
+ <div class="ai-panel-foot">
45
+ <span class="ai-tip">{{
46
+ aiLoading ? (aiStreaming ? '生成中…' : 'AI 正在处理…') : 'Enter 发送 / Esc 取消'
47
+ }}</span>
48
+ <span
49
+ v-if="!aiLoading"
50
+ class="ai-btn"
51
+ :class="{'is-disabled': !aiPrompt.trim()}"
52
+ @click="submitAi"
53
+ >生成</span
54
+ >
55
+ <span
56
+ v-else
57
+ class="ai-btn danger"
58
+ @click="stopAi"
59
+ >停止</span
60
+ >
61
+ </div>
62
+ <div
63
+ v-if="aiError"
64
+ class="ai-error">
65
+ {{ aiError }}
66
+ </div>
67
+ </div>
68
+ <!-- 历史问答:和输入框互斥展示,关掉输入框时不影响单独查看 -->
69
+ <div
70
+ v-if="aiEnabled && aiHistoryVisible"
71
+ class="ai-history-panel">
72
+ <div class="ai-history-head">
73
+ <span class="ai-tag">历史</span>
74
+ <span class="ai-scope">{{ aiHistory.length ? `共 ${aiHistory.length} 条问答` : '暂无记录' }}</span>
75
+ <span
76
+ v-if="aiHistory.length"
77
+ class="ai-btn plain"
78
+ @click="clearHistory"
79
+ >清空</span
80
+ >
81
+ <span
82
+ class="ai-close"
83
+ title="关闭"
84
+ @click="aiHistoryVisible = false"
85
+ >×</span
86
+ >
87
+ </div>
88
+ <div
89
+ v-if="!aiHistory.length"
90
+ class="ai-history-empty">
91
+ 还没有历史记录,问点什么试试
92
+ </div>
93
+ <div
94
+ v-else
95
+ class="ai-history-list">
96
+ <div
97
+ v-for="item in aiHistory"
98
+ :key="item.id"
99
+ class="ai-history-item">
100
+ <div
101
+ class="ai-history-q"
102
+ @click="toggleHistoryItem(item.id)">
103
+ <span class="ai-history-time">{{ item.time }}</span>
104
+ <span class="ai-history-text">{{ item.prompt }}</span>
105
+ <span class="ai-history-toggle">{{ expandedHistoryId === item.id ? '收起' : '展开' }}</span>
106
+ </div>
107
+ <div
108
+ v-if="expandedHistoryId === item.id"
109
+ class="ai-history-body">
110
+ <div class="ai-history-label">
111
+ 问题<span class="ai-history-scope">{{ item.scope }}</span>
112
+ </div>
113
+ <pre class="ai-history-pre">{{ item.prompt }}</pre>
114
+ <div class="ai-history-label">
115
+ 回答
116
+ <span
117
+ class="ai-btn plain"
118
+ @click="copyHistoryAnswer(item.answer)"
119
+ >复制</span
120
+ >
121
+ </div>
122
+ <pre class="ai-history-pre is-answer">{{ item.answer }}</pre>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ <!-- 未聚焦时隐藏(.is-hidden):按钮栏会挡住代码尾部,只在编辑时露出来。
128
+ @mousedown.prevent 阻止默认的焦点转移,否则点按钮的瞬间编辑器失焦、按钮立刻隐藏,
129
+ mouseup 落不到按钮上,点击会失效。 -->
130
+ <div
131
+ class="editor-operation-btn"
132
+ :class="{'is-hidden': !editorFocused}"
133
+ @mousedown.prevent>
134
+ <span
135
+ v-if="aiEnabled"
136
+ class="ai-entry"
137
+ :title="`AI 编辑(${aiHotkeyLabel})`"
138
+ @click="openAi()"
139
+ >AI</span
140
+ >
141
+ <i
142
+ title="全屏"
143
+ class="iconfont icon-fullscreen"
144
+ @click="fullscreen"></i>
145
+ <i
146
+ title="美化"
147
+ class="iconfont icon-magic-stick"
148
+ @click="onEditorBtnClick('beautify')"></i>
149
+ <i
150
+ title="全选"
151
+ class="iconfont icon-check-square"
152
+ @click="onEditorBtnClick('checkAll')"></i>
153
+ <i
154
+ title="复制"
155
+ class="iconfont icon-document-copy"
156
+ @click="onEditorBtnClick('copy')"></i>
157
+ <i
158
+ title="清空"
159
+ class="iconfont icon-clear"
160
+ @click="onEditorBtnClick('clear')"></i>
161
+ <slot name="operation-btn"></slot>
162
+ </div>
163
+ </div>
164
+ </template>
165
+ <script setup lang="ts">
166
+ import {ref, onMounted, onBeforeUnmount, nextTick, toRaw} from 'vue';
167
+ // @ts-ignore
168
+ import type {Extension} from 'codemirror';
169
+ import {EditorView, basicSetup} from 'codemirror';
170
+ import {ViewUpdate, keymap} from '@codemirror/view';
171
+ import {EditorState, EditorSelection} from '@codemirror/state';
172
+ import {javascriptLanguage} from '@codemirror/lang-javascript';
173
+ import {solarizedDark} from 'cm6-theme-solarized-dark';
174
+ import {HighlightStyle, syntaxHighlighting} from '@codemirror/language';
175
+ import {tags} from '@lezer/highlight';
176
+ import {getCodeLanguagePlugin} from './util';
177
+ import type {AiCodeEditParams, AiCodeEditRequest} from './util';
178
+ import {aiHistory, pushAiHistory, clearAiHistory} from './ai-history';
179
+ import {watch, computed} from 'vue';
180
+ // @ts-ignore
181
+ import {js_beautify} from 'js-beautify';
182
+ import {ElMessage} from 'element-plus';
183
+
184
+ const YhCodemirrorContainer = ref();
185
+ function fullscreen() {
186
+ if (document.fullscreenElement && document.fullscreenElement === YhCodemirrorContainer.value) {
187
+ document.exitFullscreen();
188
+ } else {
189
+ YhCodemirrorContainer.value.requestFullscreen();
190
+ }
191
+ }
192
+
193
+ const codeMirrorEditorRef = ref();
194
+ /** 编辑器是否持有焦点:决定右下角功能按钮栏是否显示 */
195
+ const editorFocused = ref(false);
196
+
197
+ let view: null | EditorView = null;
198
+ let state: null | EditorState = null;
199
+ // js-beautify 自定义配置
200
+ const codeBeautifyConfig = {
201
+ indent_size: 2,
202
+ };
203
+
204
+ const props = withDefaults(
205
+ defineProps<{
206
+ modelValue: string;
207
+ language?: string | string[];
208
+ autocomplete?: any;
209
+ height?: number;
210
+ /** AI 请求函数;传入即启用 AI 提示编辑。返回完整替换文本,流式输出时通过 onChunk 回调 */
211
+ aiRequest?: AiCodeEditRequest;
212
+ /** 唤起 AI 输入框的快捷键(CodeMirror 键位语法),默认 Mod-i(Win: Ctrl+I / Mac: Cmd+I) */
213
+ aiHotkey?: string;
214
+ /** AI 输入框占位提示 */
215
+ aiPlaceholder?: string;
216
+ /** 无选中时 AI 的作用范围:document 整个文档 / cursor 光标处插入 */
217
+ aiScope?: 'document' | 'cursor';
218
+ /** 编辑器主题:dark(默认)或 light */
219
+ theme?: 'dark' | 'light';
220
+ /**
221
+ * AI 输入框/历史面板的尺寸档位。编辑区被放大时(如 window-form 的「放大」抽屉)传 'large',
222
+ * 否则 12px 字号 + 2 行输入框在大面板里会显得很局促。
223
+ */
224
+ aiPanelSize?: 'normal' | 'large';
225
+ }>(),
226
+ {
227
+ modelValue: '',
228
+ language: 'javascript',
229
+ autocomplete: {},
230
+ aiHotkey: 'Mod-i',
231
+ aiPlaceholder: '描述你想要的修改,如:给这个函数加上异常处理',
232
+ aiScope: 'document',
233
+ theme: 'dark',
234
+ aiPanelSize: 'normal',
235
+ }
236
+ );
237
+
238
+ // 浅色主题(匹配原 window-form.vue 的浅色调)
239
+ const lightTheme = EditorView.theme(
240
+ {
241
+ '&': {color: '#333', backgroundColor: '#fff'},
242
+ '.cm-content': {caretColor: '#333'},
243
+ '.cm-cursor, .cm-dropCursor': {borderLeftColor: '#333'},
244
+ // 选中区/当前行的配色不在这里写:solarizedDark 这类外部主题要靠更高权重的选择器覆盖,
245
+ // 统一放在组件 <style> 里的 ".cm-editor .cm-selectionBackground / .cm-activeLine" 规则中,避免两处维护。
246
+ '.cm-panels': {backgroundColor: '#f5f5f5', color: '#333'},
247
+ '.cm-gutters': {
248
+ backgroundColor: '#f5f5f5',
249
+ color: '#888',
250
+ borderRight: '1px solid #e0e0e0',
251
+ },
252
+ // .cm-activeLineGutter 同 .cm-activeLine,见上面注释,统一在 <style> 里维护
253
+ '.cm-tooltip': {border: '1px solid #ddd', backgroundColor: '#fff'},
254
+ '.cm-tooltip .cm-tooltip-arrow:after': {
255
+ borderTopColor: '#fff',
256
+ borderBottomColor: '#fff',
257
+ },
258
+ '.cm-tooltip-autocomplete': {
259
+ '& > ul > li[aria-selected]': {backgroundColor: '#e8f4ff', color: '#333'},
260
+ },
261
+ },
262
+ {dark: false}
263
+ );
264
+
265
+ const lightHighlightStyle = HighlightStyle.define([
266
+ {tag: tags.keyword, color: '#0000ff'},
267
+ {tag: tags.string, color: '#008000'},
268
+ {tag: tags.comment, color: '#808080', fontStyle: 'italic'},
269
+ {tag: tags.function(tags.variableName), color: '#795e26'},
270
+ {tag: tags.number, color: '#098658'},
271
+ {tag: tags.operator, color: '#333'},
272
+ {tag: tags.className, color: '#267f99'},
273
+ {tag: tags.tagName, color: '#0000ff'},
274
+ {tag: tags.attributeName, color: '#e50000'},
275
+ {tag: [tags.variableName], color: '#333'},
276
+ {tag: tags.bool, color: '#0000ff'},
277
+ {tag: tags.null, color: '#0000ff'},
278
+ ]);
279
+
280
+ const lightThemeExtension = [lightTheme, syntaxHighlighting(lightHighlightStyle)];
281
+
282
+ const codeMirrorStyle = computed(() => {
283
+ let style: any = {};
284
+ if (props.height !== undefined && !isNaN(props.height)) {
285
+ style.height = `${props.height}px`;
286
+ style.display = 'flex';
287
+ style.flexDirection = 'column';
288
+ }
289
+ return style;
290
+ });
291
+
292
+ const codeMirrorEditorStyle = computed(() => {
293
+ let style: any = {};
294
+ if (props.height !== undefined && !isNaN(props.height)) {
295
+ style.flex = `1`;
296
+ style.overflow = 'auto';
297
+ }
298
+ return style;
299
+ });
300
+
301
+ watch(
302
+ () => props.modelValue,
303
+ (val) => {
304
+ if (view && state && val != view.state.doc.toString()) {
305
+ let end = view.state.doc.toString().length;
306
+ view.dispatch({
307
+ changes: [
308
+ {
309
+ from: 0,
310
+ to: end,
311
+ insert: val,
312
+ },
313
+ ],
314
+ });
315
+ }
316
+ }
317
+ );
318
+
319
+ const emits = defineEmits(['update:modelValue', 'ai-apply', 'ai-error']);
320
+
321
+ /* ------------------------------ AI 提示编辑 ------------------------------ */
322
+ const aiEnabled = computed(() => typeof props.aiRequest === 'function');
323
+ const aiHotkeyLabel = computed(() =>
324
+ props.aiHotkey.replace('Mod-', navigator.platform.toLowerCase().includes('mac') ? '⌘' : 'Ctrl+')
325
+ );
326
+ const aiVisible = ref(false);
327
+ const aiPrompt = ref('');
328
+ const aiLoading = ref(false);
329
+ const aiStreaming = ref(false);
330
+ const aiError = ref('');
331
+ const aiScopeText = ref('');
332
+ const aiInputRef = ref();
333
+ // 放大档位下输入框多给两行,配合 .is-ai-large 的字号提升
334
+ const aiInputRows = computed(() => (props.aiPanelSize === 'large' ? 4 : 2));
335
+ // 历史问答(模块级单例,见 ./ai-history)
336
+ const aiHistoryVisible = ref(false);
337
+ const expandedHistoryId = ref<number | null>(null);
338
+ const toggleHistoryItem = (id: number) => {
339
+ expandedHistoryId.value = expandedHistoryId.value === id ? null : id;
340
+ };
341
+ const clearHistory = () => {
342
+ clearAiHistory();
343
+ expandedHistoryId.value = null;
344
+ };
345
+ const copyHistoryAnswer = async (text: string) => {
346
+ try {
347
+ await navigator.clipboard.writeText(text || '');
348
+ ElMessage({message: '已复制回答', type: 'success'});
349
+ } catch (e) {
350
+ ElMessage({message: '复制失败,请手动选中复制', type: 'warning'});
351
+ }
352
+ };
353
+ // 当前 AI 作用区间(流式过程中 to 会随写入推进)
354
+ let aiRange: {from: number; to: number} | null = null;
355
+ let aiAbort: AbortController | null = null;
356
+
357
+ /** 打开 AI 输入框:有选区则作用于选区,否则按 aiScope 处理 */
358
+ const openAi = (presetPrompt?: string) => {
359
+ if (!aiEnabled.value || !view) {
360
+ return;
361
+ }
362
+ const st = toRaw(view.state);
363
+ const sel = st.selection.main;
364
+ if (!sel.empty) {
365
+ aiRange = {from: sel.from, to: sel.to};
366
+ const lines = st.doc.lineAt(sel.to).number - st.doc.lineAt(sel.from).number + 1;
367
+ aiScopeText.value = `选中的 ${lines} 行代码`;
368
+ } else if (props.aiScope === 'cursor') {
369
+ aiRange = {from: sel.head, to: sel.head};
370
+ aiScopeText.value = '在光标处插入';
371
+ } else {
372
+ aiRange = {from: 0, to: st.doc.length};
373
+ aiScopeText.value = '整个文档';
374
+ }
375
+ aiPrompt.value = presetPrompt || '';
376
+ aiError.value = '';
377
+ aiLoading.value = false;
378
+ aiVisible.value = true;
379
+ nextTick(() => {
380
+ aiInputRef.value && aiInputRef.value.focus();
381
+ });
382
+ };
383
+
384
+ const closeAi = () => {
385
+ if (!aiVisible.value) {
386
+ return;
387
+ }
388
+ stopAi();
389
+ aiVisible.value = false;
390
+ aiRange = null;
391
+ view && view.focus();
392
+ };
393
+
394
+ const stopAi = () => {
395
+ if (aiAbort) {
396
+ aiAbort.abort();
397
+ aiAbort = null;
398
+ }
399
+ aiLoading.value = false;
400
+ aiStreaming.value = false;
401
+ };
402
+
403
+ /** 把文本写入区间;流式场景下区间终点随写入长度前移 */
404
+ const writeRange = (range: {from: number; to: number}, text: string) => {
405
+ if (!view) {
406
+ return;
407
+ }
408
+ const to = Math.min(range.to, view.state.doc.length);
409
+ view.dispatch({
410
+ changes: {from: range.from, to, insert: text},
411
+ selection: EditorSelection.range(range.from, range.from + text.length),
412
+ scrollIntoView: true,
413
+ });
414
+ range.to = range.from + text.length;
415
+ };
416
+
417
+ /** 兼容模型常见的 ```lang ... ``` 包裹输出 */
418
+ const stripCodeFence = (text: string) => {
419
+ const trimmed = (text || '').replace(/\r\n/g, '\n').trim();
420
+ const matched = trimmed.match(/^```[a-zA-Z0-9_+\-]*[ \t]*\n([\s\S]*?)\n?```$/);
421
+ return matched ? matched[1] : trimmed;
422
+ };
423
+
424
+ const submitAi = async () => {
425
+ const prompt = aiPrompt.value.trim();
426
+ if (!prompt || !view || !aiRange || !props.aiRequest || aiLoading.value) {
427
+ return;
428
+ }
429
+ const range = {...aiRange};
430
+ const st = toRaw(view.state);
431
+ range.from = Math.max(0, Math.min(range.from, st.doc.length));
432
+ range.to = Math.max(range.from, Math.min(range.to, st.doc.length));
433
+ const selectedCode = st.sliceDoc(range.from, range.to);
434
+
435
+ aiLoading.value = true;
436
+ aiStreaming.value = false;
437
+ aiError.value = '';
438
+ const controller = new AbortController();
439
+ aiAbort = controller;
440
+ let streamed = false;
441
+ let acc = '';
442
+ const onChunk = (chunk: string) => {
443
+ if (chunk === undefined || chunk === null) {
444
+ return;
445
+ }
446
+ streamed = true;
447
+ aiStreaming.value = true;
448
+ acc += chunk;
449
+ writeRange(range, acc);
450
+ };
451
+
452
+ const params: AiCodeEditParams = {
453
+ prompt,
454
+ code: st.doc.toString(),
455
+ selectedCode,
456
+ language: props.language,
457
+ signal: controller.signal,
458
+ onChunk,
459
+ };
460
+
461
+ try {
462
+ const res: any = await props.aiRequest(params);
463
+ if (controller.signal.aborted) {
464
+ return;
465
+ }
466
+ if (!streamed) {
467
+ const text = typeof res === 'string' ? res : res?.text ?? res?.code ?? res?.content ?? '';
468
+ writeRange(range, stripCodeFence(text));
469
+ } else {
470
+ writeRange(range, stripCodeFence(acc));
471
+ }
472
+ aiLoading.value = false;
473
+ aiStreaming.value = false;
474
+ aiVisible.value = false;
475
+ aiRange = null;
476
+ view.focus();
477
+ const appliedText = view.state.sliceDoc(range.from, range.to);
478
+ // 记录历史问答(含 AI 最终写入编辑器的完整文本,撤销了也能在这里找回原文)
479
+ pushAiHistory({prompt, scope: aiScopeText.value, answer: appliedText});
480
+ emits('ai-apply', {prompt, text: appliedText});
481
+ ElMessage({
482
+ message: 'AI 修改已应用',
483
+ type: 'success',
484
+ });
485
+ } catch (err: any) {
486
+ aiLoading.value = false;
487
+ aiStreaming.value = false;
488
+ if (err?.name === 'AbortError' || controller.signal.aborted) {
489
+ aiError.value = '已取消';
490
+ return;
491
+ }
492
+ const message = err?.message || 'AI 请求失败';
493
+ aiError.value = message;
494
+ emits('ai-error', err);
495
+ ElMessage({
496
+ message,
497
+ type: 'error',
498
+ });
499
+ } finally {
500
+ aiAbort = null;
501
+ }
502
+ };
503
+
504
+ // 代码编辑器操作按钮
505
+ const onEditorBtnClick = (type) => {
506
+ if (view) {
507
+ const state = toRaw(view.state);
508
+ const pos = state.doc.toString().length;
509
+ if (type == 'beautify') {
510
+ const beautifyCode = js_beautify(props.modelValue, codeBeautifyConfig);
511
+ view.dispatch({
512
+ changes: [
513
+ {
514
+ from: 0,
515
+ to: pos,
516
+ insert: beautifyCode,
517
+ },
518
+ ],
519
+ });
520
+ } else if (type == 'checkAll') {
521
+ const tr = state.update({
522
+ // selection: EditorSelection.cursor(pos), // 设置光标
523
+ selection: EditorSelection.range(0, pos),
524
+ scrollIntoView: true,
525
+ });
526
+ view.dispatch(tr);
527
+ } else if (type == 'copy') {
528
+ let selectedText = '';
529
+ if (state.selection.ranges.some((range) => !range.empty)) {
530
+ const selection = state.selection.ranges[0];
531
+ selectedText = state.sliceDoc(selection.from, selection.to);
532
+ } else {
533
+ selectedText = state.doc.toString();
534
+ }
535
+ navigator.clipboard.writeText(selectedText);
536
+ ElMessage({
537
+ message: '复制成功!',
538
+ type: 'success',
539
+ });
540
+ } else if (type == 'clear') {
541
+ view.dispatch({
542
+ changes: [
543
+ {
544
+ from: 0,
545
+ to: pos,
546
+ insert: '',
547
+ },
548
+ ],
549
+ });
550
+ }
551
+ view.focus();
552
+ }
553
+ };
554
+
555
+ onMounted(() => {
556
+ const extensions: Extension[] = [
557
+ basicSetup,
558
+ props.theme === 'light' ? lightThemeExtension : solarizedDark,
559
+ EditorView.lineWrapping,
560
+ EditorView.updateListener.of((v: ViewUpdate) => {
561
+ if (v.docChanged) {
562
+ emits('update:modelValue', v.state.doc.toString());
563
+ }
564
+ if (v.focusChanged) {
565
+ editorFocused.value = v.view.hasFocus;
566
+ }
567
+ }),
568
+ ];
569
+ const language = props.language;
570
+ if (language) {
571
+ if (Array.isArray(language)) {
572
+ language.forEach((l) => {
573
+ extensions.push(getCodeLanguagePlugin(l));
574
+ if (props.autocomplete[l] && l === 'javascript') {
575
+ extensions.push(
576
+ javascriptLanguage.data.of({
577
+ autocomplete: props.autocomplete[l],
578
+ })
579
+ );
580
+ }
581
+ });
582
+ } else {
583
+ extensions.push(getCodeLanguagePlugin(language));
584
+ if (props.autocomplete[language] && language === 'javascript') {
585
+ extensions.push(
586
+ javascriptLanguage.data.of({
587
+ autocomplete: props.autocomplete[language],
588
+ })
589
+ );
590
+ }
591
+ }
592
+ }
593
+
594
+ if (aiEnabled.value) {
595
+ extensions.push(
596
+ keymap.of([
597
+ {
598
+ key: props.aiHotkey,
599
+ run: () => {
600
+ openAi();
601
+ return true;
602
+ },
603
+ },
604
+ {
605
+ key: 'Escape',
606
+ run: () => {
607
+ if (aiVisible.value) {
608
+ closeAi();
609
+ return true;
610
+ }
611
+ return false;
612
+ },
613
+ },
614
+ ])
615
+ );
616
+ }
617
+
618
+ state = EditorState.create({
619
+ doc: props.modelValue,
620
+ extensions,
621
+ });
622
+ if (state) {
623
+ view = new EditorView({
624
+ state: state,
625
+ parent: codeMirrorEditorRef.value,
626
+ });
627
+ editorFocused.value = view.hasFocus;
628
+ }
629
+ });
630
+
631
+ onBeforeUnmount(() => {
632
+ stopAi();
633
+ });
634
+
635
+ defineExpose({
636
+ /** 唤起 AI 编辑输入框(可预填提示词) */
637
+ openAi,
638
+ /** 关闭 AI 编辑输入框 */
639
+ closeAi,
640
+ /** 中断进行中的 AI 请求 */
641
+ stopAi,
642
+ /** 获取 CodeMirror 实例 */
643
+ getView: () => view,
644
+ });
645
+ </script>
646
+ <style lang="scss">
647
+ .yh-code-mirror-container {
648
+ width: 100%;
649
+ position: relative;
650
+ background-color: #173541;
651
+ &.is-light {
652
+ background-color: #fff;
653
+ .cm-editor {
654
+ .cm-line.cm-activeLine::before {
655
+ background-color: #f0f2f5;
656
+ }
657
+ .cm-activeLineGutter {
658
+ background-color: #f0f2f5;
659
+ }
660
+ .cm-selectionBackground {
661
+ background-color: #d4e7f9 !important;
662
+ }
663
+ &.cm-focused .cm-selectionBackground {
664
+ background-color: #b3d8ff !important;
665
+ }
666
+ // 双击选词后其它同名 occurrence(highlightSelectionMatches):用暖色,和蓝色选区、中性当前行都区分开
667
+ .cm-selectionMatch {
668
+ background-color: #ffe9c2;
669
+ }
670
+ }
671
+ .code-slot {
672
+ color: #333;
673
+ }
674
+ .codemirror-editor {
675
+ &::-webkit-scrollbar {
676
+ background-color: #f5f5f5;
677
+ }
678
+ &::-webkit-scrollbar-thumb {
679
+ background-color: #c1c1c1;
680
+ &:hover {
681
+ background-color: #a8a8a8;
682
+ }
683
+ }
684
+ }
685
+ .ai-prompt-panel {
686
+ background: rgba(255, 255, 255, 0.98);
687
+ border-color: #409eff;
688
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
689
+ .ai-panel-head {
690
+ color: #333;
691
+ }
692
+ .ai-input {
693
+ border-color: #dcdfe6;
694
+ background: #fff;
695
+ color: #333;
696
+ &:focus {
697
+ border-color: #409eff;
698
+ }
699
+ }
700
+ .ai-panel-foot {
701
+ color: #606266;
702
+ .ai-btn.is-disabled {
703
+ background: #c0c4cc;
704
+ }
705
+ }
706
+ .ai-error {
707
+ color: #f56c6c;
708
+ }
709
+ .ai-history-entry {
710
+ border-color: #dcdfe6;
711
+ color: #606266;
712
+ &:hover,
713
+ &.is-active {
714
+ color: #fff;
715
+ }
716
+ }
717
+ }
718
+ .ai-history-panel {
719
+ background: rgba(255, 255, 255, 0.98);
720
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
721
+ .ai-history-head {
722
+ color: #333;
723
+ }
724
+ .ai-btn.plain {
725
+ border-color: #dcdfe6;
726
+ color: #606266;
727
+ }
728
+ .ai-history-empty {
729
+ color: #606266;
730
+ }
731
+ .ai-history-item {
732
+ border-top-color: #ebeef5;
733
+ }
734
+ .ai-history-q {
735
+ color: #333;
736
+ }
737
+ .ai-history-label .ai-history-scope {
738
+ color: #606266;
739
+ }
740
+ .ai-history-pre {
741
+ background: #f5f7fa;
742
+ color: #333;
743
+ }
744
+ }
745
+ }
746
+ .code-slot {
747
+ width: 100%;
748
+ font-family: 'Fira Code', 'Roboto Mono', Consolas, 'Microsoft Yahei';
749
+ line-height: 25px;
750
+ font-size: 12px;
751
+ padding-left: 29.77px;
752
+ color: #ffffff;
753
+ }
754
+ .cm-editor {
755
+ .cm-line {
756
+ // 给下面的 .cm-activeLine::before 当定位父级
757
+ position: relative;
758
+ font-size: 16px;
759
+ line-height: 25px;
760
+ font-family: 'Fira Code', 'Roboto Mono', Consolas, 'Microsoft Yahei';
761
+ }
762
+ /* 当前行高亮 vs 选中区,必须一眼能区分 —— 这里真正的坑不是配色而是**绘制层级**:
763
+ CM6 的选区画在 .cm-selectionLayer 上(drawSelection 内部 above:false,z-index 取 -1-pos,是负数),
764
+ 它和 .cm-content 同在 .cm-scroller 这个层叠上下文里;而 .cm-line 是普通 in-flow 块,
765
+ 按 CSS 绘制顺序「负 z-index 后代 → in-flow 块 → 行内内容」,**in-flow 块画在选区层之上**。
766
+ 所以给 .cm-line 设不透明背景会把选区整块盖掉 —— baseTheme 自带的
767
+ &light .cm-activeLine: #cceeff44 / &dark: #99eeff33 是半透明的,才不会盖住,
768
+ 之前改成了不透明色,选区就被糊掉了。
769
+ 解法:把当前行高亮挪到 ::before 上并压到 z-index:-100,绘制顺序变成
770
+ 当前行底(-100) → 选区层(-2) → 文字
771
+ 选中区永远压在当前行高亮之上,同时仍然在文字之下。 */
772
+ .cm-line.cm-activeLine::before {
773
+ content: '';
774
+ position: absolute;
775
+ top: 0;
776
+ right: 0;
777
+ bottom: 0;
778
+ left: 0;
779
+ z-index: -100;
780
+ pointer-events: none;
781
+ background-color: #173541;
782
+ }
783
+ // 当前行高亮改由上面的 ::before 承载;这里必须把主题自带的**不透明**底色清掉,
784
+ // 否则它作为 in-flow 块的背景仍然会盖住选区层(solarizedDark 的 .cm-activeLine 就是 #173541,
785
+ // 和它的 selection 同色,这正是最初「选中区看不出来」的根源)。
786
+ .cm-activeLine {
787
+ background-color: transparent;
788
+ }
789
+ .cm-activeLineGutter {
790
+ background-color: #173541;
791
+ }
792
+ /* 选区色必须带 !important:CM 6.39 的 baseTheme 把默认色写成了
793
+ "&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground"
794
+ 这种「5 个类 + 子代选择器」,权重高于本组件、也高于 solarizedDark 自带的主题,
795
+ 光堆类名盖不住;用 !important 也不用跟它的 DOM 结构绑死。 */
796
+ .cm-selectionBackground {
797
+ /* 失焦:压暗一档 */
798
+ background-color: #204a5a !important;
799
+ }
800
+ &.cm-focused .cm-selectionBackground {
801
+ /* 双击选词、拖选都走这个色,和当前行的中性色明确区分 */
802
+ background-color: #2c6076 !important;
803
+ }
804
+ }
805
+ .codemirror-editor {
806
+ &::-webkit-scrollbar {
807
+ width: 6px;
808
+ height: 6px;
809
+ background-color: #173541;
810
+ }
811
+ &::-webkit-scrollbar-thumb {
812
+ background-color: #f5f5f5;
813
+ &:hover {
814
+ background-color: #fff;
815
+ }
816
+ }
817
+ }
818
+ .ai-prompt-panel {
819
+ position: absolute;
820
+ left: 8px;
821
+ right: 8px;
822
+ bottom: 34px;
823
+ z-index: 10;
824
+ padding: 6px 8px;
825
+ border: 1px solid #409eff;
826
+ border-radius: 4px;
827
+ background: rgba(15, 43, 54, 0.96);
828
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
829
+ .ai-panel-head {
830
+ display: flex;
831
+ align-items: center;
832
+ font-size: 12px;
833
+ color: #fff;
834
+ .ai-tag {
835
+ margin-right: 6px;
836
+ padding: 0 4px;
837
+ border-radius: 2px;
838
+ background: #409eff;
839
+ font-size: 11px;
840
+ line-height: 16px;
841
+ }
842
+ .ai-scope {
843
+ flex: 1;
844
+ opacity: 0.7;
845
+ }
846
+ .ai-history-entry {
847
+ margin-right: 8px;
848
+ padding: 0 4px;
849
+ border: 1px solid rgba(255, 255, 255, 0.35);
850
+ border-radius: 2px;
851
+ font-size: 11px;
852
+ line-height: 15px;
853
+ cursor: pointer;
854
+ opacity: 0.8;
855
+ &:hover,
856
+ &.is-active {
857
+ border-color: #409eff;
858
+ background: #409eff;
859
+ opacity: 1;
860
+ }
861
+ }
862
+ .ai-close {
863
+ cursor: pointer;
864
+ font-size: 16px;
865
+ line-height: 16px;
866
+ opacity: 0.7;
867
+ &:hover {
868
+ opacity: 1;
869
+ }
870
+ }
871
+ }
872
+ .ai-input {
873
+ width: 100%;
874
+ max-width: 100%;
875
+ box-sizing: border-box;
876
+ margin: 5px 0;
877
+ padding: 4px 6px;
878
+ border: 1px solid #2f5d6c;
879
+ border-radius: 3px;
880
+ background: #173541;
881
+ color: #fff;
882
+ font-size: 12px;
883
+ line-height: 18px;
884
+ resize: vertical;
885
+ outline: none;
886
+ overflow-wrap: break-word;
887
+ word-break: break-all;
888
+ &:focus {
889
+ border-color: #409eff;
890
+ }
891
+ &:disabled {
892
+ opacity: 0.6;
893
+ }
894
+ }
895
+ .ai-panel-foot {
896
+ display: flex;
897
+ align-items: center;
898
+ justify-content: space-between;
899
+ font-size: 12px;
900
+ color: #fff;
901
+ .ai-tip {
902
+ opacity: 0.6;
903
+ }
904
+ .ai-btn {
905
+ padding: 1px 10px;
906
+ border-radius: 3px;
907
+ background: #409eff;
908
+ cursor: pointer;
909
+ &.danger {
910
+ background: #f56c6c;
911
+ }
912
+ &.is-disabled {
913
+ background: #6b7c85;
914
+ cursor: not-allowed;
915
+ }
916
+ }
917
+ }
918
+ .ai-error {
919
+ margin-top: 4px;
920
+ font-size: 12px;
921
+ color: #f56c6c;
922
+ word-break: break-all;
923
+ }
924
+ }
925
+ .ai-history-panel {
926
+ position: absolute;
927
+ left: 8px;
928
+ right: 8px;
929
+ bottom: 34px;
930
+ z-index: 11;
931
+ display: flex;
932
+ flex-direction: column;
933
+ max-height: 62%;
934
+ padding: 6px 8px;
935
+ border: 1px solid #409eff;
936
+ border-radius: 4px;
937
+ background: rgba(15, 43, 54, 0.98);
938
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
939
+ .ai-history-head {
940
+ display: flex;
941
+ align-items: center;
942
+ font-size: 12px;
943
+ color: #fff;
944
+ .ai-tag {
945
+ margin-right: 6px;
946
+ padding: 0 4px;
947
+ border-radius: 2px;
948
+ background: #409eff;
949
+ font-size: 11px;
950
+ line-height: 16px;
951
+ }
952
+ .ai-scope {
953
+ flex: 1;
954
+ opacity: 0.7;
955
+ }
956
+ .ai-btn.plain {
957
+ margin-right: 8px;
958
+ }
959
+ .ai-close {
960
+ cursor: pointer;
961
+ font-size: 16px;
962
+ line-height: 16px;
963
+ opacity: 0.7;
964
+ &:hover {
965
+ opacity: 1;
966
+ }
967
+ }
968
+ }
969
+ .ai-btn.plain {
970
+ padding: 1px 6px;
971
+ border: 1px solid rgba(255, 255, 255, 0.35);
972
+ border-radius: 3px;
973
+ background: transparent;
974
+ font-size: 11px;
975
+ cursor: pointer;
976
+ &:hover {
977
+ border-color: #409eff;
978
+ color: #409eff;
979
+ }
980
+ }
981
+ .ai-history-empty {
982
+ padding: 12px 0;
983
+ color: #fff;
984
+ font-size: 12px;
985
+ text-align: center;
986
+ opacity: 0.6;
987
+ }
988
+ .ai-history-list {
989
+ flex: 1;
990
+ margin-top: 4px;
991
+ overflow-y: auto;
992
+ }
993
+ .ai-history-item {
994
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
995
+ &:first-child {
996
+ border-top: none;
997
+ }
998
+ }
999
+ .ai-history-q {
1000
+ display: flex;
1001
+ align-items: center;
1002
+ gap: 6px;
1003
+ padding: 4px 0;
1004
+ color: #fff;
1005
+ font-size: 12px;
1006
+ cursor: pointer;
1007
+ &:hover {
1008
+ background: rgba(64, 158, 255, 0.15);
1009
+ }
1010
+ .ai-history-time {
1011
+ flex: none;
1012
+ font-size: 11px;
1013
+ opacity: 0.55;
1014
+ }
1015
+ .ai-history-text {
1016
+ flex: 1;
1017
+ overflow: hidden;
1018
+ white-space: nowrap;
1019
+ text-overflow: ellipsis;
1020
+ }
1021
+ .ai-history-toggle {
1022
+ flex: none;
1023
+ font-size: 11px;
1024
+ color: #409eff;
1025
+ }
1026
+ }
1027
+ .ai-history-body {
1028
+ padding-bottom: 6px;
1029
+ }
1030
+ .ai-history-label {
1031
+ display: flex;
1032
+ align-items: center;
1033
+ margin: 2px 0;
1034
+ color: #409eff;
1035
+ font-size: 11px;
1036
+ .ai-history-scope {
1037
+ margin-left: 6px;
1038
+ color: #fff;
1039
+ opacity: 0.6;
1040
+ }
1041
+ .ai-btn.plain {
1042
+ margin-left: auto;
1043
+ }
1044
+ }
1045
+ .ai-history-pre {
1046
+ max-height: 170px;
1047
+ margin: 0 0 6px;
1048
+ padding: 4px 6px;
1049
+ overflow: auto;
1050
+ border-radius: 3px;
1051
+ background: #173541;
1052
+ color: #fff;
1053
+ font-family: 'Fira Code', 'Roboto Mono', Consolas, 'Microsoft Yahei';
1054
+ font-size: 12px;
1055
+ line-height: 18px;
1056
+ white-space: pre-wrap;
1057
+ word-break: break-all;
1058
+ }
1059
+ }
1060
+ /* 放大场景(window-form 的「放大」抽屉):编辑区撑满整个抽屉,AI 面板仍是 12px / 2 行会显得很局促,
1061
+ 这里整体提一档字号、加两行输入高度、面板内边距与历史面板高度同步放大。
1062
+ 选择器带 .is-ai-large 是为了权重高于上面 .is-light 里的同名规则,不依赖书写顺序。 */
1063
+ &.is-ai-large {
1064
+ .ai-prompt-panel,
1065
+ .ai-history-panel {
1066
+ left: 12px;
1067
+ right: 12px;
1068
+ bottom: 46px;
1069
+ padding: 10px 12px;
1070
+ border-radius: 6px;
1071
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
1072
+ }
1073
+ .ai-prompt-panel {
1074
+ .ai-panel-head,
1075
+ .ai-panel-foot {
1076
+ font-size: 14px;
1077
+ }
1078
+ .ai-tag {
1079
+ font-size: 12px;
1080
+ line-height: 18px;
1081
+ }
1082
+ .ai-history-entry {
1083
+ font-size: 12px;
1084
+ line-height: 18px;
1085
+ }
1086
+ .ai-input {
1087
+ margin: 8px 0;
1088
+ padding: 6px 8px;
1089
+ font-size: 14px;
1090
+ line-height: 22px;
1091
+ }
1092
+ .ai-btn {
1093
+ padding: 3px 14px;
1094
+ font-size: 14px;
1095
+ }
1096
+ .ai-error {
1097
+ font-size: 14px;
1098
+ }
1099
+ }
1100
+ .ai-history-panel {
1101
+ max-height: 72%;
1102
+ .ai-history-head,
1103
+ .ai-history-q {
1104
+ font-size: 14px;
1105
+ }
1106
+ .ai-history-toggle,
1107
+ .ai-history-time {
1108
+ font-size: 12px;
1109
+ }
1110
+ .ai-history-label {
1111
+ font-size: 12px;
1112
+ .ai-btn.plain {
1113
+ font-size: 12px;
1114
+ }
1115
+ }
1116
+ .ai-history-pre {
1117
+ max-height: 300px;
1118
+ padding: 6px 8px;
1119
+ font-size: 13px;
1120
+ line-height: 21px;
1121
+ }
1122
+ }
1123
+ }
1124
+ .editor-operation-btn {
1125
+ position: absolute;
1126
+ bottom: 1px;
1127
+ right: 1px;
1128
+ padding: 0 3px;
1129
+ display: flex;
1130
+ align-items: center;
1131
+ border-radius: 3px;
1132
+ opacity: 0.5;
1133
+ background: #409eff;
1134
+ transition: all 0.3s ease;
1135
+ i {
1136
+ font-size: 16px;
1137
+ color: #fff;
1138
+ margin: 0 5px;
1139
+ cursor: pointer;
1140
+ line-height: 24px;
1141
+ }
1142
+ .ai-entry {
1143
+ margin: 0 5px;
1144
+ padding: 0 4px;
1145
+ border-radius: 2px;
1146
+ background: rgba(255, 255, 255, 0.22);
1147
+ color: #fff;
1148
+ font-size: 12px;
1149
+ font-weight: bolder;
1150
+ line-height: 18px;
1151
+ cursor: pointer;
1152
+ }
1153
+ &:hover {
1154
+ opacity: 1;
1155
+ background: #409eff;
1156
+ }
1157
+ /* 未聚焦:淡出并停止接收事件(仅用 opacity:0 会留下一个看不见但能点到的区域) */
1158
+ &.is-hidden {
1159
+ opacity: 0;
1160
+ pointer-events: none;
1161
+ }
1162
+ }
1163
+ }
1164
+ </style>