jvs-ppt-ui 0.0.11 → 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.
@@ -80,6 +80,7 @@ const {
80
80
  aiPptDialogVisible,
81
81
  unsplashDialogVisible,
82
82
  isPresenting,
83
+ isReadonly,
83
84
  importSlides,
84
85
  exportSlides,
85
86
  currentEditingFormulaEl,
@@ -110,6 +111,10 @@ const {
110
111
  uploadMediaResourcesInSlides
111
112
  } = slidesState
112
113
 
114
+ watch(() => props.readonly, (val) => {
115
+ isReadonly.value = !!val
116
+ }, { immediate: true })
117
+
113
118
  // 弹层挂载闩锁:首次 visible 后保持挂载(异步组件只加载一次,关闭动画不被 v-if 掐断)
114
119
  function latchMount(source: Ref<boolean>) {
115
120
  const opened = ref(false)
@@ -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)
@@ -42,7 +43,7 @@ const {
42
43
  enterMasterView, addMaster, deleteMaster, addLayout, deleteLayout, duplicateLayout,
43
44
  clipboard, slideClipboard, handleUnifiedPaste, sortSlideElements, getEffectiveMasterElementsForSlide,
44
45
  getEffectiveSlideBackground, isCanvasDragging, activeSlideVersion, pauseHistory,
45
- activeSidebarTab
46
+ activeSidebarTab, isReadonly
46
47
  } = useSlides()
47
48
 
48
49
  const displaySlides = shallowRef<any[]>([...slides.value])
@@ -2092,7 +2093,7 @@ const masterGroupedLayouts = computed(() => {
2092
2093
 
2093
2094
  <!-- Regular Sidebar Layouts -->
2094
2095
  <template v-else>
2095
- <div class="sidebar-add-slide-container" style="padding: 12px 16px; display: flex; flex-shrink: 0;">
2096
+ <div v-if="!isReadonly" class="sidebar-add-slide-container" style="padding: 12px 16px; display: flex; flex-shrink: 0;">
2096
2097
  <div class="custom-split-button">
2097
2098
  <!-- Main Add Slide Button -->
2098
2099
  <button @click="addSlide" class="split-btn-left">
@@ -2153,7 +2154,7 @@ const masterGroupedLayouts = computed(() => {
2153
2154
  </button>
2154
2155
  </div> -->
2155
2156
 
2156
- <Draggable v-if="activeSidebarTab === 'outline'" class="slide-list" ref="slideListRef" :modelValue="displaySlides"
2157
+ <Draggable v-if="activeSidebarTab === 'outline'" :disabled="isReadonly" class="slide-list" ref="slideListRef" :modelValue="displaySlides"
2157
2158
  :animation="200" :scroll="true" :scrollSensitivity="50" @end="handleDragEnd" itemKey="id"
2158
2159
  @mouseenter="isMouseOverSlideList = true" @mouseleave="isMouseOverSlideList = false">
2159
2160
  <template #item="{ element: slide, index }">
@@ -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
  }">
@@ -2759,6 +2760,17 @@ const masterGroupedLayouts = computed(() => {
2759
2760
  </template>
2760
2761
  </template>
2761
2762
 
2763
+ <template v-else-if="isReadonly">
2764
+ <div class="menu-item" @click="copySlide(rightClickedIndex)">
2765
+ <span class="menu-label">{{ locale === 'zh' ? '复制' : 'Copy' }}</span>
2766
+ <span class="menu-shortcut">Ctrl + C</span>
2767
+ </div>
2768
+ <div class="menu-item" @click="playFromCurrent">
2769
+ <span class="menu-label">{{ locale === 'zh' ? '从当前放映' : 'Play from Current' }}</span>
2770
+ <span class="menu-shortcut">Shift + F5</span>
2771
+ </div>
2772
+ </template>
2773
+
2762
2774
  <template v-else>
2763
2775
  <div class="menu-item" @click="cutSlide(rightClickedIndex)">
2764
2776
  <span class="menu-label">{{ locale === 'zh' ? '剪切' : 'Cut' }}</span>
@@ -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;
@@ -22,7 +22,7 @@ import { getFontVariantStyle } from '../../../configs/fonts'
22
22
  import { useSlides } from '../../../composables/useSlides'
23
23
  import { createTextSelectionOverlay, type TextSelectionOverlayHandle } from '../textSelectionOverlay'
24
24
 
25
- const { shapeFormatPainter } = useSlides()
25
+ const { shapeFormatPainter, isReadonly } = useSlides()
26
26
  const isTextPaintActive = computed(() => !!(shapeFormatPainter.value && shapeFormatPainter.value.mode === 'text-range'))
27
27
  const isTextSelectable = computed(() => (props.isEditingText && props.selectedElementId === props.element.id) || isTextPaintActive.value)
28
28
 
@@ -262,7 +262,7 @@ function formatShapeHtml(el: any): string {
262
262
  <div
263
263
  class="element-content shape-element-content"
264
264
  style="width: 100%; height: 100%; position: relative;"
265
- @dblclick.stop.prevent="isImagePlaceholder && isImagePlaceholder(element) ? emit('placeholderUpload', element.id) : (isTextSelectable ? null : emit('startEditing', $event))"
265
+ @dblclick.stop.prevent="isReadonly ? null : (isImagePlaceholder && isImagePlaceholder(element) ? emit('placeholderUpload', element.id) : (isTextSelectable ? null : emit('startEditing', $event)))"
266
266
  >
267
267
  <!-- Circle Shape (with fill) -->
268
268
  <div
@@ -666,7 +666,7 @@ function formatShapeHtml(el: any): string {
666
666
  @keydown="emit('textKeyDown', $event)"
667
667
  @mousemove="emit('mousemove', $event)"
668
668
  @mouseleave="emit('mouseleave', $event)"
669
- @dblclick.stop.prevent="isImagePlaceholder && isImagePlaceholder(element) ? emit('placeholderUpload', element.id) : (isTextSelectable ? null : emit('startEditing', $event))"
669
+ @dblclick.stop.prevent="isReadonly ? null : (isImagePlaceholder && isImagePlaceholder(element) ? emit('placeholderUpload', element.id) : (isTextSelectable ? null : emit('startEditing', $event)))"
670
670
  class="shape-text-overlay"
671
671
  :style="{
672
672
  display: 'block',