MarkdownPreview.vue
53.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
<template>
<!-- 动态绑定主题类名 -->
<div
class="markdown-preview-container"
:class="[appStore.theme]"
@click="handlePreviewClick"
@dragstart="handleDragStart"
>
<div
class="markdown-body"
v-html="renderedContent"
:class="{ streaming: isStreaming }"
></div>
<span v-if="isStreaming" class="streaming-cursor"></span>
</div>
<!-- 文件保存对话框 -->
<el-dialog
v-model="saveDialogVisible"
title="保存文件"
width="520px"
:close-on-click-modal="false"
:append-to-body="true"
:z-index="10000"
class="file-save-dialog"
@close="handleDialogClose"
>
<div class="save-dialog-content">
<!-- 文件夹树形列表 -->
<div class="folder-tree-container">
<el-tree
ref="folderTreeRef"
:data="treeData"
:props="{
children: 'children',
label: 'label',
isLeaf: 'isLeaf',
}"
node-key="id"
:lazy="true"
:load="loadTreeChildren"
:highlight-current="true"
:expand-on-click-node="false"
:default-expand-all="false"
:render-after-expand="false"
class="folder-tree"
@node-click="handleTreeNodeClick"
>
<template #default="{ data }">
<span class="tree-node">
<i
:class="[
'fas',
data.id === -1 ? 'fa-home' : 'fa-folder',
'folder-icon'
]"
></i>
<span class="tree-node-label">{{ data.label }}</span>
</span>
</template>
</el-tree>
<div v-if="treeData.length === 0 && !isLoadingFolders && saveDialogVisible" class="empty-state">
<i class="fas fa-folder-open empty-icon"></i>
<p class="empty-text">根目录下没有文件夹</p>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancelSave" size="default">取消</el-button>
<el-button
type="primary"
@click="handleSave"
size="default"
:loading="isSaving"
>
保存
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, watch, nextTick, onMounted, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import MarkdownIt from "markdown-it";
import DOMPurify from "dompurify";
import hljs from "highlight.js";
import "highlight.js/styles/github-dark.css";
import mermaid from "mermaid";
import { useAppStore } from "@/stores/app";
import { useCodexStore } from "@/stores/codex";
import { ElMessage, ElLoading } from "element-plus";
import { getFiles } from "@/api/files";
// 兼容性插件引入
// @ts-ignore
import dayjs from "dayjs";
// @ts-ignore
import advancedFormat from "dayjs/plugin/advancedFormat";
// @ts-ignore
import customParseFormat from "dayjs/plugin/customParseFormat";
// @ts-ignore
import isoWeek from "dayjs/plugin/isoWeek";
// @ts-ignore
import weekOfYear from "dayjs/plugin/weekOfYear";
// @ts-ignore
import isBetween from "dayjs/plugin/isBetween";
// 强制转换,解决某些环境下插件没有默认导出的问题
const _advancedFormat = (advancedFormat as any).default || advancedFormat;
const _customParseFormat = (customParseFormat as any).default || customParseFormat;
const _isoWeek = (isoWeek as any).default || isoWeek;
const _weekOfYear = (weekOfYear as any).default || weekOfYear;
const _isBetween = (isBetween as any).default || isBetween;
dayjs.extend(_advancedFormat);
dayjs.extend(_customParseFormat);
dayjs.extend(_isoWeek);
dayjs.extend(_weekOfYear);
dayjs.extend(_isBetween);
const appStore = useAppStore();
const codexStore = useCodexStore();
const router = useRouter();
const route = useRoute();
// 文件保存对话框相关状态
const saveDialogVisible = ref(false);
const selectedNodeId = ref<string | number | null>(null); // 选中的节点ID
const isSaving = ref(false);
const isLoadingFolders = ref(false);
// 树形数据(只包含第一级)
const treeData = ref<Array<{
id: string | number;
label: string;
isFolder: boolean;
children?: any[];
isLeaf?: boolean;
}>>([]);
const pendingSaveInfo = ref<{ sessionId: number; tmpPath: string; btn: HTMLElement | null } | null>(null);
// Markdown-it 插件
// @ts-ignore
import markdownItAttrs from "markdown-it-attrs";
// @ts-ignore
import markdownItSup from "markdown-it-sup";
// @ts-ignore
import markdownItSub from "markdown-it-sub";
// @ts-ignore
import markdownItMark from "markdown-it-mark";
// @ts-ignore
import markdownItFootnote from "markdown-it-footnote";
// @ts-ignore
import markdownItTaskLists from "markdown-it-task-lists";
import markdownItAnchor from "markdown-it-anchor";
// @ts-ignore
import markdownItImageFigures from "markdown-it-image-figures";
// @ts-ignore
import markdownItMultimdTable from "markdown-it-multimd-table";
// KaTeX
// @ts-ignore
import markdownItTexmath from "markdown-it-texmath";
import katex from "katex";
import "katex/dist/katex.min.css";
// Props
interface Props {
content: string;
isStreaming?: boolean;
model?: string;
}
const props = withDefaults(defineProps<Props>(), {
isStreaming: false,
model: "standard",
});
// 初始化 Mermaid 逻辑封装
const initMermaid = () => {
mermaid.initialize({
startOnLoad: false,
theme: appStore.theme === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
fontFamily: 'inherit',
gantt: {
useWidth: 1200,
fontSize: 12,
}
});
};
// 处理 Mermaid 渲染
const renderMermaid = async () => {
try {
const elements = document.querySelectorAll(".mermaid");
if (elements.length > 0) {
initMermaid();
// 批量运行渲染
await mermaid.run({ nodes: Array.from(elements) as HTMLElement[] });
}
} catch (error) {
// 过滤流式中间态的语法错误,避免控制台由于残缺语法而爆红
if (!props.isStreaming) {
console.warn("[MarkdownPreview] Mermaid 最终渲染警告:", error);
}
}
};
// 复制功能
const handleCopyCode = (target: HTMLElement) => {
const code = decodeURIComponent(target.getAttribute("data-code") || "");
navigator.clipboard.writeText(code).then(() => {
const originalText = target.innerText;
target.innerText = "已复制";
target.classList.add("copied");
setTimeout(() => {
target.innerText = originalText;
target.classList.remove("copied");
}, 2000);
});
};
/**
* 加载指定文件夹下的文件和文件夹(不递归,只加载一级)
* @param parentId 父文件夹ID,undefined表示根目录
* @returns 文件和文件夹列表
*/
const loadFolderItems = async (
parentId?: string | number
): Promise<Array<{ id: string | number; name: string; isFolder: boolean }>> => {
try {
const response = await getFiles(parentId);
if (!response || !response.data) {
return [];
}
// 处理不同的数据格式
let items: any[] = [];
if (Array.isArray(response.data)) {
items = response.data;
} else if (response.data.files && Array.isArray(response.data.files)) {
items = response.data.files;
} else if (response.data.items && Array.isArray(response.data.items)) {
items = response.data.items;
}
// 只返回文件夹,过滤掉文件
return items
.filter((item: any) => {
const isFolder = item.folder === true ||
item.type === "folder" ||
item.isFolder === true ||
item.is_folder === true;
return isFolder; // 只保留文件夹
})
.map((item: any) => {
const itemId = item.id === 0 || item.id === "0" ? -1 : item.id;
const itemName = item.folderName || item.name || "未命名文件夹";
return {
id: itemId,
name: itemName,
isFolder: true, // 现在只返回文件夹
};
});
} catch (error) {
console.error(`加载文件夹内容失败 (parentId: ${parentId}):`, error);
return [];
}
};
// 加载第一级文件夹(包含根目录"我的文档")
const loadFolderList = async () => {
try {
isLoadingFolders.value = true;
// 只加载根目录下的第一级文件夹
const items = await loadFolderItems(undefined);
// 转换为树形组件需要的格式,并添加根目录"我的文档"
treeData.value = [
{
id: -1,
label: "我的文档",
isFolder: true,
isLeaf: false,
children: items.map(item => ({
id: item.id,
label: item.name,
isFolder: true,
isLeaf: false, // 文件夹不是叶子节点,可以展开
children: [], // 文件夹有children数组(空),等待懒加载
})),
}
];
console.log(`已加载根目录和 ${items.length} 个一级文件夹`);
} catch (error) {
console.error("加载文件列表失败:", error);
// 即使加载失败,也显示根目录
treeData.value = [
{
id: -1,
label: "我的文档",
isFolder: true,
isLeaf: false,
children: [],
}
];
ElMessage.warning("加载文件夹列表失败,将保存到根目录");
} finally {
isLoadingFolders.value = false;
}
};
/**
* 树形组件懒加载函数
* @param node 当前节点
* @param resolve 回调函数,用于返回子节点数据
*/
const loadTreeChildren = async (node: any, resolve: (data: any[]) => void) => {
try {
// 如果是根节点"我的文档"(level === 0),加载第一级文件夹
// 如果是其他节点,加载该节点的子文件夹
const parentId = node.level === 0 ? undefined : node.data.id;
// 只加载文件夹
const items = await loadFolderItems(parentId);
// 转换为树形组件需要的格式(只包含文件夹)
const children = items.map(item => ({
id: item.id,
label: item.name,
isFolder: true,
isLeaf: false, // 文件夹不是叶子节点,可以展开
children: [], // 等待懒加载
}));
// 直接 resolve,:render-after-expand="false" 确保数据加载完成后才渲染
resolve(children);
} catch (error) {
console.error(`懒加载失败 (parentId: ${node.data.id}, level: ${node.level}):`, error);
resolve([]);
}
};
// 树节点点击处理
const folderTreeRef = ref();
const handleTreeNodeClick = (data: any) => {
// 使用 nextTick 确保 DOM 更新完成后再更新状态,避免闪烁
nextTick(() => {
selectedNodeId.value = data.id;
// 手动设置当前节点,确保选中状态正确
if (folderTreeRef.value) {
folderTreeRef.value.setCurrentKey(data.id);
}
});
console.log("选中节点:", data);
};
// 下载并保存生成文件(图片 / PDF / DOCX / PPT 等)
const handleDownloadImage = async (btn: HTMLElement) => {
const tmpPath = btn.getAttribute('data-tmp-path');
const sessionIdRaw = btn.getAttribute('data-session-id');
let sessionId = sessionIdRaw && sessionIdRaw !== 'undefined' ? parseInt(sessionIdRaw) : 0;
// 增加降级处理:如果 DOM 属性中的 sessionId 无效,尝试使用当前活动会话的 ID
if ((isNaN(sessionId) || sessionId === 0) && codexStore.currentSession?.sessionId) {
sessionId = codexStore.currentSession.sessionId;
console.log("使用当前活动会话的 sessionId 作为降级:", sessionId);
}
if (!tmpPath || isNaN(sessionId) || sessionId === 0) {
console.warn("无法执行保存:元数据缺失", { tmpPath, sessionId });
return;
}
// 保存按钮的原始 HTML 内容,以便取消时恢复(包含图标)
const originalHTML = btn.innerHTML;
btn.setAttribute('data-original-html', originalHTML);
btn.setAttribute('disabled', 'true');
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
// 保存按钮信息和文件信息
pendingSaveInfo.value = { sessionId, tmpPath, btn };
selectedNodeId.value = null;
// 先打开对话框
saveDialogVisible.value = true;
// 等待对话框渲染完成后再加载文件列表
await nextTick();
await loadFolderList();
// 再次等待,确保数据已注入到下拉框
await nextTick();
};
// 恢复按钮状态
const restoreButtonState = () => {
if (pendingSaveInfo.value?.btn) {
pendingSaveInfo.value.btn.removeAttribute('disabled');
const originalHTML = pendingSaveInfo.value.btn.getAttribute('data-original-html');
if (originalHTML) {
pendingSaveInfo.value.btn.innerHTML = originalHTML;
} else {
// 降级处理:如果没有保存原始 HTML,使用默认内容
pendingSaveInfo.value.btn.innerHTML = '<i class="fas fa-save"></i> 保存';
}
}
};
// 取消保存
const cancelSave = () => {
saveDialogVisible.value = false;
// restoreButtonState 会在 handleDialogClose 中调用
};
// 对话框关闭时的处理(无论是点击取消、关闭按钮还是点击遮罩)
const handleDialogClose = () => {
selectedNodeId.value = null;
restoreButtonState();
pendingSaveInfo.value = null;
// 延迟清空数据,避免关闭动画时显示空状态
setTimeout(() => {
treeData.value = []; // 清空数据,下次打开时重新加载
// 重置树形组件的选中状态
if (folderTreeRef.value) {
folderTreeRef.value.setCurrentKey(null);
}
}, 300); // 等待关闭动画完成(Element Plus 默认关闭动画约 300ms)
};
// 在树形数据中查找节点(递归)
const findNodeInTree = (nodes: any[], id: string | number): any => {
for (const node of nodes) {
if (node.id === id) {
return node;
}
if (node.children && node.children.length > 0) {
const found = findNodeInTree(node.children, id);
if (found) return found;
}
}
return null;
};
// 执行保存
const handleSave = async () => {
if (!pendingSaveInfo.value) {
ElMessage.error("保存信息缺失");
return;
}
const { sessionId, tmpPath, btn } = pendingSaveInfo.value;
try {
isSaving.value = true;
// 从选中的节点获取parentId
// 只选择文件夹,根目录(id === -1)时 parentId 为 null
let parentId: number | null = null;
if (selectedNodeId.value !== null && selectedNodeId.value !== -1) {
// 选择的是文件夹(非根目录),使用文件夹ID
parentId = Number(selectedNodeId.value);
} else {
// 未选择或选择的是根目录,parentId为null(保存到根目录)
parentId = null;
}
const result = await codexStore.submitFileDecision(sessionId, tmpPath, parentId);
if (result.success) {
ElMessage.success("文件已成功保存到工作区");
saveDialogVisible.value = false;
// 更新按钮状态
if (btn) {
btn.innerHTML = '<i class="fas fa-check"></i> 已保存';
// 与图片保存按钮保持一致:使用 is-saved 类控制样式
btn.classList.add("is-saved");
btn.setAttribute('disabled', 'true');
}
// 清理状态
selectedNodeId.value = null;
pendingSaveInfo.value = null;
if (folderTreeRef.value) {
folderTreeRef.value.setCurrentKey(null);
}
} else {
ElMessage.error(result.error || "保存失败");
}
} catch (error: any) {
console.error("保存文件失败:", error);
ElMessage.error("保存文件失败,请重试");
} finally {
isSaving.value = false;
}
};
// 处理文件卡片点击
const handleFileCardClick = async (cardElement: HTMLElement) => {
const path = cardElement.getAttribute('data-file-path');
const fileName = cardElement.getAttribute('data-file-name');
const fileType = cardElement.getAttribute('data-file-type');
if (!path || !fileName || !fileType) {
console.warn("文件卡片缺少必要信息:", { path, fileName, fileType });
ElMessage.warning("文件信息不完整,无法打开");
return;
}
const loading = ElLoading.service({
lock: true,
text: "正在打开文件...",
background: "rgba(0, 0, 0, 0.7)",
});
try {
// 检查是否已经在 Workspace 页面
const isAlreadyOnWorkspace = route.name === "Workspace";
if (isAlreadyOnWorkspace) {
// 如果已经在 Workspace 页面,发送自定义事件直接打开文件,避免页面刷新
window.dispatchEvent(
new CustomEvent("open-codex-file", {
detail: {
filePath: path,
fileName: fileName,
fileType: fileType,
},
}),
);
loading.close();
return;
}
// 如果不在 Workspace 页面,通过路由跳转到工作台
await router.push({
name: "Workspace",
query: {
filePath: path,
fileName: fileName,
fileType: fileType,
fromCodex: "true",
},
});
} catch (error: any) {
console.error("打开文件失败:", error);
ElMessage.error("打开文件失败,请重试");
} finally {
loading.close();
}
};
// 全局点击事件处理
const handlePreviewClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 代码复制
if (target.classList.contains("copy-code-btn")) {
handleCopyCode(target);
return;
}
// 生成图片保存
const saveBtn = target.closest(".image-save-btn") as HTMLElement;
if (saveBtn) {
handleDownloadImage(saveBtn);
return;
}
// 文件卡片点击(排除保存按钮)
const fileCard = target.closest(".file-generated-card") as HTMLElement;
const isSaveBtn = target.closest(".image-save-btn") as HTMLElement;
if (fileCard && !isSaveBtn) {
handleFileCardClick(fileCard);
return;
}
};
// 处理拖拽开始
const handleDragStart = (e: DragEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG') {
const tmpPath = target.getAttribute('data-tmp-path');
const sessionId = target.getAttribute('data-session-id');
const src = target.getAttribute('src');
if (tmpPath && sessionId) {
try {
const payload = {
from: "MarkdownPreview",
type: "file",
files: [
{
name: "generated-image.png",
type: "image",
tmpPath: tmpPath,
sessionId: sessionId,
src: src
},
],
};
const jsonStr = JSON.stringify(payload);
e.dataTransfer!.setData("application/json", jsonStr);
e.dataTransfer!.setData("text/plain", jsonStr);
e.dataTransfer!.effectAllowed = "copyMove";
console.log("📸 MarkdownPreview 拖拽开始:", payload);
} catch (err) {
console.error("❌ MarkdownPreview 设置拖拽数据失败:", err);
}
}
}
};
onMounted(() => {
initMermaid();
renderMermaid();
});
watch(() => appStore.theme, () => {
nextTick(() => renderMermaid());
});
watch(() => props.content, () => {
if (!props.isStreaming) {
nextTick(() => renderMermaid());
}
});
// --- 关键函数:Mermaid 代码智能纠偏器 (V23 调试增强版) ---
function fixMermaidSyntax(code: string): string {
if (!code) return "";
// 0. 全局标准化:将全角冒号替换为半角冒号
let cleanCode = code.replace(/:/g, ':');
const isGantt = cleanCode.includes('gantt');
// 第一步:合并断行
let lines = cleanCode.split('\n');
let mergedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i] || "";
let trimmed = line.trim();
if (!trimmed && i < lines.length - 1) continue;
// 断行合并 A: 当前行以冒号开头
if (trimmed.startsWith(':') && mergedLines.length > 0) {
const lastIdx = mergedLines.length - 1;
mergedLines[lastIdx] = (mergedLines[lastIdx] || "").trim() + " " + trimmed;
continue;
}
// 断行合并 B: 上一行以冒号结尾
if (mergedLines.length > 0) {
const prevLine = (mergedLines[mergedLines.length - 1] || "").trim();
if (prevLine.endsWith(':') && !trimmed.startsWith('section') && !trimmed.startsWith('title') && trimmed !== "") {
const lastIdx = mergedLines.length - 1;
mergedLines[lastIdx] = mergedLines[lastIdx] + " " + trimmed;
continue;
}
}
mergedLines.push(line);
}
// 第二步:如果是甘特图,建立中文任务名 -> 简单ID 的映射表
if (!isGantt) {
return mergedLines.join('\n');
}
const taskNameToId = new Map<string, string>();
let taskCounter = 0;
// 扫描所有任务定义,收集任务名
for (let line of mergedLines) {
const trimmed = line.trim();
if (!trimmed.includes(':')) continue;
const parts = trimmed.split(':');
let taskName = (parts[0] || "").trim();
// 移除可能存在的引号
if (taskName.startsWith('"') && taskName.endsWith('"')) {
taskName = taskName.slice(1, -1);
}
// 跳过配置行
if (/^(section|title|dateFormat|axisFormat|excludes|includes|todayMarker)/.test(taskName)) {
continue;
}
// 如果任务名包含中文,为其分配一个简单ID
if (taskName && /[\u4e00-\u9fa5]/.test(taskName) && !taskNameToId.has(taskName)) {
taskCounter++;
taskNameToId.set(taskName, `task${taskCounter}`);
}
}
// 第三步:重写任务定义,替换 after 引用
const finalLines: string[] = [];
for (let line of mergedLines) {
let trimmed = line.trim();
if (!trimmed.includes(':')) {
finalLines.push(line);
continue;
}
const parts = trimmed.split(':');
let taskName = (parts[0] || "").trim();
let rest = parts.slice(1).join(':').trim();
// 移除任务名的引号(如果有)
const cleanTaskName = taskName.replace(/^"|"$/g, '');
// 跳过配置行
if (/^(section|title|dateFormat|axisFormat|excludes|includes|todayMarker)/.test(cleanTaskName)) {
finalLines.push(line);
continue;
}
// 如果这是一个中文任务,重写为 "任务名" : taskId, ...
if (taskNameToId.has(cleanTaskName)) {
const taskId = taskNameToId.get(cleanTaskName)!;
// 处理 rest 部分,替换 after 引用
if (rest.includes('after')) {
// 匹配 after "任务名" 或 after 任务名(到逗号或空格或结尾为止)
rest = rest.replace(/after\s+(?:"([^"]+)"|(\S+))/g, (fullMatch, quoted, unquoted) => {
const refName = (quoted || unquoted || "").trim();
if (taskNameToId.has(refName)) {
return `after ${taskNameToId.get(refName)}`;
}
return fullMatch; // 保持原样
});
}
// 智能重组:确保语法完整性
if (rest) {
// rest 不为空,在前面加上 taskId
line = ` "${cleanTaskName}" : ${taskId}, ${rest}`;
} else {
// rest 为空,添加默认时长避免 Mermaid 解析错误
line = ` "${cleanTaskName}" : ${taskId}, 1d`;
}
} else if (rest.includes('after')) {
// 即使任务名不是中文,也要处理 after 中的中文引用
rest = rest.replace(/after\s+(?:"([^"]+)"|(\S+))/g, (fullMatch, quoted, unquoted) => {
const refName = (quoted || unquoted || "").trim();
if (taskNameToId.has(refName)) {
return `after ${taskNameToId.get(refName)}`;
}
return fullMatch; // 保持原样
});
line = ` ${taskName} : ${rest}`;
}
finalLines.push(line);
}
const result = finalLines.join('\n');
// 调试输出:在甘特图场景下,输出处理后的代码供排查
if (isGantt && typeof console !== 'undefined') {
console.log('[Mermaid 甘特图纠偏] 原始代码:', code);
console.log('[Mermaid 甘特图纠偏] 处理后代码:', result);
console.log('[Mermaid 甘特图纠偏] 任务ID映射表:', Array.from(taskNameToId.entries()));
}
return result;
}
// 初始化 Markdown 渲染器
const md: any = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: true,
highlight: (str: string, lang: string): string => {
if (lang === 'mermaid') {
// 在交给渲染前进行语法纠正
const cleanCode = fixMermaidSyntax(str);
return `<div class="mermaid-container"><pre class="mermaid">${md.utils.escapeHtml(cleanCode)}</pre></div>`;
}
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><div class="code-header"><span class="code-lang">${lang}</span><button class="copy-code-btn" data-code="${encodeURIComponent(str)}">复制</button></div><code class="language-${lang}">${
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
}</code></pre>`;
} catch (__) {}
}
return `<pre class="hljs"><div class="code-header"><button class="copy-code-btn" data-code="${encodeURIComponent(str)}">复制</button></div><code>${md.utils.escapeHtml(str)}</code></pre>`;
},
});
md.use(markdownItAttrs)
.use(markdownItSup)
.use(markdownItSub)
.use(markdownItMark)
.use(markdownItFootnote)
.use(markdownItTaskLists)
.use(markdownItAnchor, { permalink: false, level: [1, 2, 3, 4, 5, 6] })
.use(markdownItImageFigures, { figcaption: true, copyAttrs: true, lazy: true, async: true })
.use(markdownItMultimdTable, { multiline: true, rowspan: true, headerless: true })
.use(markdownItTexmath, { engine: katex, delimiters: "dollars", katexOptions: { throwOnError: false, strict: false } });
// 自定义图片渲染:如果包含生成元数据,则增加“保存到工作区”按钮
const defaultImageRender = md.renderer.rules.image || function(tokens: any[], idx: number, options: any, _env: any, self: any) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.image = (tokens: any[], idx: number, options: any, env: any, self: any) => {
const token = tokens[idx];
const tmpPath = token.attrGet('data-tmp-path');
const sessionId = token.attrGet('data-session-id');
// 给图片添加 draggable 属性和元数据
token.attrSet('draggable', 'true');
if (tmpPath) token.attrSet('data-tmp-path', tmpPath);
if (sessionId) token.attrSet('data-session-id', sessionId);
const html = defaultImageRender(tokens, idx, options, env, self);
if (tmpPath && sessionId) {
const isSaved = codexStore.isImageSaved(tmpPath);
return `
<div class="generated-image-wrapper">
${html}
<button
class="image-save-btn ${isSaved ? 'is-saved' : ''}"
${isSaved ? 'disabled' : ''}
data-tmp-path="${tmpPath}"
data-session-id="${sessionId}"
>
<i class="fas ${isSaved ? 'fa-check' : 'fa-save'}"></i>
${isSaved ? '已保存' : '保存'}
</button>
</div>
`;
}
return html;
};
// --- 核心修复函数:Markdown 内容处理 (V25 终极隔离版) ---
function processFullContent(content: string, isStreaming: boolean): string {
if (!content) return content;
// 0. 强制解壳:如果 AI 错误地将公式包裹在代码块中,先解开
// 匹配无语言标识的代码块,且内部包含常见数学符号
let rawContent = content.replace(/\r\n/g, '\n');
rawContent = rawContent.replace(/```(?!\w)\n?([\s\S]*?[∩∪×÷±≈≠≤≥²³^\\=+\-*\/%][\s\S]*?)\n?```/g, '\n$1\n');
let rawLines = rawContent.split('\n');
const codeBlocks: string[] = [];
const htmlBlocks: string[] = [];
const processedLines: string[] = [];
let currentBlock: string[] = [];
let isInsideCode = false;
let isInsideHtml = false;
// 1. 物理隔离代码块和 HTML 块 (使用占位符保护)
// 先检测包含 generated-image-wrapper 的 HTML 块(文件卡片)
for (let i = 0; i < rawLines.length; i++) {
const line = rawLines[i];
const trimmed = line.trim();
// 检测包含 generated-image-wrapper 的 HTML 块开始
if (trimmed.includes('generated-image-wrapper') && trimmed.includes('<div')) {
if (!isInsideHtml) {
isInsideHtml = true;
currentBlock = [line];
continue;
}
}
if (isInsideHtml) {
currentBlock.push(line);
// 检测 HTML 块结束(找到闭合的 </div> 标签,且包含 generated-image-wrapper)
if (trimmed.includes('</div>') && currentBlock.some(l => l.includes('generated-image-wrapper'))) {
// 检查是否是对应的闭合标签(简单检查:计算 div 标签数量)
const allBlockText = currentBlock.join('\n');
const openDivs = (allBlockText.match(/<div[^>]*>/g) || []).length;
const closeDivs = (allBlockText.match(/<\/div>/g) || []).length;
if (closeDivs >= openDivs) {
isInsideHtml = false;
htmlBlocks.push(currentBlock.join('\n'));
processedLines.push(`\n\n__HTML_BLOCK_TOKEN_${htmlBlocks.length - 1}__\n\n`);
currentBlock = [];
continue;
}
}
continue;
}
// 代码块处理
if (trimmed.startsWith('```')) {
if (!isInsideCode) {
isInsideCode = true; currentBlock = [line];
} else {
isInsideCode = false; currentBlock.push(line);
codeBlocks.push(currentBlock.join('\n'));
processedLines.push(`\n\n__MD_BLOCK_TOKEN_${codeBlocks.length - 1}__\n\n`);
currentBlock = [];
}
continue;
}
if (isInsideCode) currentBlock.push(line);
else processedLines.push(line);
}
if (isInsideCode && currentBlock.length > 0) {
if (isStreaming) currentBlock.push('```');
codeBlocks.push(currentBlock.join('\n'));
processedLines.push(`\n\n__MD_BLOCK_TOKEN_${codeBlocks.length - 1}__\n\n`);
}
if (isInsideHtml && currentBlock.length > 0) {
htmlBlocks.push(currentBlock.join('\n'));
processedLines.push(`\n\n__HTML_BLOCK_TOKEN_${htmlBlocks.length - 1}__\n\n`);
}
let res = processedLines.join('\n');
// 2. 文本修复(仅限非代码块区域)
res = res.replace(/\\(\$)/g, '$1').replace(/\\([_#&])/g, '$1');
const symbolMap: Record<string, string> = {
'∩': '\\cap ', '∪': '\\cup ', '×': '\\times ', '÷': '\\div ',
'±': '\\pm ', '≈': '\\approx ', '≠': '\\neq ', '≤': '\\le ', '≥': '\\ge ',
'²': '^2', '³': '^3'
};
Object.keys(symbolMap).forEach(key => { res = res.split(key).join(symbolMap[key]); });
// 增强数学块处理:优先处理块级公式,并替换内部的 | 为 \mid
res = res.replace(/\$\$([\s\S]*?)\$\$/g, (_, p1) => {
const processed = p1.replace(/(?<!\\)\|/g, '\\mid ');
return `\n\n$$${processed}$$\n\n`;
});
res = res.replace(/\\\[([\s\S]*?)\\\]/g, (_, p1) => {
const processed = p1.replace(/(?<!\\)\|/g, '\\mid ');
return `\n\n$$${processed}$$\n\n`;
});
res = res.replace(/\\\(([\s\S]*?)\\\)/g, (_, p1) => {
const processed = p1.replace(/(?<!\\)\|/g, '\\mid ');
return `$${processed}$`;
});
// 3. 数学片段合并逻辑
const lines = res.split('\n');
let isInsideMath = false; let mathBuffer: string[] = []; let finalLines: string[] = [];
const flushMath = () => {
if (mathBuffer.length > 0) {
if (mathBuffer.length === 1) finalLines.push(`\n\n$$${mathBuffer[0]}$$\n\n`);
else { finalLines.push('\n\n$$\\begin{aligned}' + mathBuffer.join(' \\\\ ') + '\\end{aligned}$$\n\n'); }
mathBuffer = [];
}
};
for (let line of lines) {
const trimmed = line.trim();
// 【关键修复】显式跳过包含代码块和 HTML 块占位符的行,绝不让它进入数学逻辑
if (trimmed.includes('__MD_BLOCK_TOKEN_') || trimmed.includes('__HTML_BLOCK_TOKEN_')) {
flushMath();
finalLines.push(line);
continue;
}
const bc = (trimmed.match(/\$\$/g) || []).length;
if (bc > 0) {
flushMath();
if (bc % 2 !== 0) isInsideMath = !isInsideMath;
finalLines.push(line);
continue;
}
if (trimmed.includes('\\begin{') || trimmed.includes('\\end{')) { flushMath(); finalLines.push(line); continue; }
if (isInsideMath) { finalLines.push(line); continue; }
const isTableRow = trimmed.startsWith('|') && trimmed.endsWith('|') && trimmed.includes('-');
const hasMathCmd = /\\[a-zA-Z]{2,}/.test(trimmed);
const hasMathStruct = /[\^_\{\}∩∪×÷±≈≠≤≥²³]/.test(trimmed);
// 增加占位符过滤,确保不误判
const containsPlaceholder = trimmed.includes('__MD_BLOCK_TOKEN_');
const startsWithMath = /^[\\+\-\=]/.test(trimmed) && !trimmed.startsWith("- ") && !trimmed.startsWith("* ");
const isListItem = /^\d+\.\s/.test(trimmed) || trimmed.startsWith("- ") || trimmed.startsWith("* ");
const hasChinese = /[\u4e00-\u9fa5]/.test(trimmed);
// 启发式判断:如果包含较多空格且没有明显的数学命令,则视为普通句子
const spaceCount = (trimmed.match(/\s/g) || []).length;
// 检查是否包含多个英文单词(长度 >= 3)
const longWords = trimmed.match(/[a-zA-Z]{3,}/g) || [];
const hasEnglishWords = longWords.length >= 3;
// 检查是否包含常见的 Markdown 标签或 English 特征
const isLikelySentence = (hasEnglishWords && spaceCount > 2) ||
trimmed.includes("**") ||
trimmed.includes("__") ||
(trimmed.includes("[") && trimmed.includes("]")) ||
/^[A-Z][a-z]+/.test(trimmed) ||
isListItem; // 列表项不是数学公式
if (isTableRow) { flushMath(); finalLines.push(line); continue; }
if (trimmed && !hasChinese && (hasMathCmd || hasMathStruct || startsWithMath) && !containsPlaceholder && !isLikelySentence) {
// 额外检查:如果是常见的英文标点用法(如括号),则不视为数学公式
const isCommonPunctuation = /^[\(\[\{].*[\)\}\]]$/.test(trimmed) && !hasMathCmd;
if (isCommonPunctuation) {
flushMath();
finalLines.push(line);
} else {
mathBuffer.push(trimmed);
}
} else {
if (trimmed === '' && mathBuffer.length > 0) {} else { flushMath(); finalLines.push(line); }
}
}
if (isInsideMath && isStreaming) mathBuffer.push('$$');
flushMath();
res = finalLines.join('\n');
// 4. 标题与思考块修复
res = res.replace(/([^\n])(?=#{1,6}\s)/g, '$1\n\n').replace(/(^#{1,6}\s[^\n]+)\n(?=[^\n#])/gm, '$1\n\n').replace(/^(#{1,6})([^#\s])/gm, '$1 $2');
if (res.includes('<think>')) {
const p = '\n\n'; const s = '\n\n';
if (res.includes('</think>')) res = res.replace(/<think>([\s\S]*?)<\/think>/g, (_, c) => `${p}<details class="think-block"><summary>💭 查看思考过程</summary><div class="think-content">${md.render(c.trim())}</div></details>${s}`);
else res = res.replace(/<think>([\s\S]*)$/g, (_, c) => `${p}<details class="think-block" open><summary>💭 正在思考中...</summary><div class="think-content">${md.render(c.trim())}<span class="streaming-cursor"></span></div></details>${s}`);
}
// 5. 还原占位符并对 Mermaid 进行语法增强
res = res.replace(/__MD_BLOCK_TOKEN_(\d+)__/g, (_, index) => {
let block = codeBlocks[parseInt(index)] || '';
if (block.includes('```mermaid')) {
// 针对序列图的引号修复
if (block.includes('sequenceDiagram')) {
block = block.replace(/^\s*(loop|opt|alt|par|critical|break)\s+([^"\n]+)$/gm, (m, p1, p2) => {
const content = p2.trim();
if (content.startsWith('"')) return m;
return ` ${p1} "${content}"`;
});
block = block.replace(/^\s*(note\s+(?:over|left\s+of|right\s+of)\s+[^:\n]+):\s*([^"\n]+)$/gm, (m, p1, p2) => {
const content = p2.trim();
if (content.startsWith('"')) return m;
return ` ${p1}: "${content}"`;
});
}
// 针对ER图的空格修复
if (block.includes('erDiagram')) {
block = block.replace(/(\w+)\s*(\|\|--o\{|\|\|--\|\||\}o--o\{|\}o--\|\|)\s*(\w+)/g, '$1 $2 $3');
}
}
return block;
});
// 还原 HTML 块占位符(不进行任何处理,保持原样)
res = res.replace(/__HTML_BLOCK_TOKEN_(\d+)__/g, (_, index) => {
return htmlBlocks[parseInt(index)] || '';
});
return res;
}
const renderedContent = computed(() => {
if (!props.content) return "";
// 显式依赖 savedImagePaths 以触发响应式更新
void codexStore.savedImagePaths;
try {
let processed = processFullContent(props.content, props.isStreaming);
processed = processed.replace(/\n{3,}/g, '\n\n');
const rendered = md.render(processed);
return DOMPurify.sanitize(rendered, {
// 允许 blob: 和 data: 协议的 URL
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ADD_TAGS: ["details", "summary", "iframe", "math", "semantics", "mrow", "mi", "mo", "mn", "msup", "msub", "mfrac", "annotation", "figure", "figcaption", "span", "div", "svg", "path", "g", "defs", "symbol", "use", "button", "i", "img"],
ADD_ATTR: ["class", "id", "style", "target", "rel", "src", "alt", "data-*", "d", "viewBox", "fill", "stroke", "stroke-width", "transform", "disabled", "onerror"],
ALLOW_DATA_ATTR: true,
});
} catch (error) { return `<div class="render-error">渲染错误: ${error}</div>`; }
});
</script>
<style scoped lang="scss">
.markdown-preview-container {
position: relative; padding: 12px 16px; background: var(--color-card, #2a2a2a); border-radius: 12px; color: var(--color-text, #fff); line-height: 1.8; font-size: 14px; word-wrap: break-word; overflow-wrap: break-word; transition: background 0.3s, color 0.3s;
&.light { background: var(--color-card, #ffffff); color: var(--color-text, #24292e); }
.streaming-cursor { display: inline-block; width: 2px; height: 1em; background: var(--color-primary, #409eff); margin-left: 2px; animation: blink 1s infinite; }
}
:deep(.markdown-body) {
color: inherit; font-size: 14px; line-height: 1.8;
h1 { font-size: 1.25em; margin-top: 16px; margin-bottom: 8px; font-weight: 600; color: inherit; }
h2 { font-size: 1.15em; margin-top: 14px; margin-bottom: 7px; font-weight: 600; color: inherit; }
h3 { font-size: 1.05em; margin-top: 12px; margin-bottom: 6px; font-weight: 600; color: inherit; }
h4 { font-size: 1em; margin-top: 10px; margin-bottom: 5px; font-weight: 600; color: inherit; }
h5 { font-size: 0.95em; margin-top: 8px; margin-bottom: 4px; font-weight: 600; color: inherit; }
h6 { font-size: 0.9em; margin-top: 8px; margin-bottom: 4px; font-weight: 600; color: inherit; }
img { max-width: 100%; height: auto; border-radius: 8px; display: block; margin: 12px 0; }
blockquote { margin: 16px 0; padding: 12px 16px; border-left: 4px solid var(--color-primary, #409eff); background: rgba(255, 255, 255, 0.05); color: var(--color-text-secondary, #999); border-radius: 4px; .light & { background: #f0f2f5 !important; color: #666 !important; border-left-color: var(--color-primary, #1e70ff); } p { margin: 0; } }
pre {
margin: 16px 0; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; overflow: hidden;
.light & {
background: #f6f8fa !important; border-color: #d1d5da !important;
.code-header { background: rgba(0, 0, 0, 0.05) !important; border-bottom-color: #d1d5da !important; color: #24292e !important; }
.hljs { color: #24292e !important; background: transparent !important;
&-keyword, &-operator, &-selector-tag { color: #d73a49 !important; }
&-title, &-section, &-selector-id { color: #6f42c1 !important; }
&-string, &-attr, &-selector-attr { color: #032f62 !important; }
&-comment { color: #6a737d !important; }
&-number, &-literal { color: #005cc5 !important; }
&-params { color: #24292e !important; }
&-function { color: #6f42c1 !important; }
}
}
.code-header { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: rgba(255, 255, 255, 0.05); border-bottom: 1px solid #333; .code-lang { font-size: 12px; color: #999; text-transform: uppercase; } .copy-code-btn { padding: 2px 8px; font-size: 12px; background: transparent; border: 1px solid #444; border-radius: 4px; color: #999; cursor: pointer; &:hover { background: #333; color: #fff; } &.copied { color: #67c23a; border-color: #67c23a; } } }
code { display: block; padding: 12px 16px; background: transparent !important; color: inherit; font-size: 13px; font-family: "Consolas", monospace; overflow-x: auto; }
}
.mermaid-container { margin: 16px 0; padding: 24px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; display: flex; justify-content: center; overflow-x: auto; border: 1px solid rgba(255, 255, 255, 0.1); .light & { background: #ffffff; border-color: #eee; box-shadow: 0 2px 8px rgba(0,0,0,0.05); } }
code { padding: 2px 6px; background: rgba(255,255,255,0.1); border-radius: 4px; color: var(--color-primary, #409eff); .light & { background: rgba(0,0,0,0.05); } }
.katex { font-size: 1.15em; color: inherit; } .katex-display { overflow-x: auto; padding: 8px 0; }
/* 生成图片保存按钮样式 - 简化版 */
.generated-image-wrapper {
position: relative;
margin: 12px 0;
display: inline-block;
max-width: 100%;
img { margin: 0 !important; }
// 当包含文件卡片时,使用块级布局
&.has-file-card {
display: block;
width: 100%;
}
.image-save-btn {
margin-top: 5px;
padding: 2px 8px;
font-size: 12px;
color: var(--color-primary, #409eff);
background: rgba(64, 158, 255, 0.1);
border: 1px solid var(--color-primary, #409eff);
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
&:hover:not([disabled]) {
background: var(--color-primary, #409eff);
color: #fff;
}
&[disabled] {
opacity: 0.6;
cursor: not-allowed;
background: rgba(128, 128, 128, 0.1);
border-color: #999;
color: #999;
}
&.is-saved {
background: rgba(103, 194, 58, 0.1);
border-color: #67c23a;
color: #67c23a;
}
}
}
/* 卡片下方“已保存”按钮样式(统一为绿色状态) */
:deep(.markdown-body .success) {
background: rgba(103, 194, 58, 0.1) !important;
border-color: #67c23a !important;
color: #67c23a !important;
}
figure { margin: 24px auto; text-align: center; img { max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border: 1px solid var(--color-border, #333); } figcaption { margin-top: 10px; font-size: 13px; color: var(--color-text-secondary, #888); font-style: italic; &::before { content: "图: "; font-weight: 500; } } }
/* 文件生成卡片 */
.file-generated-card {
margin: 0;
padding: 12px 16px;
background: var(--color-card, #2a2a2a);
border: 1px solid var(--color-border, #444);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
display: block;
&:hover {
background: var(--color-card-hover, #333);
border-color: var(--color-primary, #409eff);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.15);
}
&:active {
transform: translateY(0);
}
.file-card-content {
display: flex;
align-items: center;
gap: 12px;
}
.file-card-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
img {
width: 32px;
height: 32px;
object-fit: contain;
}
}
.file-card-info {
flex: 1;
min-width: 0;
.file-card-name {
font-size: 14px;
font-weight: 500;
color: var(--color-text, #fff);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-card-time {
font-size: 12px;
color: var(--color-text-secondary, #999);
}
}
.light & {
background: var(--color-card, #ffffff);
border-color: var(--color-border, #e0e0e0);
&:hover {
background: var(--color-card-hover, #f5f5f5);
border-color: var(--color-primary, #1e70ff);
}
.file-card-icon {
background: rgba(0, 0, 0, 0.05);
}
.file-card-name {
color: var(--color-text, #24292e);
}
.file-card-time {
color: var(--color-text-secondary, #666);
}
}
}
table { border-collapse: collapse; width: 100%; margin: 16px 0; border: 1px solid var(--color-border, #333); th, td { border: 1px solid var(--color-border, #333); padding: 10px 14px; text-align: left; vertical-align: middle; } th { background: rgba(255, 255, 255, 0.08); font-weight: 600; white-space: nowrap; } td { line-height: 1.6; } tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); } .light & { border-color: #dfe2e5; th, td { border-color: #dfe2e5; } th { background: #f6f8fa; } tr:nth-child(even) { background: #fcfcfc; } } }
ul, ol { padding-left: 24px; margin-top: 8px; margin-bottom: 8px; ul, ol { margin-top: 4px; margin-bottom: 4px; padding-left: 20px; } }
li { margin: 4px 0; line-height: 1.6; &::marker { color: var(--color-text-secondary, #888); font-size: 0.9em; } }
.think-block { margin: 16px 0; padding: 12px 16px; background: rgba(64, 158, 255, 0.05); border: 1px solid rgba(64, 158, 255, 0.2); border-radius: 12px; summary { font-weight: 500; color: var(--color-primary, #409eff); cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 13px; } .think-content { margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(64, 158, 255, 0.2); color: #999; font-size: 13px; font-style: italic; } }
}
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
/* 文件保存对话框样式 - 美化版本 */
:deep(.file-save-dialog) {
z-index: 10000 !important;
.el-dialog {
z-index: 10000 !important;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid var(--color-border, #e4e7ed);
}
.el-dialog__header {
padding: 20px 24px;
border-bottom: 1px solid var(--color-border, #e4e7ed);
background: linear-gradient(to bottom, var(--color-bg, #fff), var(--color-bg-secondary, #fafafa));
.el-dialog__title {
font-size: 18px;
font-weight: 600;
color: var(--color-text, #303133);
letter-spacing: -0.3px;
}
.el-dialog__headerbtn {
top: 20px;
right: 24px;
.el-dialog__close {
font-size: 18px;
color: var(--color-text-secondary, #909399);
transition: color 0.2s;
&:hover {
color: var(--color-text, #303133);
}
}
}
}
.el-dialog__body {
padding: 0;
background: var(--color-bg, #fff);
}
.el-overlay {
z-index: 9999 !important;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.save-dialog-content {
display: flex;
flex-direction: column;
height: 420px;
}
/* 文件夹树形容器 */
.folder-tree-container {
flex: 1;
overflow: hidden;
background: var(--color-bg, #fff);
position: relative;
padding: 12px;
.folder-tree {
height: 100%;
overflow-y: auto;
overflow-x: hidden; /* 防止水平滚动 */
padding: 4px 0;
width: 100%;
/* 使用固定布局,减少重排 */
contain: layout;
box-sizing: border-box;
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border, #dcdfe6);
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: var(--color-text-secondary, #909399);
}
}
:deep(.el-tree-node__content) {
height: 40px;
padding: 0 16px;
margin: 3px 0;
border-radius: 8px;
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
will-change: background-color, box-shadow;
min-width: 0; /* 防止 flex 子元素溢出 */
/* 固定宽度,防止展开时宽度变化 */
width: 100%;
box-sizing: border-box;
/* 使用固定布局,防止子节点插入时影响父节点 */
position: relative;
/* 防止内容在展开时移动 */
overflow: visible;
/* 启用硬件加速,固定位置 */
transform: translateZ(0);
/* 固定左边位置,防止向右移动 */
left: 0 !important;
margin-left: 0 !important;
&:hover:not(.is-current) {
background-color: var(--color-bg-hover, #f5f7fa);
}
}
/* 确保树节点内容在展开时不会移动 */
:deep(.el-tree-node.is-expanded > .el-tree-node__content) {
/* 展开时保持位置不变 */
transform: translateZ(0) !important;
left: 0 !important;
margin-left: 0 !important;
}
/* 防止树节点标签在展开时移动 */
:deep(.el-tree-node__label) {
position: relative;
transform: translateZ(0);
left: 0 !important;
margin-left: 0 !important;
padding-left: 0 !important;
}
/* 确保树节点内容在展开时不会移动 */
:deep(.el-tree-node.is-expanded > .el-tree-node__content) {
/* 展开时保持位置不变 */
transform: translateZ(0) !important;
}
/* 确保树节点容器宽度稳定 */
:deep(.el-tree-node) {
min-width: 0;
width: 100%;
box-sizing: border-box;
/* 固定位置,防止展开时移动 */
position: relative;
}
:deep(.el-tree-node__children) {
overflow: hidden; /* 防止子节点展开时影响父节点宽度 */
width: 100%;
/* 防止子节点插入时影响父节点布局 */
position: relative;
margin-left: 0;
padding-left: 0;
}
/* 防止树节点包装器在展开时移动 */
:deep(.el-tree-node__wrapper) {
position: relative;
transform: translateZ(0);
}
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background: linear-gradient(to right, var(--color-primary-light, #ecf5ff), rgba(64, 158, 255, 0.08));
color: var(--color-primary, #409eff);
font-weight: 600;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
&:hover {
background: linear-gradient(to right, var(--color-primary-light, #ecf5ff), rgba(64, 158, 255, 0.12));
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.2);
}
.tree-node-label {
color: var(--color-primary, #409eff);
transition: color 0.15s;
}
.folder-icon {
color: var(--color-primary, #409eff);
}
}
:deep(.el-tree-node__expand-icon) {
color: var(--color-text-secondary, #909399);
font-size: 14px;
transition: color 0.15s;
&:hover {
color: var(--color-primary, #409eff);
}
}
:deep(.el-tree-node__expand-icon.is-leaf) {
color: transparent;
cursor: default;
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
.tree-node {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
.folder-icon {
color: var(--color-primary, #409eff);
font-size: 18px;
transition: color 0.15s;
}
.tree-node-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
color: var(--color-text, #303133);
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-secondary, #909399);
padding: 40px 20px;
.empty-icon {
font-size: 56px;
margin-bottom: 16px;
opacity: 0.4;
color: var(--color-text-secondary, #909399);
animation: float 3s ease-in-out infinite;
}
.empty-text {
font-size: 15px;
margin: 0;
color: var(--color-text-secondary, #909399);
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid var(--color-border, #e4e7ed);
background: linear-gradient(to top, var(--color-bg-secondary, #fafafa), var(--color-bg, #fff));
.el-button {
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
transition: all 0.2s;
&.el-button--primary {
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
&:hover {
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
/* 全局样式:确保对话框在最上层 */
:global(.el-overlay) {
z-index: 9999 !important;
}
:global(.el-dialog) {
z-index: 10000 !important;
}
</style>