vue2-client 1.22.19 → 1.22.21

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.
Files changed (88) hide show
  1. package/.env.his +19 -19
  2. package/.env.message +19 -19
  3. package/.history/.eslintrc_20260521171150.js +74 -0
  4. package/.history/.eslintrc_20260521171213.js +74 -0
  5. package/.history/src/base-client/components/common/HIS/HAddNativeForm/HAddNativeForm_20260601154443.vue +726 -0
  6. package/.history/src/base-client/components/common/HIS/HAddNativeForm/HAddNativeForm_20260601154700.vue +478 -0
  7. package/.history/src/base-client/components/common/HIS/HButtons/HButtons_20260512175435.vue +706 -0
  8. package/.history/src/base-client/components/common/HIS/HButtons/HButtons_20260512175450.vue +694 -0
  9. package/.history/src/base-client/components/common/HIS/HButtons/HButtons_20260611152602.vue +755 -0
  10. package/.history/src/base-client/components/common/HIS/HForm/HForm_20260513145941.vue +524 -0
  11. package/.history/src/base-client/components/common/HIS/HForm/HForm_20260513153133.vue +731 -0
  12. package/.history/src/base-client/components/common/HIS/HForm/HForm_20260513160316.vue +525 -0
  13. package/.history/src/base-client/components/common/HIS/HForm/HForm_20260601144150.vue +1046 -0
  14. package/.history/src/base-client/components/common/HIS/HFormTable/HFormTable_20260310142713.vue +512 -0
  15. package/.history/src/base-client/components/common/HIS/HFormTable/HFormTable_20260310145118.vue +511 -0
  16. package/.history/src/base-client/components/common/HIS/HFormTable/HFormTable_20260311094834.vue +696 -0
  17. package/.history/src/base-client/components/common/HIS/HFormTable/HFormTable_20260320143028.vue +693 -0
  18. package/.history/src/base-client/components/common/HIS/HFormTable/HFormTable_20260409101450.vue +677 -0
  19. package/.history/src/base-client/components/common/HIS/HTab/HTab_20260625173703.vue +767 -0
  20. package/.history/src/base-client/components/common/HIS/HTab/HTab_20260626112425.vue +803 -0
  21. package/.history/src/base-client/components/common/XAddNativeForm/XAddNativeForm_20260513140637.vue +1455 -0
  22. package/.history/src/base-client/components/common/XAddNativeForm/XAddNativeForm_20260513140935.vue +1441 -0
  23. package/.history/src/base-client/components/common/XAddNativeForm/XAddNativeForm_20260513150818.vue +1441 -0
  24. package/.history/src/base-client/components/common/XAddNativeForm/XAddNativeForm_20260513153119.vue +1442 -0
  25. package/.history/src/base-client/components/common/XAddNativeForm/XAddNativeForm_20260513153126.vue +1486 -0
  26. package/.history/src/base-client/components/common/XForm/XFormItem_20260513140854.vue +1607 -0
  27. package/.history/src/base-client/components/common/XMarkdownViewer/XMarkdownViewer_20260519140403.vue +643 -0
  28. package/.history/src/base-client/components/common/XMarkdownViewer/XMarkdownViewer_20260519140829.vue +628 -0
  29. package/.history/src/base-client/components/common/XMarkdownViewer/demo_20260519142824.vue +104 -0
  30. package/.history/src/base-client/components/common/XMarkdownViewer/demo_20260519143155.vue +102 -0
  31. package/.history/src/base-client/components/common/XReportGrid/XReport_20260309171231.vue +1241 -0
  32. package/.history/src/base-client/components/common/XReportGrid/XReport_20260309171441.vue +1223 -0
  33. package/.history/src/base-client/components/his/HAi/HAi_20260612174826.vue +472 -0
  34. package/.history/src/base-client/components/his/HAi/HAi_20260612175839.vue +538 -0
  35. package/.history/src/base-client/components/his/HAi/HAi_20260615103331.vue +650 -0
  36. package/.history/src/base-client/components/his/HAi/HAi_20260618140214.vue +1396 -0
  37. package/.history/src/base-client/components/his/HAi/HAi_20260618140556.vue +1396 -0
  38. package/.history/src/base-client/components/his/XHDescriptions/XHDescriptions_20260424134504.vue +1469 -0
  39. package/.history/src/base-client/components/his/XSidebar/XSidebar_20260610171133.vue +788 -0
  40. package/.history/src/base-client/components/his/XSidebar/XSidebar_20260610171151.vue +780 -0
  41. package/.history/src/base-client/components/his/XTransfer/XTransfer_20260511170841.vue +585 -0
  42. package/.history/src/base-client/components/his/XTransfer/XTransfer_20260511171138.vue +787 -0
  43. package/.history/src/base-client/components/his/XTransfer/XTransfer_20260512141830.vue +739 -0
  44. package/.history/src/components/STable/index_20260409155138.js +806 -0
  45. package/.history/src/components/STable/index_20260409155218.js +814 -0
  46. package/.history/src/expression/core/Expression_20260305164427.js +1371 -0
  47. package/.history/src/expression/core/Expression_20260305170258.js +1358 -0
  48. package/.history/src/expression/core/Program_20260305111830.js +944 -0
  49. package/.history/src/expression/core/Program_20260305112041.js +931 -0
  50. package/.history/src/logic/LogicRunner_20260304154306.js +170 -0
  51. package/.history/src/logic/LogicRunner_20260304155553.js +112 -0
  52. package/.history/src/logic/LogicRunner_20260305105834.js +112 -0
  53. package/.history/src/logic/LogicRunner_20260305112718.js +129 -0
  54. package/.history/src/logic/LogicRunner_20260305182436.js +133 -0
  55. package/.history/src/logic/LogicRunner_20260306151301.js +213 -0
  56. package/.history/src/logic/LogicRunner_20260306152419.js +213 -0
  57. package/.history/src/logic/plugins/common/DateTools_20260305154159.js +61 -0
  58. package/.history/src/logic/plugins/common/DateTools_20260305154217.js +44 -0
  59. package/.history/src/logic/plugins/common/DateTools_20260305161014.js +44 -0
  60. package/.history/src/logic/plugins/common/HttpTools_20260305164352.js +80 -0
  61. package/.history/src/logic/plugins/common/HttpTools_20260305170258.js +75 -0
  62. package/.history/src/logic/plugins/common/HttpTools_20260305171634.js +75 -0
  63. package/.history/src/logic/plugins/common/HttpTools_20260306152419.js +72 -0
  64. package/.history/src/services/api/restTools_20260427142149.js +245 -0
  65. package/.history/src/services/api/restTools_20260427142853.js +230 -0
  66. package/.history/src/services/api/restTools_20260519135558.js +230 -0
  67. package/.history/src/services/api/restTools_20260519140825.js +230 -0
  68. package/.history/src/services/api/restTools_20260519151223.js +230 -0
  69. package/.history/src/utils/indexedDB_20260306150918.js +593 -0
  70. package/.history/src/utils/indexedDB_20260306151301.js +586 -0
  71. package/.idea/af-vue2-client.iml +4 -7
  72. package/.idea/codeStyles/Project.xml +62 -0
  73. package/.idea/codeStyles/codeStyleConfig.xml +5 -0
  74. package/.idea/misc.xml +6 -0
  75. package/CHANGELOG.md +837 -837
  76. package/package.json +117 -117
  77. package/preview-input-box.html +180 -0
  78. package/src/base-client/components/common/HIS/HForm/HForm.vue +1186 -1186
  79. package/src/base-client/components/common/HIS/HTab/HTab.vue +790 -757
  80. package/src/base-client/components/common/ImagePreviewModal/index.js +3 -3
  81. package/src/base-client/components/common/XMarkdownViewer/demo.vue +102 -102
  82. package/src/base-client/components/his/HAi/HAi.vue +159 -30
  83. package/src/base-client/components/his/XTransfer/index.md +327 -327
  84. package/src/components/MarkdownEditor/demo.vue +300 -300
  85. package/src/components/MarkdownEditor/index.vue +590 -590
  86. package/src/router/async/router.map.js +141 -141
  87. package/src/services/api/restTools.js +1 -1
  88. package/.idea/inspectionProfiles/Project_Default.xml +0 -6
