jvs-ppt-ui 0.0.12 → 0.0.14

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jvs-ppt-ui",
3
3
  "private": false,
4
- "version": "0.0.12",
4
+ "version": "0.0.14",
5
5
  "type": "module",
6
6
  "main": "./dist/lib/jvs-ppt-ui.umd.js",
7
7
  "module": "./dist/lib/jvs-ppt-ui.es.js",
package/src/App.vue CHANGED
@@ -46,8 +46,8 @@ async function handleCustomUpload(fileOrBlob: File | Blob, context: UploadResour
46
46
  <template>
47
47
  <!-- :custom-upload="handleCustomUpload" -->
48
48
  <PptEditor ref="editorRef" :custom-upload="handleCustomUpload" :enable-mcp="true" :enable-ai="true"
49
- ai-api-url="/jvs-dam/api/ppt/ai-replace" :enable-material="true" :readonly="true" :show-sidebar-right="false"
50
- material-tree-api-url="/jvs-dam/api/dam/material/tree" material-page-api-url="/jvs-dam/api/dam/material/page"
51
- material-api-url="/jvs-dam/api/dam/material/tree" material-save-api-url="/jvs-dam/api/dam/material/save"
49
+ ai-api-url="/jvs-dam/api/ppt/ai-replace" :enable-material="true"
50
+ material-api-url="/jvs-dam/stock/search" material-page-api-url="/jvs-dam/stock/search"
51
+ material-save-api-url="/jvs-dam/api/dam/material/save"
52
52
  @save="handleSave" @ready="handleReady" />
53
53
  </template>
@@ -18,6 +18,7 @@ import CanvasGroupElement from './canvas/elements/CanvasGroupElement.vue'
18
18
  import CanvasSymbolPanel from './canvas/panels/CanvasSymbolPanel.vue'
19
19
  import CanvasSearchPanel from './canvas/panels/CanvasSearchPanel.vue'
20
20
  import CanvasSelectionPanel from './canvas/panels/CanvasSelectionPanel.vue'
21
+ import { isTextContainingElement } from '../utils/groupFlip'
21
22
 
22
23
  import { useCanvasTable, cleanTableDOMForSaving } from '../composables/canvas/useCanvasTable'
23
24
  import { useCanvasAlignment } from '../composables/canvas/useCanvasAlignment'
@@ -4405,7 +4406,7 @@ function getOverlayStyle(el: any) {
4405
4406
  width: `${w}px`,
4406
4407
  height: `${h}px`,
4407
4408
  zIndex: isSelectedActive ? 300 : 100,
4408
- transform: `rotate(${((parentPixels.rotate || 0) + (el.style?.rotate || 0))}deg) scale(${el.type !== 'text' && el.type !== 'line' && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${el.type !== 'text' && el.type !== 'line' && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
4409
+ transform: `rotate(${((parentPixels.rotate || 0) + (el.style?.rotate || 0))}deg) scale(${!isTextContainingElement(el) && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${!isTextContainingElement(el) && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
4409
4410
  pointerEvents: 'none' as const,
4410
4411
  boxSizing: 'border-box' as const,
4411
4412
  '--handle-scale': +(100 / (zoomRatio.value || 100)).toFixed(4)
@@ -4419,7 +4420,7 @@ function getOverlayStyle(el: any) {
4419
4420
  width: getOverlayWidth(el),
4420
4421
  height: getOverlayHeight(el),
4421
4422
  zIndex: isSelectedActive ? 300 : 100,
4422
- transform: el.type === 'line' ? 'none' : `rotate(${el.style.rotate || 0}deg) scale(${el.type !== 'text' && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${el.type !== 'text' && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
4423
+ transform: el.type === 'line' ? 'none' : `rotate(${el.style.rotate || 0}deg) scale(${!isTextContainingElement(el) && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${!isTextContainingElement(el) && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
4423
4424
  pointerEvents: 'none' as const,
4424
4425
  boxSizing: 'border-box' as const,
4425
4426
  '--handle-scale': +(100 / (zoomRatio.value || 100)).toFixed(4)
@@ -7857,7 +7858,7 @@ async function handleGlobalPaste(e: ClipboardEvent) {
7857
7858
  width: (replica.el.type === 'text' && !isMasterOrPlaceholder(replica.el) && (replica.el.autoFit?.type === 'shape' || replica.el.style?.width === 'auto') && !replica.el.content?.includes('<table') && !replica.el.latex && (replica.el.style?.writingMode === 'vertical-rl' || replica.el.style?.writingMode === 'vertical-lr')) ? 'auto' : (replica.el.style?.width || (replica.el.width ? `${replica.el.width}px` : 'auto')),
7858
7859
  height: (replica.el.type === 'text' && !isMasterOrPlaceholder(replica.el) && (replica.el.autoFit?.type === 'shape' || replica.el.style?.height === 'auto') && !replica.el.content?.includes('<table') && !replica.el.latex && replica.el.style?.writingMode !== 'vertical-rl' && replica.el.style?.writingMode !== 'vertical-lr') ? 'auto' : (replica.el.style?.height || (replica.el.height ? `${replica.el.height}px` : 'auto')),
7859
7860
  zIndex: 2,
7860
- transform: replica.el.type === 'line' ? 'none' : `rotate(${replica.el.style?.rotate || replica.el.rotate || 0}deg) scale(${replica.el.type !== 'text' && ((replica.el.style as any)?.flipH || replica.el.flipH || replica.el.isFlipH) ? -1 : 1}, ${replica.el.type !== 'text' && ((replica.el.style as any)?.flipV || replica.el.flipV || replica.el.isFlipV) ? -1 : 1})`,
7861
+ transform: replica.el.type === 'line' ? 'none' : `rotate(${replica.el.style?.rotate || replica.el.rotate || 0}deg) scale(${!isTextContainingElement(replica.el) && ((replica.el.style as any)?.flipH || replica.el.flipH || replica.el.isFlipH) ? -1 : 1}, ${!isTextContainingElement(replica.el) && ((replica.el.style as any)?.flipV || replica.el.flipV || replica.el.isFlipV) ? -1 : 1})`,
7861
7862
  opacity: (replica.el.style as any)?.opacity !== undefined ? (replica.el.style as any)?.opacity : 1,
7862
7863
  filter: getElementDropShadowFilter(replica.el),
7863
7864
  pointerEvents: 'none'
@@ -7947,7 +7948,7 @@ async function handleGlobalPaste(e: ClipboardEvent) {
7947
7948
  width: el.style?.width || (el.width !== undefined ? `${el.width}px` : 'auto'),
7948
7949
  height: el.style?.height || (el.height !== undefined ? `${el.height}px` : 'auto'),
7949
7950
  zIndex: 1,
7950
- transform: `rotate(${el.style?.rotate || el.rotate || 0}deg) scale(${el.type !== 'text' && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${el.type !== 'text' && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
7951
+ transform: `rotate(${el.style?.rotate || el.rotate || 0}deg) scale(${!isTextContainingElement(el) && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${!isTextContainingElement(el) && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
7951
7952
  opacity: el.style?.opacity !== undefined ? el.style.opacity : 1,
7952
7953
  pointerEvents: 'none'
7953
7954
  }">
@@ -8047,7 +8048,7 @@ async function handleGlobalPaste(e: ClipboardEvent) {
8047
8048
  height: (el.type === 'text' && !isMasterOrPlaceholder(el) && (el.autoFit?.type === 'shape' || el.style?.height === 'auto') && !el.content?.includes('<table') && !el.latex && el.style?.writingMode !== 'vertical-rl' && el.style?.writingMode !== 'vertical-lr') ? 'auto' : (el.style?.height || (el.height ? `${el.height}px` : 'auto')),
8048
8049
  zIndex: 3,
8049
8050
  cursor: isReadonly ? 'default' : ((isEditingText && (selectedElementId === el.id || editingElementId === el.id) && (el.type === 'text' || el.type === 'shape') && !el.latex && !el.content?.includes('<table')) ? 'text' : undefined),
8050
- transform: el.type === 'line' ? 'none' : `rotate(${el.style?.rotate || 0}deg) scale(${el.type !== 'text' && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${el.type !== 'text' && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
8051
+ transform: el.type === 'line' ? 'none' : `rotate(${el.style?.rotate || 0}deg) scale(${!isTextContainingElement(el) && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${!isTextContainingElement(el) && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
8051
8052
  opacity: (el.style as any)?.opacity !== undefined ? (el.style as any)?.opacity : 1,
8052
8053
  filter: getElementDropShadowFilter(el),
8053
8054
  pointerEvents: (isDragging || isCanvasDragging) ? 'none' : ((isDraggingLine && el.id === selectedElementId) ? 'none' : (isLockedMasterElement(el) ? 'none' : (el.type === 'group' ? 'none' : (el.type === 'line' ? 'none' : (((el.type === 'shape' || !el.type) && !hasElementFill(el) && !hasElementBorder(el) && !isImagePlaceholder(el)) ? 'none' : 'auto')))))
@@ -13,6 +13,7 @@ import { parseColorString, extractTextAlignFromHtml, updateTextAlignInHtml, norm
13
13
 
14
14
  import { shapeFormulas, shapeList as getPptxShapeList, ensurePptxShapes } from '../utils/pptxShapesLazy'
15
15
  import { inferElementTextType, type SlideTextType } from '../utils/textTypes'
16
+ import { applyGroupGeometryFlip } from '../utils/groupFlip'
16
17
  // 快捷键弹层异步化;mock DSL 模板(~77KB)改为触发时动态加载
17
18
  const ShortcutsDialog = defineAsyncComponent(() => import('./ShortcutsDialog.vue'))
18
19
 
@@ -5829,6 +5830,10 @@ const parsePPTXJSONData = (json: any, zipMeta?: any) => {
5829
5830
  elements: sortedTopGroupChildren.map((child: any) => convertChildElement(child, topW, topH, childIdMap))
5830
5831
  }
5831
5832
 
5833
+ if (grpFlipH || grpFlipV) {
5834
+ applyGroupGeometryFlip(groupElement, grpFlipH, grpFlipV)
5835
+ }
5836
+
5832
5837
  slide.elements.push(groupElement)
5833
5838
  }
5834
5839
  else if (el.type === 'diagram') {
@@ -21,6 +21,7 @@ import {
21
21
  import {
22
22
  getLineElementPath, getLineElementRenderPath, getLineDashArray, getSvgWidth, getSvgHeight, isEllipseShape
23
23
  } from '../utils/line'
24
+ import { isTextContainingElement } from '../utils/groupFlip'
24
25
 
25
26
  const {
26
27
  isPresenting, activeIndex, slides, stopPresentation, renderLatexToSvg, notify, execNext, execPrev,
@@ -1200,7 +1201,7 @@ function getSafeShapeText(el: any): string {
1200
1201
  width: isAutoSizeWidthText(el) ? 'auto' : (el.style?.width || 'auto'),
1201
1202
  height: isAutoSizeHeightText(el) ? 'auto' : (el.style?.height || 'auto'),
1202
1203
  zIndex: 3,
1203
- transform: `rotate(${el.style.rotate || 0}deg) scale(${el.type !== 'text' && el.type !== 'line' && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${el.type !== 'text' && el.type !== 'line' && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
1204
+ transform: `rotate(${el.style.rotate || 0}deg) scale(${!isTextContainingElement(el) && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${!isTextContainingElement(el) && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
1204
1205
  opacity: (el.style as any).opacity !== undefined ? (el.style as any).opacity : 1,
1205
1206
  filter: (el.type !== 'image' && el.type !== 'chart' && (el.style as any).boxShadow && (el.style as any).boxShadow !== 'none') ? `drop-shadow(${(el.style as any).boxShadow})` : '',
1206
1207
  cursor: (el.link || hasTriggerAnimations(el.id)) ? 'pointer' : 'default',
@@ -28,6 +28,7 @@ import PresentationGroupChild from './PresentationGroupChild.vue'
28
28
  import {
29
29
  getLineElementPath, getLineElementRenderPath, getLineDashArray, getSvgWidth, getSvgHeight, isEllipseShape, scaleSvgPath
30
30
  } from '../utils/line'
31
+ import { isTextContainingElement } from '../utils/groupFlip'
31
32
  import { SHAPE_LIST, type ShapePoolItem } from '../utils/shapes'
32
33
 
33
34
  const flatShapePoolItems: ShapePoolItem[] = SHAPE_LIST.flatMap(c => c.children)
@@ -2246,7 +2247,7 @@ const masterGroupedLayouts = computed(() => {
2246
2247
  width: el.style?.width || (el.width !== undefined ? `${el.width}px` : 'auto'),
2247
2248
  height: el.style?.height || (el.height !== undefined ? `${el.height}px` : 'auto'),
2248
2249
  zIndex: 1,
2249
- transform: `rotate(${el.style?.rotate || el.rotate || 0}deg) scale(${el.type !== 'text' && el.type !== 'line' && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${el.type !== 'text' && el.type !== 'line' && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
2250
+ transform: `rotate(${el.style?.rotate || el.rotate || 0}deg) scale(${!isTextContainingElement(el) && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${!isTextContainingElement(el) && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
2250
2251
  opacity: el.style?.opacity !== undefined ? el.style.opacity : 1,
2251
2252
  pointerEvents: 'none'
2252
2253
  }">
@@ -2347,7 +2348,7 @@ const masterGroupedLayouts = computed(() => {
2347
2348
  width: el.style?.width || 'auto',
2348
2349
  height: el.style?.height || 'auto',
2349
2350
  zIndex: 3,
2350
- transform: `rotate(${el.style.rotate || 0}deg) scale(${el.type !== 'text' && el.type !== 'line' && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${el.type !== 'text' && el.type !== 'line' && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
2351
+ transform: `rotate(${el.style.rotate || 0}deg) scale(${!isTextContainingElement(el) && ((el.style as any)?.flipH || el.flipH || el.isFlipH) ? -1 : 1}, ${!isTextContainingElement(el) && ((el.style as any)?.flipV || el.flipV || el.isFlipV) ? -1 : 1})`,
2351
2352
  opacity: (el.style as any).opacity !== undefined ? (el.style as any).opacity : 1,
2352
2353
  filter: (el.type !== 'image' && el.type !== 'chart' && (el.style as any).boxShadow && (el.style as any).boxShadow !== 'none') ? `drop-shadow(${(el.style as any).boxShadow})` : ''
2353
2354
  }">
@@ -245,13 +245,43 @@ const colorOptions = [
245
245
  { name: '黑色系', hex: '#212121' }
246
246
  ]
247
247
 
248
+ /**
249
+ * 资产细分子类型枚举 (对应后端 AssetSubType 与 jvs-material-ui)
250
+ */
251
+ export type StockSubType =
252
+ | 'Photos'
253
+ | 'Illustrations'
254
+ | 'Vectors'
255
+ | 'Icons'
256
+ | 'Videos'
257
+ | 'Music'
258
+ | 'Sound_Effects'
259
+ | 'GIF'
260
+ | '_3D_Models'
261
+
262
+ // 页面分类 Tab -> 后端 /stock/search subType 映射 (对应 jvs-dam-2.5 AssetSubType)
263
+ const tabToSubTypeMap: Record<string, StockSubType | undefined> = {
264
+ all: undefined,
265
+ image: 'Photos',
266
+ gif: 'GIF',
267
+ sticker: 'Icons',
268
+ svg: 'Vectors',
269
+ slide: undefined
270
+ }
271
+
248
272
  const loading = ref(false)
249
- const categoryTree = ref<MaterialCategory[]>([])
250
273
  const pagedMaterials = ref<MaterialItem[]>([])
251
274
  const currentPage = ref(1)
252
275
  const pageSize = 24
253
276
  const totalRecords = ref(0)
254
277
 
278
+ // 分页与异常熔断状态(彻底杜绝滚动到底部时的无限死循环请求)
279
+ const loadingMore = ref(false)
280
+ const loadFailed = ref(false)
281
+ const loadErrorMsg = ref('')
282
+ const reachEnd = ref(false)
283
+ let scrollThrottleTimer: number | null = null
284
+
255
285
  // 预设类型中文名映射
256
286
  const typeNameMap: Record<string, string> = {
257
287
  image: '图库图片',
@@ -279,11 +309,33 @@ async function safeFetchJson(url: string, options: RequestInit = {}): Promise<an
279
309
  }
280
310
  }
281
311
 
282
- // 规范化素材数据项
312
+ // 获取有效的公网素材搜索接口 URL(平滑兼容旧配置并默认使用 /jvs-dam/stock/search)
313
+ function getEffectiveStockUrl(): string {
314
+ let url = (materialPageApiUrl?.value && materialPageApiUrl.value.trim()) ||
315
+ (materialApiUrl?.value && materialApiUrl.value.trim()) ||
316
+ DEFAULT_MATERIAL_PAGE_API_URL
317
+ if (url.includes('/api/dam/material/tree') || url.includes('/api/dam/material/page')) {
318
+ url = DEFAULT_MATERIAL_PAGE_API_URL
319
+ }
320
+ return url
321
+ }
322
+
323
+ // 规范化素材数据项(支持 MediaResource 格式及自定义素材项)
283
324
  function normalizeMaterialItem(item: any, defaultCatName = '', defaultType = ''): MaterialItem {
284
- const type = item.type || defaultType || 'image'
285
- let previewUrl = item.previewUrl || item.url || item.downloadUrl || ''
286
- let url = item.url || item.previewUrl || item.downloadUrl || ''
325
+ const subType = item.assetSubType || item.subType || ''
326
+ let type = item.type || defaultType || ''
327
+ if (!type) {
328
+ if (subType === 'GIF') type = 'gif'
329
+ else if (subType === 'Icons') type = 'sticker'
330
+ else if (subType === 'Vectors') type = 'svg'
331
+ else if (subType === 'Photos' || subType === 'Illustrations') type = 'image'
332
+ else if (item.assetType === 'VIDEO') type = 'video'
333
+ else type = 'image'
334
+ }
335
+
336
+ let previewUrl = item.previewUrl || item.image || item.url || item.downloadUrl || item.sourceUrl || ''
337
+ let sourceUrl = item.sourceUrl || item.downloadUrl || item.url || previewUrl
338
+ let url = sourceUrl || previewUrl
287
339
 
288
340
  if (type === 'slide') {
289
341
  if (!previewUrl || previewUrl.includes('gold-cover.png')) {
@@ -294,19 +346,39 @@ function normalizeMaterialItem(item: any, defaultCatName = '', defaultType = '')
294
346
  }
295
347
  }
296
348
 
297
- const fmt = item.format || getItemFormat({ ...item, url, previewUrl, type })
349
+ // 来源平台识别(如 PEXELS, PIXABAY, UNSPLASH, ICONIFY 等)
350
+ const platform = item.sourcePlatform || (item.id && typeof item.id === 'string' && item.id.includes('_') ? item.id.split('_')[0].toUpperCase() : '')
351
+
352
+ const fmt = item.format || getItemFormat({ ...item, url, previewUrl, sourceUrl, type, assetSubType: subType })
353
+
354
+ // 宽高比解析
355
+ let ratio = item.aspectRatio ? String(item.aspectRatio).replace(':', '/') : undefined
356
+ if (!ratio && item.width && item.height) {
357
+ ratio = `${item.width} / ${item.height}`
358
+ }
298
359
 
299
360
  return {
300
361
  ...item,
362
+ id: item.id || `stock-${Math.random().toString(36).slice(2, 9)}`,
363
+ title: item.title || `${platform ? `${platform} ` : ''}${subType ? typeNameMap[type] || subType : '素材'}`,
301
364
  type,
365
+ assetSubType: subType,
366
+ sourcePlatform: platform,
302
367
  format: fmt,
303
- category: item.category || defaultCatName || (type && typeNameMap[type]) || type || '素材',
368
+ category: item.category || defaultCatName || (subType ? typeNameMap[type] || subType : '') || type || '素材',
304
369
  previewUrl,
305
- url
370
+ url,
371
+ sourceUrl,
372
+ color: item.color || item.primaryColor || '',
373
+ primaryColor: item.primaryColor || item.color || '',
374
+ aspectRatio: ratio || '4 / 3',
375
+ originalWidth: item.width || item.originalWidth,
376
+ originalHeight: item.height || item.originalHeight,
377
+ tags: Array.isArray(item.tags) ? item.tags : []
306
378
  }
307
379
  }
308
380
 
309
- // 统一数据格式解析
381
+ // 统一数据格式解析(兼容 jvs-dam-2.5 StockController R.ok(list) 及各种分页结构)
310
382
  function parseMaterialList(data: any): MaterialItem[] {
311
383
  if (!data) return []
312
384
  let rawList: any[] = []
@@ -328,59 +400,20 @@ function parseMaterialList(data: any): MaterialItem[] {
328
400
  return rawList.map(item => normalizeMaterialItem(item))
329
401
  }
330
402
 
331
- // 1. 获取首页分类树的 API 请求 (/jvs-dam/api/dam/material/tree)
332
- async function loadTreeMaterials(query = '') {
333
- const baseTreeUrl = (materialTreeApiUrl?.value && materialTreeApiUrl.value.trim() && !materialTreeApiUrl.value.includes('/templates/index.json'))
334
- ? materialTreeApiUrl.value.trim()
335
- : ((materialApiUrl.value && materialApiUrl.value.trim() && !materialApiUrl.value.includes('/templates/index.json'))
336
- ? materialApiUrl.value.trim()
337
- : DEFAULT_MATERIAL_API_URL)
338
-
339
- if (!baseTreeUrl) return
340
-
341
- try {
342
- loading.value = true
343
- const q = query.trim()
344
- const treeUrl = q
345
- ? `${baseTreeUrl}${baseTreeUrl.includes('?') ? '&' : '?'}title=${encodeURIComponent(q)}`
346
- : baseTreeUrl
347
-
348
- const data = await safeFetchJson(treeUrl, {
349
- headers: {
350
- ...(materialHeaders.value || {})
351
- }
352
- })
353
-
354
- if (Array.isArray(data?.data) && data.data.length > 0 && Array.isArray(data.data[0]?.items)) {
355
- categoryTree.value = data.data.map((cat: any) => ({
356
- ...cat,
357
- name: cat.name || (cat.type ? typeNameMap[cat.type] : '') || cat.type,
358
- items: (cat.items || []).map((item: any) => normalizeMaterialItem(item, cat.name, cat.type))
359
- }))
360
- } else {
361
- const flatList = parseMaterialList(data)
362
- categoryTree.value = flatList.length > 0 ? [
363
- { id: 'all-items', type: 'image', name: '素材', hasMore: true, items: flatList }
364
- ] : []
365
- }
366
- } catch (err: any) {
367
- console.error('加载素材树出错:', err)
368
- } finally {
369
- loading.value = false
403
+ // 核心检索方法:请求后端 stock/search 接口并发抓取 Pexels, Pixabay, Unsplash, Iconify 等平台
404
+ async function fetchMaterials(page = 1, append = false) {
405
+ // 单页模板独立处理
406
+ if (filterParams.activeType === 'slide') {
407
+ await fetchSlideTemplates(page, append)
408
+ return
370
409
  }
371
- }
372
-
373
- // 分页与异常熔断状态(彻底杜绝滚动到底部时的无限死循环请求)
374
- const loadingMore = ref(false)
375
- const loadFailed = ref(false)
376
- const loadErrorMsg = ref('')
377
- const reachEnd = ref(false)
378
- let scrollThrottleTimer: number | null = null
379
410
 
380
- // 2. 请求某一分类的分页接口 (/jvs-dam/api/dam/material/page)
381
- async function fetchCategoryPage(type: string, page = 1, append = false, query = '') {
382
- const pageUrl = (materialPageApiUrl?.value && materialPageApiUrl.value.trim()) || DEFAULT_MATERIAL_PAGE_API_URL
383
- if (!pageUrl) {
411
+ const stockUrl = getEffectiveStockUrl()
412
+ if (!stockUrl) {
413
+ if (!append) {
414
+ pagedMaterials.value = []
415
+ totalRecords.value = 0
416
+ }
384
417
  reachEnd.value = true
385
418
  return
386
419
  }
@@ -391,87 +424,119 @@ async function fetchCategoryPage(type: string, page = 1, append = false, query =
391
424
  loadFailed.value = false
392
425
  loadErrorMsg.value = ''
393
426
 
394
- let url = `${pageUrl}${pageUrl.includes('?') ? '&' : '?'}current=${page}&size=${pageSize}`
395
- if (type && type !== 'all') {
396
- url += `&type=${encodeURIComponent(type)}`
397
- }
398
- const q = query.trim()
427
+ const params = new URLSearchParams()
428
+ params.append('current', String(page))
429
+ params.append('size', String(pageSize))
430
+
431
+ const q = filterParams.query.trim()
399
432
  if (q) {
400
- url += `&title=${encodeURIComponent(q)}`
433
+ params.append('keyword', q)
434
+ }
435
+
436
+ const subType = tabToSubTypeMap[filterParams.activeType]
437
+ if (subType) {
438
+ params.append('subType', subType)
401
439
  }
402
- const data = await safeFetchJson(url, {
440
+
441
+ const fullUrl = `${stockUrl}${stockUrl.includes('?') ? '&' : '?'}${params.toString()}`
442
+ const data = await safeFetchJson(fullUrl, {
403
443
  headers: {
404
444
  ...(materialHeaders.value || {})
405
445
  }
406
446
  })
407
447
 
448
+ // 针对接口返回非成功状态(如未登录 code: -2 或业务异常)
449
+ if (data && data.code !== undefined && data.code !== 0 && data.code !== 200) {
450
+ if (!append) {
451
+ pagedMaterials.value = []
452
+ totalRecords.value = 0
453
+ }
454
+ reachEnd.value = true
455
+ return
456
+ }
457
+
408
458
  const newRecords = parseMaterialList(data)
409
459
 
410
- // 若返回空数组,说明后续已无更多数据,彻底关闭加载开关
411
- if (newRecords.length === 0) {
460
+ if (newRecords.length === 0 || newRecords.length < pageSize) {
412
461
  reachEnd.value = true
413
462
  } else {
414
463
  reachEnd.value = false
415
464
  }
416
465
 
417
466
  if (append) {
418
- pagedMaterials.value = [...pagedMaterials.value, ...newRecords]
467
+ const existingIds = new Set(pagedMaterials.value.map(i => i.id))
468
+ const uniqueItems = newRecords.filter(i => !existingIds.has(i.id))
469
+ pagedMaterials.value = [...pagedMaterials.value, ...uniqueItems]
419
470
  } else {
420
471
  pagedMaterials.value = newRecords
421
472
  }
473
+
422
474
  currentPage.value = page
423
- totalRecords.value = data.data?.total ?? (newRecords.length < pageSize ? (page - 1) * pageSize + newRecords.length : 9999)
475
+ totalRecords.value = data?.data?.total ?? data?.total ?? (reachEnd.value ? pagedMaterials.value.length : 9999)
424
476
  } catch (err: any) {
425
- console.error('加载分页素材出错:', err)
426
- // 熔断保护:记录失败并终止滚动自动重试
427
- loadFailed.value = true
428
- loadErrorMsg.value = err.message || '网络连接或接口异常'
477
+ console.warn('[Stock Search] 加载公网素材出错:', err)
478
+ if (!append) {
479
+ pagedMaterials.value = []
480
+ totalRecords.value = 0
481
+ reachEnd.value = true
482
+ } else {
483
+ loadFailed.value = true
484
+ loadErrorMsg.value = err.message || '网络连接或接口异常'
485
+ }
429
486
  } finally {
430
487
  loading.value = false
431
488
  loadingMore.value = false
432
489
  }
433
490
  }
434
491
 
435
- // 聚合当前素材列表
436
- const rawSourceMaterials = computed<MaterialItem[]>(() => {
437
- if (pagedMaterials.value.length > 0) {
438
- if (filterParams.activeType === 'all') {
439
- const treeItems: MaterialItem[] = []
440
- categoryTree.value.forEach(cat => {
441
- if (cat.items && cat.items.length > 0) {
442
- treeItems.push(...cat.items)
443
- }
444
- })
445
- const map = new Map<string | number, MaterialItem>()
446
- treeItems.forEach(i => map.set(i.id, i))
447
- pagedMaterials.value.forEach(i => map.set(i.id, i))
448
- return Array.from(map.values())
492
+ // 加载单页 PPT 模板
493
+ async function fetchSlideTemplates(page = 1, append = false) {
494
+ try {
495
+ loading.value = !append
496
+ loadingMore.value = append
497
+ loadFailed.value = false
498
+ loadErrorMsg.value = ''
499
+
500
+ const data = await safeFetchJson('/templates/index.json')
501
+ const templates = parseMaterialList(data)
502
+ if (templates.length > 0) {
503
+ pagedMaterials.value = templates
504
+ totalRecords.value = templates.length
505
+ reachEnd.value = true
506
+ } else {
507
+ pagedMaterials.value = []
508
+ totalRecords.value = 0
509
+ reachEnd.value = true
449
510
  }
450
- return pagedMaterials.value
511
+ } catch (err: any) {
512
+ pagedMaterials.value = []
513
+ totalRecords.value = 0
514
+ reachEnd.value = true
515
+ } finally {
516
+ loading.value = false
517
+ loadingMore.value = false
451
518
  }
452
- const items: MaterialItem[] = []
453
- categoryTree.value.forEach(cat => {
454
- if (cat.items && cat.items.length > 0) {
455
- items.push(...cat.items)
456
- }
457
- })
458
- return items
519
+ }
520
+
521
+ // 聚合当前素材列表
522
+ const rawSourceMaterials = computed<MaterialItem[]>(() => {
523
+ return pagedMaterials.value
459
524
  })
460
525
 
461
526
  // 是否还有更多数据可供加载(若已失败或已到底,立即返回 false,防止触底循环)
462
527
  const hasMore = computed(() => {
463
528
  if (reachEnd.value || loadFailed.value) return false
464
529
  if (totalRecords.value > 0) {
465
- return displayedMaterials.value.length < totalRecords.value
530
+ return pagedMaterials.value.length < totalRecords.value
466
531
  }
467
- return false
532
+ return !reachEnd.value
468
533
  })
469
534
 
470
535
  // 加载更多分页数据
471
536
  async function loadMore() {
472
537
  if (loading.value || loadingMore.value || !hasMore.value || loadFailed.value || reachEnd.value) return
473
538
  const nextPage = currentPage.value + 1
474
- await fetchCategoryPage(filterParams.activeType, nextPage, true, filterParams.query)
539
+ await fetchMaterials(nextPage, true)
475
540
  }
476
541
 
477
542
  // 手动点击重试(重置失败状态并重新触发加载)
@@ -479,7 +544,7 @@ async function retryLoadMore() {
479
544
  loadFailed.value = false
480
545
  loadErrorMsg.value = ''
481
546
  const targetPage = currentPage.value + 1
482
- await fetchCategoryPage(filterParams.activeType, targetPage, true, filterParams.query)
547
+ await fetchMaterials(targetPage, true)
483
548
  }
484
549
 
485
550
  // 瀑布流滚动触底自动加载监听(带 250ms 节流与熔断保护)
@@ -505,7 +570,15 @@ const displayedMaterials = computed<MaterialItem[]>(() => {
505
570
  return rawSourceMaterials.value.filter(item => {
506
571
  // 1. 类型过滤 (红框2)
507
572
  if (filterParams.activeType !== 'all') {
508
- if (item.type !== filterParams.activeType) return false
573
+ if (filterParams.activeType === 'slide') {
574
+ if (item.type !== 'slide' && !item.slide) return false
575
+ } else {
576
+ const subType = tabToSubTypeMap[filterParams.activeType]
577
+ if (subType) {
578
+ if (item.assetSubType && item.assetSubType !== subType) return false
579
+ if (!item.assetSubType && item.type !== filterParams.activeType) return false
580
+ }
581
+ }
509
582
  }
510
583
  // 2. 格式过滤 (红框3)
511
584
  if (filterParams.format) {
@@ -513,8 +586,9 @@ const displayedMaterials = computed<MaterialItem[]>(() => {
513
586
  if (fmt.toUpperCase() !== filterParams.format.toUpperCase()) return false
514
587
  }
515
588
  // 3. 颜色过滤 (红框3)
516
- if (filterParams.color && item.color) {
517
- if (item.color.toLowerCase() !== filterParams.color.toLowerCase()) return false
589
+ if (filterParams.color) {
590
+ const c = (item.color || item.primaryColor || '').toLowerCase()
591
+ if (!c || c !== filterParams.color.toLowerCase()) return false
518
592
  }
519
593
  return true
520
594
  })
@@ -541,22 +615,18 @@ async function switchCategoryTab(typeValue: string) {
541
615
  filterParams.activeType = typeValue
542
616
  currentPage.value = 1
543
617
  pagedMaterials.value = []
544
- if (typeValue !== 'all') {
545
- await fetchCategoryPage(typeValue, 1, false, filterParams.query)
546
- } else {
547
- await loadTreeMaterials(filterParams.query)
548
- }
618
+ reachEnd.value = false
619
+ loadFailed.value = false
620
+ await fetchMaterials(1, false)
549
621
  }
550
622
 
551
623
  // 搜索处理(红框1)
552
624
  async function handleSearch() {
553
625
  currentPage.value = 1
554
626
  pagedMaterials.value = []
555
- if (filterParams.activeType !== 'all') {
556
- await fetchCategoryPage(filterParams.activeType, 1, false, filterParams.query)
557
- } else {
558
- await loadTreeMaterials(filterParams.query)
559
- }
627
+ reachEnd.value = false
628
+ loadFailed.value = false
629
+ await fetchMaterials(1, false)
560
630
  }
561
631
 
562
632
  function clearQuery() {
@@ -591,9 +661,23 @@ function handleSelectMaterial(meta: MaterialItem) {
591
661
  if (meta.type === 'slide' || meta.slide) {
592
662
  handleInsertSlide(meta)
593
663
  } else {
594
- const imgUrl = meta.url || meta.previewUrl || meta.downloadUrl
664
+ // 优先采用高清 sourceUrl,回退到 url / previewUrl / downloadUrl
665
+ const imgUrl = meta.sourceUrl || meta.url || meta.previewUrl || meta.downloadUrl
595
666
  if (imgUrl) {
596
- addElement('image', imgUrl)
667
+ // 若包含长宽信息,按比例设定元素尺寸插入画布
668
+ let initialDimensions: { width?: number; height?: number } | undefined = undefined
669
+ const rawW = meta.originalWidth || meta.width
670
+ const rawH = meta.originalHeight || meta.height
671
+ if (rawW && rawH && rawW > 0 && rawH > 0) {
672
+ const maxWidth = 480
673
+ const maxHeight = 360
674
+ const scale = Math.min(maxWidth / rawW, maxHeight / rawH, 1)
675
+ initialDimensions = {
676
+ width: Math.round(rawW * scale),
677
+ height: Math.round(rawH * scale)
678
+ }
679
+ }
680
+ addElement('image', imgUrl, undefined, undefined, undefined, initialDimensions)
597
681
  notify(`已成功添加素材 "${meta.title || '图片'}" 到画布`)
598
682
  } else {
599
683
  notify('该素材暂无可用的图片地址')
@@ -650,7 +734,7 @@ async function handleInsertSlide(meta: MaterialItem) {
650
734
  }
651
735
 
652
736
  onMounted(() => {
653
- loadTreeMaterials()
737
+ fetchMaterials(1, false)
654
738
 
655
739
  // 监听容器大小以实现极致自适应
656
740
  if (containerRef.value) {
@@ -674,8 +758,8 @@ onUnmounted(() => {
674
758
  window.removeEventListener('keydown', handleKeydown)
675
759
  })
676
760
 
677
- watch(() => materialTreeApiUrl?.value, () => {
678
- loadTreeMaterials()
761
+ watch([() => materialApiUrl?.value, () => materialPageApiUrl?.value, () => materialTreeApiUrl?.value], () => {
762
+ fetchMaterials(1, false)
679
763
  })
680
764
  </script>
681
765
 
@@ -884,6 +968,7 @@ watch(() => materialTreeApiUrl?.value, () => {
884
968
  <div class="card-bottom-info">
885
969
  <div class="format-badge-box">
886
970
  <span class="format-tag">{{ getItemFormat(item) }}</span>
971
+ <span v-if="item.sourcePlatform" class="platform-tag">{{ item.sourcePlatform }}</span>
887
972
  <span v-if="item.title" class="format-title-hint" :title="item.title">{{ item.title }}</span>
888
973
  </div>
889
974
  <button type="button" class="more-options-dot-btn" @click.stop="openItemMenu(item)">
@@ -1483,6 +1568,19 @@ watch(() => materialTreeApiUrl?.value, () => {
1483
1568
  flex-shrink: 0;
1484
1569
  }
1485
1570
 
1571
+ .platform-tag {
1572
+ font-size: 9px;
1573
+ font-weight: 700;
1574
+ color: #2563eb;
1575
+ background: #eff6ff;
1576
+ border: 1px solid #dbeafe;
1577
+ padding: 1px 5px;
1578
+ border-radius: 3px;
1579
+ letter-spacing: 0.4px;
1580
+ flex-shrink: 0;
1581
+ text-transform: uppercase;
1582
+ }
1583
+
1486
1584
  .format-title-hint {
1487
1585
  font-size: 11px;
1488
1586
  color: #64748b;