@@ -1,590 +1,590 @@
1
- <template>
2
- <div :style="{height: height}" class="markdown-editor-wrapper">
3
- <mavon-editor
4
- ref="md"
5
- v-model="editorContent"
6
- :toolbars="toolbars"
7
- :html="false"
8
- :subfield="subfield"
9
- :defaultOpen="defaultOpen"
10
- :placeholder="placeholder"
11
- :imageFilter="imageFilter"
12
- @fullScreen="fullScreen"
13
- @imgAdd="handleImageUpload"
14
- @imgDel="handleImageDelete"
15
- @change="handleContentChange"
16
- @save="handleSave"
17
- />
18
- </div>
19
- </template>
20
-
21
- <script>
22
- import { mavonEditor } from 'mavon-editor'
23
- import 'mavon-editor/dist/css/index.css'
24
- import { mapState } from 'vuex'
25
- import { upload, fileDeleteV4 } from '@vue2-client/services/api/common'
26
-
27
- export default {
28
- name: 'MarkdownEditor',
29
- components: { mavonEditor },
30
-
31
- props: {
32
- value: { type: String, default: '' },
33
- height: { type: String, default: '600px' },
34
- placeholder: { type: String, default: '开始编写内容...' },
35
- subfield: { type: Boolean, default: true },
36
- defaultOpen: { type: String, default: 'edit' },
37
- serviceName: { type: String, default: undefined },
38
- imgPrefix: { type: String, default: '' },
39
- resUploadMode: { type: String, default: 'server' },
40
- pathKey: { type: String, default: 'Default' },
41
- resUploadStock: { type: Number, default: 1 },
42
- maxImageSize: { type: Number, default: 5 },
43
- formType: { type: String, default: 'image' },
44
- useType: { type: String, default: 'Default' },
45
- autoDetectDelete: { type: Boolean, default: true },
46
- deleteDebounce: { type: Number, default: 1000 }
47
- },
48
-
49
- data() {
50
- return {
51
- editorContent: this.value,
52
- previousContent: this.value,
53
- pendingDeletions: [],
54
- deleteTimer: null,
55
- isFullScreen: false,
56
- previewImage: '',
57
- previewImageName: '',
58
-
59
- // ===== 图片列表 =====
60
- imageList: [],
61
-
62
- xssOptions: {
63
- whiteList: {
64
- img: ['src', 'alt', 'title', 'width', 'height', 'style', 'class'],
65
- a: ['href', 'title', 'target'],
66
- div: ['class', 'style'],
67
- span: ['class', 'style'],
68
- p: ['class', 'style'],
69
- br: [],
70
- strong: [],
71
- b: [],
72
- em: [],
73
- i: [],
74
- u: [],
75
- strike: [],
76
- code: ['class'],
77
- pre: ['class'],
78
- blockquote: ['class'],
79
- ol: ['class'],
80
- ul: ['class'],
81
- li: ['class'],
82
- table: ['class', 'style'],
83
- thead: ['class'],
84
- tbody: ['class'],
85
- tr: ['class'],
86
- th: ['class', 'colspan', 'rowspan'],
87
- td: ['class', 'colspan', 'rowspan'],
88
- h1: ['class', 'style'],
89
- h2: ['class', 'style'],
90
- h3: ['class', 'style'],
91
- h4: ['class', 'style'],
92
- h5: ['class', 'style'],
93
- h6: ['class', 'style']
94
- },
95
- stripIgnoreTag: true,
96
- whiteListCss: {
97
- 'width': true,
98
- 'height': true,
99
- 'max-width': true,
100
- 'max-height': true,
101
- 'margin': true,
102
- 'padding': true,
103
- 'border': true,
104
- 'border-radius': true,
105
- 'text-align': true,
106
- 'color': true,
107
- 'background-color': true,
108
- 'font-size': true,
109
- 'font-weight': true,
110
- 'font-family': true
111
- }
112
- },
113
-
114
- toolbars: {
115
- bold: true,
116
- italic: true,
117
- header: true,
118
- underline: true,
119
- strikethrough: true,
120
- mark: true,
121
- superscript: true,
122
- subscript: true,
123
- quote: true,
124
- ol: true,
125
- ul: true,
126
- link: true,
127
- imagelink: true,
128
- code: true,
129
- table: false,
130
- fullscreen: true,
131
- preview: true,
132
- htmlcode: true,
133
- help: true,
134
- undo: true,
135
- redo: true,
136
- trash: true,
137
- alignleft: true,
138
- aligncenter: true,
139
- alignright: true,
140
- }
141
- }
142
- },
143
-
144
- computed: {
145
- ...mapState('account', { currUser: 'user' }),
146
- ...mapState('setting', ['compatible'])
147
- },
148
-
149
- watch: {
150
- value(newVal) {
151
- if (newVal !== this.editorContent) {
152
- this.editorContent = newVal
153
- this.previousContent = newVal
154
- }
155
- }
156
- },
157
-
158
- methods: {
159
- // ============================================================
160
- // 工具方法
161
- // ============================================================
162
- getUrlParam(url, key) {
163
- if (!url) return null
164
- const queryString = url.split('?')[1]
165
- if (!queryString) return null
166
- const params = new URLSearchParams(queryString)
167
- return params.get(key)
168
- },
169
-
170
- getBaseUrl(url) {
171
- return url ? url.split('?')[0] : ''
172
- },
173
-
174
- // ===== 关键修复:从内容中提取图片 URL =====
175
- extractImageUrls(content) {
176
- if (!content) return []
177
- const urls = []
178
- // 匹配 ![](url) 或 ![alt](url) 格式
179
- const regex = /!\[[^\]]*\]\(([^)]+)\)/g
180
- let match
181
- while ((match = regex.exec(content)) !== null) {
182
- // match[1] 是 URL
183
- const fullUrl = match[1]
184
- // 保留完整 URL(包含 ?id=xxx 参数)
185
- urls.push(fullUrl)
186
- }
187
- console.log('提取到的图片 URL:', urls)
188
- return urls
189
- },
190
-
191
- // ============================================================
192
- // 图片列表管理
193
- // ============================================================
194
-
195
- addImageToList(imageData) {
196
- this.imageList.push({
197
- pos: imageData.pos,
198
- file: imageData.file,
199
- relativePath: imageData.relativePath,
200
- f_downloadpath: imageData.f_downloadpath,
201
- id: imageData.id,
202
- name: imageData.file.name,
203
- uploadTime: Date.now()
204
- })
205
- console.log('图片已添加到列表,当前数量:', this.imageList.length)
206
- this.$emit('image-list-changed', this.imageList)
207
- },
208
-
209
- removeImageFromList(idOrUrl) {
210
- const beforeCount = this.imageList.length
211
-
212
- this.imageList = this.imageList.filter(item => {
213
- // 通过 ID 匹配
214
- if (String(item.id) === String(idOrUrl)) return false
215
- // 通过 URL 中的 ID 匹配
216
- const itemId = this.getUrlParam(item.relativePath, 'id')
217
- if (String(itemId) === String(idOrUrl)) return false
218
- // 通过完整 URL 匹配
219
- if (item.relativePath === idOrUrl) return false
220
- return true
221
- })
222
-
223
- if (this.imageList.length < beforeCount) {
224
- console.log('图片已从列表中移除,剩余:', this.imageList.length)
225
- this.$emit('image-list-changed', this.imageList)
226
- }
227
- },
228
-
229
- getImageList() {
230
- return this.imageList
231
- },
232
-
233
- clearImageList() {
234
- this.imageList = []
235
- this.$emit('image-list-changed', this.imageList)
236
- },
237
-
238
- // ============================================================
239
- // 删除服务器图片
240
- // ============================================================
241
- async deleteServerImage(url) {
242
- try {
243
- const id = this.getUrlParam(url, 'id')
244
- if (id) {
245
- await fileDeleteV4({
246
- id: id,
247
- resDeleteMode: this.resUploadMode
248
- })
249
- console.log('服务器图片删除成功:', id)
250
- this.removeImageFromList(id)
251
- this.$emit('image-deleted', { id, url })
252
- return { success: true, id, url }
253
- } else {
254
- console.warn('无法从 URL 提取 ID:', url)
255
- return { success: false, url, error: 'No ID found' }
256
- }
257
- } catch (error) {
258
- console.error('删除服务器图片失败:', url, error)
259
- this.$emit('image-delete-error', { url, error })
260
- return { success: false, url, error }
261
- }
262
- },
263
-
264
- // ============================================================
265
- // 批量删除图片
266
- // ============================================================
267
- async batchDeleteImages() {
268
- if (this.pendingDeletions.length === 0) return
269
-
270
- const urls = [...this.pendingDeletions]
271
- this.pendingDeletions = []
272
-
273
- console.log('批量删除图片 URLs:', urls)
274
-
275
- // 先从图片列表中移除
276
- urls.forEach(url => {
277
- const id = this.getUrlParam(url, 'id')
278
- if (id) {
279
- this.removeImageFromList(id)
280
- } else {
281
- this.removeImageFromList(url)
282
- }
283
- })
284
-
285
- const results = await Promise.allSettled(
286
- urls.map(url => this.deleteServerImage(url))
287
- )
288
-
289
- const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.success).length
290
- if (successCount > 0) {
291
- this.$message.success(`成功删除 ${successCount} 张图片`)
292
- }
293
-
294
- this.$emit('batch-deleted', { urls, results })
295
- },
296
-
297
- // ============================================================
298
- // 检测被删除的图片(修复版)
299
- // ============================================================
300
- detectDeletedImages(oldContent, newContent) {
301
- if (!this.autoDetectDelete) return
302
- if (oldContent === newContent) return
303
-
304
- const oldImages = this.extractImageUrls(oldContent)
305
- const newImages = this.extractImageUrls(newContent)
306
-
307
- // 找出被删除的图片 URL
308
- const deletedImages = oldImages.filter(url => !newImages.includes(url))
309
-
310
- if (deletedImages.length > 0) {
311
- console.log('检测到被删除的图片(编辑区):', deletedImages)
312
-
313
- // ===== 从图片列表中移除 =====
314
- deletedImages.forEach(url => {
315
- const id = this.getUrlParam(url, 'id')
316
- if (id) {
317
- this.removeImageFromList(id)
318
- } else {
319
- this.removeImageFromList(url)
320
- }
321
- })
322
-
323
- // 添加到待删除队列(去重)
324
- deletedImages.forEach(url => {
325
- if (!this.pendingDeletions.includes(url)) {
326
- this.pendingDeletions.push(url)
327
- }
328
- })
329
-
330
- // 防抖
331
- clearTimeout(this.deleteTimer)
332
- this.deleteTimer = setTimeout(() => {
333
- this.batchDeleteImages()
334
- }, this.deleteDebounce)
335
- }
336
- },
337
-
338
- // ============================================================
339
- // 全屏事件
340
- // ============================================================
341
- fullScreen(status, val) {
342
- this.isFullScreen = status
343
- },
344
-
345
- // ============================================================
346
- // 图片上传
347
- // ============================================================
348
- async handleImageUpload(pos, $file) {
349
- if (!$file) return
350
-
351
- if ($file.size > this.maxImageSize * 1024 * 1024) {
352
- this.$message.warning(`图片大小不能超过 ${this.maxImageSize}MB`)
353
- return
354
- }
355
-
356
- const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
357
- if (!allowedTypes.includes($file.type)) {
358
- this.$message.warning('仅支持 JPEG、PNG、GIF、WebP 格式')
359
- return
360
- }
361
-
362
- try {
363
- const formData = new FormData()
364
- formData.append('avatar', $file)
365
- formData.append('resUploadMode', this.resUploadMode)
366
- formData.append('pathKey', this.pathKey)
367
- formData.append('formType', this.formType)
368
- formData.append('useType', this.useType)
369
- formData.append('resUploadStock', this.resUploadStock)
370
- formData.append('filename', $file.name)
371
- formData.append('filesize', ($file.size / 1024 / 1024).toFixed(4))
372
- formData.append('f_operator', this.currUser ? this.currUser.username : '')
373
-
374
- const headers = { 'Content-Type': 'multipart/form-data' }
375
- const res = await upload(formData, this.serviceName, {
376
- headers,
377
- timeout: 600 * 1000,
378
- dedupe: false
379
- })
380
-
381
- let relativePath = ''
382
-
383
- if (res.success || res.id) {
384
- let dataObj
385
- if (res.id) {
386
- dataObj = res
387
- } else if (typeof res.data === 'string') {
388
- dataObj = JSON.parse(res.data)
389
- } else {
390
- dataObj = res.data
391
- }
392
-
393
- const downloadPath = `${dataObj.f_downloadpath}${dataObj.f_downloadpath.includes('?') ? '&' : '?'}id=${dataObj.id}`
394
-
395
- if (this.imgPrefix) {
396
- relativePath = this.imgPrefix + downloadPath
397
- } else {
398
- relativePath = downloadPath
399
- }
400
-
401
- this.$refs.md.$img2Url(pos, relativePath)
402
-
403
- // 更新 previousContent(避免误删)
404
- this.previousContent = this.editorContent
405
-
406
- this.$message.success('图片上传成功!')
407
-
408
- this.addImageToList({
409
- pos,
410
- file: $file,
411
- relativePath,
412
- f_downloadpath: dataObj.f_downloadpath,
413
- id: dataObj.id
414
- })
415
-
416
- this.$emit('upload-success', {
417
- pos,
418
- file: $file,
419
- relativePath,
420
- response: dataObj
421
- })
422
- } else {
423
- this.$message.error('上传失败!')
424
- this.$emit('upload-error', res.data)
425
- }
426
- } catch (error) {
427
- console.error('图片上传失败:', error)
428
- this.$message.error('图片上传失败,请重试')
429
- this.$emit('upload-error', error)
430
- }
431
- },
432
-
433
- // ============================================================
434
- // 图片删除(预览区点击 X)
435
- // ============================================================
436
- handleImageDelete(pos) {
437
- if (pos && pos.length === 2) {
438
- const url = pos[0]
439
- const file = pos[1]
440
-
441
- console.log('预览区删除图片:', url)
442
-
443
- if (typeof url === 'string' && url.startsWith('/resource')) {
444
- const id = this.getUrlParam(url, 'id')
445
-
446
- // ===== 先从图片列表中移除 =====
447
- if (id) {
448
- this.removeImageFromList(id)
449
- } else {
450
- this.removeImageFromList(url)
451
- }
452
-
453
- if (id) {
454
- fileDeleteV4({
455
- id: id,
456
- resDeleteMode: this.resUploadMode
457
- }).then(res => {
458
- this.removeImageMarkdown(url, file?._name)
459
- this.$message.success('图片删除成功!')
460
- this.$emit('img-del', { url, file, success: true })
461
- }).catch(e => {
462
- console.error('服务器删除失败:', e)
463
- this.$message.error('图片删除失败,请重试')
464
- this.removeImageMarkdown(url, file?._name)
465
- this.$emit('img-del', { url, file, success: false, error: e })
466
- })
467
- } else {
468
- this.removeImageMarkdown(url, file?._name)
469
- }
470
- } else {
471
- this.removeImageMarkdown(url, file?._name)
472
- }
473
- }
474
- },
475
-
476
- // ============================================================
477
- // 从内容中移除图片标记
478
- // ============================================================
479
- removeImageMarkdown(url, fileName) {
480
- let content = this.editorContent
481
-
482
- const exactMatch = `![${fileName}](${url})`
483
- if (content.includes(exactMatch)) {
484
- content = content.replace(exactMatch, '')
485
- } else {
486
- const escapedUrl = url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
487
- const regex = new RegExp(`\\!\\[[^\\]]*\\]\\(${escapedUrl}(\\?[^)]*)?\\)`, 'g')
488
- content = content.replace(regex, '')
489
- }
490
-
491
- content = content.replace(/\n{3,}/g, '\n\n')
492
- this.editorContent = content
493
- this.previousContent = content
494
- this.$emit('input', content)
495
- },
496
-
497
- // ============================================================
498
- // 内容变化 - 核心检测逻辑
499
- // ============================================================
500
- handleContentChange(value, render) {
501
- this.detectDeletedImages(this.previousContent, value)
502
-
503
- this.editorContent = value
504
- this.previousContent = value
505
- this.$emit('input', value)
506
- this.$emit('change', { value, render })
507
- },
508
-
509
- // ============================================================
510
- // 保存
511
- // ============================================================
512
- handleSave(value, render) {
513
- clearTimeout(this.deleteTimer)
514
- this.batchDeleteImages()
515
- this.$emit('save', { value, render })
516
- },
517
-
518
- // ============================================================
519
- // 图片过滤器
520
- // ============================================================
521
- imageFilter(file) {
522
- const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
523
- if (!allowedTypes.includes(file.type)) {
524
- this.$message.warning('仅支持 JPEG、PNG、GIF、WebP 格式')
525
- return false
526
- }
527
- if (file.size > this.maxImageSize * 1024 * 1024) {
528
- this.$message.warning(`图片大小不能超过 ${this.maxImageSize}MB`)
529
- return false
530
- }
531
- return true
532
- },
533
-
534
- // ============================================================
535
- // 对外方法
536
- // ============================================================
537
- getContent() {
538
- return this.editorContent
539
- },
540
-
541
- setContent(content) {
542
- this.editorContent = content
543
- this.previousContent = content
544
- this.$emit('input', content)
545
- this.$nextTick(() => {
546
- const urls = this.extractImageUrls(content)
547
- const currentIds = urls.map(url => this.getUrlParam(url, 'id')).filter(Boolean)
548
- this.imageList = this.imageList.filter(item => {
549
- return currentIds.includes(String(item.id))
550
- })
551
- this.$emit('image-list-changed', this.imageList)
552
- })
553
- },
554
-
555
- clear() {
556
- this.editorContent = ''
557
- this.previousContent = ''
558
- this.imageList = []
559
- this.$emit('input', '')
560
- this.$emit('image-list-changed', [])
561
- },
562
-
563
- getHtml() {
564
- return this.$refs.md?.$refs?.content?.html || ''
565
- },
566
-
567
- insertImage(url, title = '', alt = '') {
568
- this.$refs.md.$img2Url(this.editorContent.length, url, title, alt)
569
- }
570
- },
571
-
572
- beforeDestroy() {
573
- clearTimeout(this.deleteTimer)
574
- if (this.pendingDeletions.length > 0) {
575
- this.batchDeleteImages()
576
- }
577
- }
578
- }
579
- </script>
580
-
581
- <style scoped>
582
- .markdown-editor-wrapper ::v-deep .v-note-wrapper {
583
- height: 100%;
584
- z-index: auto;
585
- }
586
-
587
- .markdown-editor-wrapper ::v-deep .fullscreen {
588
- z-index: 1501 !important;
589
- }
590
- </style>
1
+ <template>
2
+ <div :style="{height: height}" class="markdown-editor-wrapper">
3
+ <mavon-editor
4
+ ref="md"
5
+ v-model="editorContent"
6
+ :toolbars="toolbars"
7
+ :html="false"
8
+ :subfield="subfield"
9
+ :defaultOpen="defaultOpen"
10
+ :placeholder="placeholder"
11
+ :imageFilter="imageFilter"
12
+ @fullScreen="fullScreen"
13
+ @imgAdd="handleImageUpload"
14
+ @imgDel="handleImageDelete"
15
+ @change="handleContentChange"
16
+ @save="handleSave"
17
+ />
18
+ </div>
19
+ </template>
20
+
21
+ <script>
22
+ import { mavonEditor } from 'mavon-editor'
23
+ import 'mavon-editor/dist/css/index.css'
24
+ import { mapState } from 'vuex'
25
+ import { upload, fileDeleteV4 } from '@vue2-client/services/api/common'
26
+
27
+ export default {
28
+ name: 'MarkdownEditor',
29
+ components: { mavonEditor },
30
+
31
+ props: {
32
+ value: { type: String, default: '' },
33
+ height: { type: String, default: '600px' },
34
+ placeholder: { type: String, default: '开始编写内容...' },
35
+ subfield: { type: Boolean, default: true },
36
+ defaultOpen: { type: String, default: 'edit' },
37
+ serviceName: { type: String, default: undefined },
38
+ imgPrefix: { type: String, default: '' },
39
+ resUploadMode: { type: String, default: 'server' },
40
+ pathKey: { type: String, default: 'Default' },
41
+ resUploadStock: { type: Number, default: 1 },
42
+ maxImageSize: { type: Number, default: 5 },
43
+ formType: { type: String, default: 'image' },
44
+ useType: { type: String, default: 'Default' },
45
+ autoDetectDelete: { type: Boolean, default: true },
46
+ deleteDebounce: { type: Number, default: 1000 }
47
+ },
48
+
49
+ data() {
50
+ return {
51
+ editorContent: this.value,
52
+ previousContent: this.value,
53
+ pendingDeletions: [],
54
+ deleteTimer: null,
55
+ isFullScreen: false,
56
+ previewImage: '',
57
+ previewImageName: '',
58
+
59
+ // ===== 图片列表 =====
60
+ imageList: [],
61
+
62
+ xssOptions: {
63
+ whiteList: {
64
+ img: ['src', 'alt', 'title', 'width', 'height', 'style', 'class'],
65
+ a: ['href', 'title', 'target'],
66
+ div: ['class', 'style'],
67
+ span: ['class', 'style'],
68
+ p: ['class', 'style'],
69
+ br: [],
70
+ strong: [],
71
+ b: [],
72
+ em: [],
73
+ i: [],
74
+ u: [],
75
+ strike: [],
76
+ code: ['class'],
77
+ pre: ['class'],
78
+ blockquote: ['class'],
79
+ ol: ['class'],
80
+ ul: ['class'],
81
+ li: ['class'],
82
+ table: ['class', 'style'],
83
+ thead: ['class'],
84
+ tbody: ['class'],
85
+ tr: ['class'],
86
+ th: ['class', 'colspan', 'rowspan'],
87
+ td: ['class', 'colspan', 'rowspan'],
88
+ h1: ['class', 'style'],
89
+ h2: ['class', 'style'],
90
+ h3: ['class', 'style'],
91
+ h4: ['class', 'style'],
92
+ h5: ['class', 'style'],
93
+ h6: ['class', 'style']
94
+ },
95
+ stripIgnoreTag: true,
96
+ whiteListCss: {
97
+ 'width': true,
98
+ 'height': true,
99
+ 'max-width': true,
100
+ 'max-height': true,
101
+ 'margin': true,
102
+ 'padding': true,
103
+ 'border': true,
104
+ 'border-radius': true,
105
+ 'text-align': true,
106
+ 'color': true,
107
+ 'background-color': true,
108
+ 'font-size': true,
109
+ 'font-weight': true,
110
+ 'font-family': true
111
+ }
112
+ },
113
+
114
+ toolbars: {
115
+ bold: true,
116
+ italic: true,
117
+ header: true,
118
+ underline: true,
119
+ strikethrough: true,
120
+ mark: true,
121
+ superscript: true,
122
+ subscript: true,
123
+ quote: true,
124
+ ol: true,
125
+ ul: true,
126
+ link: true,
127
+ imagelink: true,
128
+ code: true,
129
+ table: false,
130
+ fullscreen: true,
131
+ preview: true,
132
+ htmlcode: true,
133
+ help: true,
134
+ undo: true,
135
+ redo: true,
136
+ trash: true,
137
+ alignleft: true,
138
+ aligncenter: true,
139
+ alignright: true,
140
+ }
141
+ }
142
+ },
143
+
144
+ computed: {
145
+ ...mapState('account', { currUser: 'user' }),
146
+ ...mapState('setting', ['compatible'])
147
+ },
148
+
149
+ watch: {
150
+ value(newVal) {
151
+ if (newVal !== this.editorContent) {
152
+ this.editorContent = newVal
153
+ this.previousContent = newVal
154
+ }
155
+ }
156
+ },
157
+
158
+ methods: {
159
+ // ============================================================
160
+ // 工具方法
161
+ // ============================================================
162
+ getUrlParam(url, key) {
163
+ if (!url) return null
164
+ const queryString = url.split('?')[1]
165
+ if (!queryString) return null
166
+ const params = new URLSearchParams(queryString)
167
+ return params.get(key)
168
+ },
169
+
170
+ getBaseUrl(url) {
171
+ return url ? url.split('?')[0] : ''
172
+ },
173
+
174
+ // ===== 关键修复:从内容中提取图片 URL =====
175
+ extractImageUrls(content) {
176
+ if (!content) return []
177
+ const urls = []
178
+ // 匹配 ![](url) 或 ![alt](url) 格式
179
+ const regex = /!\[[^\]]*\]\(([^)]+)\)/g
180
+ let match
181
+ while ((match = regex.exec(content)) !== null) {
182
+ // match[1] 是 URL
183
+ const fullUrl = match[1]
184
+ // 保留完整 URL(包含 ?id=xxx 参数)
185
+ urls.push(fullUrl)
186
+ }
187
+ console.log('提取到的图片 URL:', urls)
188
+ return urls
189
+ },
190
+
191
+ // ============================================================
192
+ // 图片列表管理
193
+ // ============================================================
194
+
195
+ addImageToList(imageData) {
196
+ this.imageList.push({
197
+ pos: imageData.pos,
198
+ file: imageData.file,
199
+ relativePath: imageData.relativePath,
200
+ f_downloadpath: imageData.f_downloadpath,
201
+ id: imageData.id,
202
+ name: imageData.file.name,
203
+ uploadTime: Date.now()
204
+ })
205
+ console.log('图片已添加到列表,当前数量:', this.imageList.length)
206
+ this.$emit('image-list-changed', this.imageList)
207
+ },
208
+
209
+ removeImageFromList(idOrUrl) {
210
+ const beforeCount = this.imageList.length
211
+
212
+ this.imageList = this.imageList.filter(item => {
213
+ // 通过 ID 匹配
214
+ if (String(item.id) === String(idOrUrl)) return false
215
+ // 通过 URL 中的 ID 匹配
216
+ const itemId = this.getUrlParam(item.relativePath, 'id')
217
+ if (String(itemId) === String(idOrUrl)) return false
218
+ // 通过完整 URL 匹配
219
+ if (item.relativePath === idOrUrl) return false
220
+ return true
221
+ })
222
+
223
+ if (this.imageList.length < beforeCount) {
224
+ console.log('图片已从列表中移除,剩余:', this.imageList.length)
225
+ this.$emit('image-list-changed', this.imageList)
226
+ }
227
+ },
228
+
229
+ getImageList() {
230
+ return this.imageList
231
+ },
232
+
233
+ clearImageList() {
234
+ this.imageList = []
235
+ this.$emit('image-list-changed', this.imageList)
236
+ },
237
+
238
+ // ============================================================
239
+ // 删除服务器图片
240
+ // ============================================================
241
+ async deleteServerImage(url) {
242
+ try {
243
+ const id = this.getUrlParam(url, 'id')
244
+ if (id) {
245
+ await fileDeleteV4({
246
+ id: id,
247
+ resDeleteMode: this.resUploadMode
248
+ })
249
+ console.log('服务器图片删除成功:', id)
250
+ this.removeImageFromList(id)
251
+ this.$emit('image-deleted', { id, url })
252
+ return { success: true, id, url }
253
+ } else {
254
+ console.warn('无法从 URL 提取 ID:', url)
255
+ return { success: false, url, error: 'No ID found' }
256
+ }
257
+ } catch (error) {
258
+ console.error('删除服务器图片失败:', url, error)
259
+ this.$emit('image-delete-error', { url, error })
260
+ return { success: false, url, error }
261
+ }
262
+ },
263
+
264
+ // ============================================================
265
+ // 批量删除图片
266
+ // ============================================================
267
+ async batchDeleteImages() {
268
+ if (this.pendingDeletions.length === 0) return
269
+
270
+ const urls = [...this.pendingDeletions]
271
+ this.pendingDeletions = []
272
+
273
+ console.log('批量删除图片 URLs:', urls)
274
+
275
+ // 先从图片列表中移除
276
+ urls.forEach(url => {
277
+ const id = this.getUrlParam(url, 'id')
278
+ if (id) {
279
+ this.removeImageFromList(id)
280
+ } else {
281
+ this.removeImageFromList(url)
282
+ }
283
+ })
284
+
285
+ const results = await Promise.allSettled(
286
+ urls.map(url => this.deleteServerImage(url))
287
+ )
288
+
289
+ const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.success).length
290
+ if (successCount > 0) {
291
+ this.$message.success(`成功删除 ${successCount} 张图片`)
292
+ }
293
+
294
+ this.$emit('batch-deleted', { urls, results })
295
+ },
296
+
297
+ // ============================================================
298
+ // 检测被删除的图片(修复版)
299
+ // ============================================================
300
+ detectDeletedImages(oldContent, newContent) {
301
+ if (!this.autoDetectDelete) return
302
+ if (oldContent === newContent) return
303
+
304
+ const oldImages = this.extractImageUrls(oldContent)
305
+ const newImages = this.extractImageUrls(newContent)
306
+
307
+ // 找出被删除的图片 URL
308
+ const deletedImages = oldImages.filter(url => !newImages.includes(url))
309
+
310
+ if (deletedImages.length > 0) {
311
+ console.log('检测到被删除的图片(编辑区):', deletedImages)
312
+
313
+ // ===== 从图片列表中移除 =====
314
+ deletedImages.forEach(url => {
315
+ const id = this.getUrlParam(url, 'id')
316
+ if (id) {
317
+ this.removeImageFromList(id)
318
+ } else {
319
+ this.removeImageFromList(url)
320
+ }
321
+ })
322
+
323
+ // 添加到待删除队列(去重)
324
+ deletedImages.forEach(url => {
325
+ if (!this.pendingDeletions.includes(url)) {
326
+ this.pendingDeletions.push(url)
327
+ }
328
+ })
329
+
330
+ // 防抖
331
+ clearTimeout(this.deleteTimer)
332
+ this.deleteTimer = setTimeout(() => {
333
+ this.batchDeleteImages()
334
+ }, this.deleteDebounce)
335
+ }
336
+ },
337
+
338
+ // ============================================================
339
+ // 全屏事件
340
+ // ============================================================
341
+ fullScreen(status, val) {
342
+ this.isFullScreen = status
343
+ },
344
+
345
+ // ============================================================
346
+ // 图片上传
347
+ // ============================================================
348
+ async handleImageUpload(pos, $file) {
349
+ if (!$file) return
350
+
351
+ if ($file.size > this.maxImageSize * 1024 * 1024) {
352
+ this.$message.warning(`图片大小不能超过 ${this.maxImageSize}MB`)
353
+ return
354
+ }
355
+
356
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
357
+ if (!allowedTypes.includes($file.type)) {
358
+ this.$message.warning('仅支持 JPEG、PNG、GIF、WebP 格式')
359
+ return
360
+ }
361
+
362
+ try {
363
+ const formData = new FormData()
364
+ formData.append('avatar', $file)
365
+ formData.append('resUploadMode', this.resUploadMode)
366
+ formData.append('pathKey', this.pathKey)
367
+ formData.append('formType', this.formType)
368
+ formData.append('useType', this.useType)
369
+ formData.append('resUploadStock', this.resUploadStock)
370
+ formData.append('filename', $file.name)
371
+ formData.append('filesize', ($file.size / 1024 / 1024).toFixed(4))
372
+ formData.append('f_operator', this.currUser ? this.currUser.username : '')
373
+
374
+ const headers = { 'Content-Type': 'multipart/form-data' }
375
+ const res = await upload(formData, this.serviceName, {
376
+ headers,
377
+ timeout: 600 * 1000,
378
+ dedupe: false
379
+ })
380
+
381
+ let relativePath = ''
382
+
383
+ if (res.success || res.id) {
384
+ let dataObj
385
+ if (res.id) {
386
+ dataObj = res
387
+ } else if (typeof res.data === 'string') {
388
+ dataObj = JSON.parse(res.data)
389
+ } else {
390
+ dataObj = res.data
391
+ }
392
+
393
+ const downloadPath = `${dataObj.f_downloadpath}${dataObj.f_downloadpath.includes('?') ? '&' : '?'}id=${dataObj.id}`
394
+
395
+ if (this.imgPrefix) {
396
+ relativePath = this.imgPrefix + downloadPath
397
+ } else {
398
+ relativePath = downloadPath
399
+ }
400
+
401
+ this.$refs.md.$img2Url(pos, relativePath)
402
+
403
+ // 更新 previousContent(避免误删)
404
+ this.previousContent = this.editorContent
405
+
406
+ this.$message.success('图片上传成功!')
407
+
408
+ this.addImageToList({
409
+ pos,
410
+ file: $file,
411
+ relativePath,
412
+ f_downloadpath: dataObj.f_downloadpath,
413
+ id: dataObj.id
414
+ })
415
+
416
+ this.$emit('upload-success', {
417
+ pos,
418
+ file: $file,
419
+ relativePath,
420
+ response: dataObj
421
+ })
422
+ } else {
423
+ this.$message.error('上传失败!')
424
+ this.$emit('upload-error', res.data)
425
+ }
426
+ } catch (error) {
427
+ console.error('图片上传失败:', error)
428
+ this.$message.error('图片上传失败,请重试')
429
+ this.$emit('upload-error', error)
430
+ }
431
+ },
432
+
433
+ // ============================================================
434
+ // 图片删除(预览区点击 X)
435
+ // ============================================================
436
+ handleImageDelete(pos) {
437
+ if (pos && pos.length === 2) {
438
+ const url = pos[0]
439
+ const file = pos[1]
440
+
441
+ console.log('预览区删除图片:', url)
442
+
443
+ if (typeof url === 'string' && url.startsWith('/resource')) {
444
+ const id = this.getUrlParam(url, 'id')
445
+
446
+ // ===== 先从图片列表中移除 =====
447
+ if (id) {
448
+ this.removeImageFromList(id)
449
+ } else {
450
+ this.removeImageFromList(url)
451
+ }
452
+
453
+ if (id) {
454
+ fileDeleteV4({
455
+ id: id,
456
+ resDeleteMode: this.resUploadMode
457
+ }).then(res => {
458
+ this.removeImageMarkdown(url, file?._name)
459
+ this.$message.success('图片删除成功!')
460
+ this.$emit('img-del', { url, file, success: true })
461
+ }).catch(e => {
462
+ console.error('服务器删除失败:', e)
463
+ this.$message.error('图片删除失败,请重试')
464
+ this.removeImageMarkdown(url, file?._name)
465
+ this.$emit('img-del', { url, file, success: false, error: e })
466
+ })
467
+ } else {
468
+ this.removeImageMarkdown(url, file?._name)
469
+ }
470
+ } else {
471
+ this.removeImageMarkdown(url, file?._name)
472
+ }
473
+ }
474
+ },
475
+
476
+ // ============================================================
477
+ // 从内容中移除图片标记
478
+ // ============================================================
479
+ removeImageMarkdown(url, fileName) {
480
+ let content = this.editorContent
481
+
482
+ const exactMatch = `![${fileName}](${url})`
483
+ if (content.includes(exactMatch)) {
484
+ content = content.replace(exactMatch, '')
485
+ } else {
486
+ const escapedUrl = url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
487
+ const regex = new RegExp(`\\!\\[[^\\]]*\\]\\(${escapedUrl}(\\?[^)]*)?\\)`, 'g')
488
+ content = content.replace(regex, '')
489
+ }
490
+
491
+ content = content.replace(/\n{3,}/g, '\n\n')
492
+ this.editorContent = content
493
+ this.previousContent = content
494
+ this.$emit('input', content)
495
+ },
496
+
497
+ // ============================================================
498
+ // 内容变化 - 核心检测逻辑
499
+ // ============================================================
500
+ handleContentChange(value, render) {
501
+ this.detectDeletedImages(this.previousContent, value)
502
+
503
+ this.editorContent = value
504
+ this.previousContent = value
505
+ this.$emit('input', value)
506
+ this.$emit('change', { value, render })
507
+ },
508
+
509
+ // ============================================================
510
+ // 保存
511
+ // ============================================================
512
+ handleSave(value, render) {
513
+ clearTimeout(this.deleteTimer)
514
+ this.batchDeleteImages()
515
+ this.$emit('save', { value, render })
516
+ },
517
+
518
+ // ============================================================
519
+ // 图片过滤器
520
+ // ============================================================
521
+ imageFilter(file) {
522
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
523
+ if (!allowedTypes.includes(file.type)) {
524
+ this.$message.warning('仅支持 JPEG、PNG、GIF、WebP 格式')
525
+ return false
526
+ }
527
+ if (file.size > this.maxImageSize * 1024 * 1024) {
528
+ this.$message.warning(`图片大小不能超过 ${this.maxImageSize}MB`)
529
+ return false
530
+ }
531
+ return true
532
+ },
533
+
534
+ // ============================================================
535
+ // 对外方法
536
+ // ============================================================
537
+ getContent() {
538
+ return this.editorContent
539
+ },
540
+
541
+ setContent(content) {
542
+ this.editorContent = content
543
+ this.previousContent = content
544
+ this.$emit('input', content)
545
+ this.$nextTick(() => {
546
+ const urls = this.extractImageUrls(content)
547
+ const currentIds = urls.map(url => this.getUrlParam(url, 'id')).filter(Boolean)
548
+ this.imageList = this.imageList.filter(item => {
549
+ return currentIds.includes(String(item.id))
550
+ })
551
+ this.$emit('image-list-changed', this.imageList)
552
+ })
553
+ },
554
+
555
+ clear() {
556
+ this.editorContent = ''
557
+ this.previousContent = ''
558
+ this.imageList = []
559
+ this.$emit('input', '')
560
+ this.$emit('image-list-changed', [])
561
+ },
562
+
563
+ getHtml() {
564
+ return this.$refs.md?.$refs?.content?.html || ''
565
+ },
566
+
567
+ insertImage(url, title = '', alt = '') {
568
+ this.$refs.md.$img2Url(this.editorContent.length, url, title, alt)
569
+ }
570
+ },
571
+
572
+ beforeDestroy() {
573
+ clearTimeout(this.deleteTimer)
574
+ if (this.pendingDeletions.length > 0) {
575
+ this.batchDeleteImages()
576
+ }
577
+ }
578
+ }
579
+ </script>
580
+
581
+ <style scoped>
582
+ .markdown-editor-wrapper ::v-deep .v-note-wrapper {
583
+ height: 100%;
584
+ z-index: auto;
585
+ }
586
+
587
+ .markdown-editor-wrapper ::v-deep .fullscreen {
588
+ z-index: 1501 !important;
589
+ }
590
+ </style>