jvs-ppt-ui 0.0.14 → 0.0.16

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 (67) hide show
  1. package/dist/lib/check_imgs.html +18 -0
  2. package/dist/lib/jvs-ppt-ui/iconfont/iconfont.css +187 -27
  3. package/dist/lib/jvs-ppt-ui/iconfont/iconfont.js +1 -1
  4. package/dist/lib/jvs-ppt-ui/iconfont/iconfont.json +345 -65
  5. package/dist/lib/jvs-ppt-ui/iconfont/iconfont.ttf +0 -0
  6. package/dist/lib/jvs-ppt-ui/iconfont/iconfont.woff +0 -0
  7. package/dist/lib/jvs-ppt-ui/iconfont/iconfont.woff2 +0 -0
  8. package/dist/lib/jvs-ppt-ui/img/al.png +0 -0
  9. package/dist/lib/jvs-ppt-ui/img/d.png +0 -0
  10. package/dist/lib/jvs-ppt-ui/img/dl.png +0 -0
  11. package/dist/lib/jvs-ppt-ui/img/l.png +0 -0
  12. package/dist/lib/jvs-ppt-ui.es.js +44973 -43349
  13. package/dist/lib/jvs-ppt-ui.umd.js +102 -102
  14. package/dist/lib/style.css +1 -1
  15. package/package.json +1 -1
  16. package/scripts/check-icons.mjs +22 -0
  17. package/scripts/patch-anim-icons.mjs +45 -0
  18. package/src/assets/iconfont/iconfont.css +187 -27
  19. package/src/assets/iconfont/iconfont.js +1 -1
  20. package/src/assets/iconfont/iconfont.json +345 -65
  21. package/src/assets/iconfont/iconfont.ttf +0 -0
  22. package/src/assets/iconfont/iconfont.woff +0 -0
  23. package/src/assets/iconfont/iconfont.woff2 +0 -0
  24. package/src/components/CanvasArea.vue +218 -193
  25. package/src/components/ChartTypeSelector.vue +509 -148
  26. package/src/components/Header.vue +327 -44
  27. package/src/components/PresentationGroupChild.vue +44 -13
  28. package/src/components/PresentationMode.vue +84 -33
  29. package/src/components/QuickBorderPopover.vue +51 -11
  30. package/src/components/QuickColorPopover.vue +65 -21
  31. package/src/components/SidebarLeft.vue +286 -107
  32. package/src/components/SidebarRight.vue +45 -38
  33. package/src/components/TemplateGallery.vue +1110 -1237
  34. package/src/components/ToastNotification.vue +2 -2
  35. package/src/components/Toolbar.vue +825 -733
  36. package/src/components/canvas/CanvasQuickToolbar.vue +17 -30
  37. package/src/components/canvas/elements/CanvasGroupElement.vue +6 -0
  38. package/src/components/canvas/elements/CanvasShapeElement.vue +198 -38
  39. package/src/components/canvas/elements/CanvasTextElement.vue +4 -3
  40. package/src/components/sidebar/common/SidebarAlignGroup.vue +104 -50
  41. package/src/components/sidebar/common/SidebarBorderAccordion.vue +375 -148
  42. package/src/components/sidebar/common/SidebarPositionGroup.vue +358 -149
  43. package/src/components/sidebar/common/SidebarReflectionAccordion.vue +429 -203
  44. package/src/components/sidebar/common/SidebarShadowAccordion.vue +844 -416
  45. package/src/components/sidebar/panels/GradientEditor.vue +671 -316
  46. package/src/components/sidebar/panels/LayoutThumbSvg.vue +33 -16
  47. package/src/components/sidebar/panels/SidebarAnimationPanel.vue +701 -138
  48. package/src/components/sidebar/panels/SidebarChartFormatPanel.vue +3 -3
  49. package/src/components/sidebar/panels/SidebarDesignPanel.vue +1754 -416
  50. package/src/components/sidebar/panels/SidebarMediaFormatPanel.vue +12 -0
  51. package/src/components/sidebar/panels/SidebarShapeFormatPanel.vue +813 -174
  52. package/src/components/sidebar/panels/SidebarTableFormatPanel.vue +2 -2
  53. package/src/components/sidebar/panels/SidebarTextFormatPanel.vue +1938 -1427
  54. package/src/components/sidebar/panels/SidebarTransitionPanel.vue +791 -210
  55. package/src/composables/canvas/useCanvasShortcuts.ts +20 -6
  56. package/src/composables/canvas/useCanvasText.ts +1 -1
  57. package/src/composables/canvas/useCanvasTransform.ts +44 -0
  58. package/src/composables/presetThemes.ts +43 -44
  59. package/src/composables/sidebar/useSidebarAnimation.ts +101 -25
  60. package/src/composables/sidebar/useSidebarDesign.ts +30 -11
  61. package/src/composables/sidebar/useSidebarElementTransform.ts +10 -1
  62. package/src/composables/sidebar/useSidebarImage.ts +10 -2
  63. package/src/composables/sidebar/useSidebarShapeFormat.ts +562 -107
  64. package/src/composables/useSlides.ts +331 -153
  65. package/src/main.ts +2 -0
  66. package/src/style.css +926 -359
  67. package/src/utils/canvas/elementStyles.ts +42 -1
@@ -1,69 +1,169 @@
1
1
  <script setup lang="ts">
2
- import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
2
+ import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
3
3
  import {
4
4
  Search,
5
5
  Plus,
6
6
  Close,
7
7
  CircleCloseFilled,
8
- ArrowDown,
9
8
  ArrowLeft,
10
9
  ArrowRight,
11
- MoreFilled,
12
- View
10
+ View,
11
+ VideoPlay
13
12
  } from '@element-plus/icons-vue'
14
- import { useSlides, DEFAULT_MATERIAL_API_URL, DEFAULT_MATERIAL_PAGE_API_URL } from '../composables/useSlides'
13
+ import { useSlides, DEFAULT_MATERIAL_PAGE_API_URL } from '../composables/useSlides'
15
14
 
16
15
  const {
17
- insertExternalSlide,
18
16
  addElement,
19
17
  notify,
20
18
  materialApiUrl,
21
19
  materialTreeApiUrl,
22
20
  materialPageApiUrl,
23
21
  materialHeaders,
24
- saveCurrentSlideToMaterial,
25
- activeSlide,
22
+ activeTab,
26
23
  materialPopoverVisible
27
24
  } = useSlides()
28
25
 
29
- // 容器响应式宽度监听(区分浮窗大屏与侧边栏窄屏模式)
30
- const containerRef = ref<HTMLElement | null>(null)
31
- const isNarrow = ref(false)
32
- let resizeObserver: ResizeObserver | null = null
26
+ export interface MaterialItem {
27
+ id: string | number
28
+ title: string
29
+ pptSource?: string
30
+ category?: string
31
+ type?: string
32
+ assetType?: string
33
+ assetSubType?: string
34
+ subType?: string
35
+ format?: string
36
+ color?: string
37
+ slideIndex?: number
38
+ fileName?: string
39
+ url?: string
40
+ previewUrl?: string
41
+ sourceUrl?: string
42
+ downloadUrl?: string
43
+ elementsCount?: number
44
+ originalWidth?: number
45
+ originalHeight?: number
46
+ originalSize?: { width: number; height: number }
47
+ tags?: string[]
48
+ aspectRatio?: string
49
+ [key: string]: any
50
+ }
33
51
 
34
- // 默认单页模板封面地址
35
- const DEFAULT_SLIDE_COVER = 'https://frame.bctools.cn/jvs-oss/material/templates/gold-cover.png'
52
+ export type StockSubType =
53
+ | 'Vectors'
54
+ | 'Photos'
55
+ | 'Illustrations'
56
+ | 'Videos'
36
57
 
37
- function getMaterialThumb(item: MaterialItem): string {
38
- if (item.type === 'slide') {
39
- const u = item.previewUrl || item.url
40
- if (!u || u.includes('gold-cover.png')) {
41
- return DEFAULT_SLIDE_COVER
42
- }
43
- return u
58
+ interface OverviewCategoryConfig {
59
+ key: StockSubType
60
+ label: string
61
+ subType: StockSubType
62
+ tags: string[]
63
+ }
64
+
65
+ // 图二:固定 4 大核心分类区块配置,每个分类对应后端 subType 参数独立请求一次接口
66
+ const overviewCategories: OverviewCategoryConfig[] = [
67
+ {
68
+ key: 'Vectors',
69
+ label: '矢量图',
70
+ subType: 'Vectors',
71
+ tags: ['风景', '商务', '科技', '插画', '几何', '背景', '图标']
72
+ },
73
+ {
74
+ key: 'Photos',
75
+ label: '照片',
76
+ subType: 'Photos',
77
+ tags: ['风景', '商务', '科技', '人物', '自然', '建筑', '美食', '办公']
78
+ },
79
+ {
80
+ key: 'Illustrations',
81
+ label: '插画',
82
+ subType: 'Illustrations',
83
+ tags: ['风景', '商务', '科技', '人物', '自然', '创意', '手绘', '扁平']
84
+ },
85
+ {
86
+ key: 'Videos',
87
+ label: '高清视频',
88
+ subType: 'Videos',
89
+ tags: ['风景', '商务', '科技', '自然', '城市', '航拍', '办公']
44
90
  }
45
- return item.previewUrl || item.url || item.downloadUrl || ''
91
+ ]
92
+
93
+ const subTypeNameMap: Record<StockSubType, string> = {
94
+ Vectors: '矢量图',
95
+ Photos: '照片',
96
+ Illustrations: '插画',
97
+ Videos: '高清视频'
98
+ }
99
+
100
+ // 视图状态:先图二 (overview) 再图三 (detail)
101
+ const viewMode = ref<'overview' | 'detail'>('overview')
102
+ const overviewQuery = ref('')
103
+ const baseDetailTitle = ref('素材库')
104
+ const baseSearchQuery = ref('')
105
+ const activeTag = ref('')
106
+
107
+ // 统一筛选与搜索模型
108
+ const filterParams = reactive<{
109
+ query: string
110
+ activeSubType: StockSubType
111
+ }>({
112
+ query: '',
113
+ activeSubType: 'Vectors'
114
+ })
115
+
116
+ // 图三顶部返回栏显示的标题
117
+ const detailHeaderTitle = computed(() => {
118
+ if (activeTag.value) return activeTag.value
119
+ if (baseSearchQuery.value) return baseSearchQuery.value
120
+ return baseDetailTitle.value || subTypeNameMap[filterParams.activeSubType] || '素材库'
121
+ })
122
+
123
+ // 当前分类下的细分标签列表
124
+ const currentCategoryTags = computed(() => {
125
+ const found = overviewCategories.find(c => c.subType === filterParams.activeSubType)
126
+ if (found) return found.tags
127
+ return ['风景', '商务', '科技', '人物', '自然', '建筑', '办公']
128
+ })
129
+
130
+ function isVideoUrl(url?: string): boolean {
131
+ if (!url) return false
132
+ const clean = url.split('?')[0].toLowerCase()
133
+ return clean.endsWith('.mp4') || clean.endsWith('.webm') || clean.endsWith('.mov') || clean.endsWith('.m4v') || clean.endsWith('.ogg')
134
+ }
135
+
136
+ function isVideoMaterial(item: MaterialItem): boolean {
137
+ return (
138
+ item.type === 'video' ||
139
+ item.assetType === 'VIDEO' ||
140
+ item.assetSubType === 'Videos' ||
141
+ item.subType === 'Videos' ||
142
+ isVideoUrl(item.sourceUrl || item.url || item.downloadUrl)
143
+ )
144
+ }
145
+
146
+ function getMaterialThumb(item: MaterialItem): string {
147
+ return item.previewUrl || item.image || item.coverUrl || item.thumbnail || item.url || item.sourceUrl || item.downloadUrl || ''
46
148
  }
47
149
 
48
- // 提取格式标签
49
150
  function getItemFormat(item: MaterialItem): string {
50
151
  if (item.format) return item.format.toUpperCase()
51
- const u = (item.url || item.previewUrl || item.fileName || '').toLowerCase()
152
+ const u = (item.sourceUrl || item.url || item.previewUrl || item.fileName || '').toLowerCase()
153
+ if (u.includes('.mp4')) return 'MP4'
154
+ if (u.includes('.mov') || u.includes('.webm')) return 'VIDEO'
155
+ if (u.includes('.svg')) return 'SVG'
52
156
  if (u.includes('.png')) return 'PNG'
53
157
  if (u.includes('.gif')) return 'GIF'
54
- if (u.includes('.svg')) return 'SVG'
55
- if (u.includes('.psd')) return 'PSD'
56
- if (u.includes('.ai')) return 'AI'
57
- if (u.includes('.mov') || u.includes('.mp4')) return 'MOV'
58
- if (item.type === 'gif') return 'GIF'
59
- if (item.type === 'svg') return 'SVG'
60
- if (item.type === 'sticker') return 'PNG'
158
+ if (item.assetSubType === 'Videos' || item.type === 'video') return 'MP4'
159
+ if (item.assetSubType === 'Vectors' || item.type === 'svg') return 'SVG'
61
160
  return 'JPG'
62
161
  }
63
162
 
64
- // 100% 自研可靠的全屏大图画廊预览状态
163
+ // 全屏大图/视频预览状态
65
164
  interface PreviewItem {
66
165
  url: string
166
+ isVideo: boolean
67
167
  title: string
68
168
  format: string
69
169
  meta: MaterialItem
@@ -85,19 +185,31 @@ function openPreview(item: MaterialItem, e?: Event) {
85
185
  e.preventDefault()
86
186
  }
87
187
 
88
- const currentUrl = getMaterialThumb(item)
188
+ const currentUrl = isVideoMaterial(item)
189
+ ? (item.sourceUrl || item.url || item.downloadUrl || getMaterialThumb(item))
190
+ : getMaterialThumb(item)
191
+
89
192
  if (!currentUrl) {
90
193
  notify('该素材暂无可用预览地址')
91
194
  return
92
195
  }
93
196
 
197
+ const subKey = (item.assetSubType || item.subType || filterParams.activeSubType) as StockSubType
198
+ const sourceList = viewMode.value === 'detail'
199
+ ? displayedMaterials.value
200
+ : (overviewItemsMap[subKey] || [])
201
+
94
202
  const validItems: PreviewItem[] = []
95
- displayedMaterials.value.forEach(m => {
96
- const u = getMaterialThumb(m)
203
+ sourceList.forEach(m => {
204
+ const isVid = isVideoMaterial(m)
205
+ const u = isVid
206
+ ? (m.sourceUrl || m.url || m.downloadUrl || getMaterialThumb(m))
207
+ : getMaterialThumb(m)
97
208
  if (u) {
98
209
  validItems.push({
99
210
  url: u,
100
- title: m.title || '素材大图',
211
+ isVideo: isVid && isVideoUrl(u),
212
+ title: m.title || '素材预览',
101
213
  format: getItemFormat(m),
102
214
  meta: m
103
215
  })
@@ -110,7 +222,8 @@ function openPreview(item: MaterialItem, e?: Event) {
110
222
  } else {
111
223
  validItems.unshift({
112
224
  url: currentUrl,
113
- title: item.title || '素材大图',
225
+ isVideo: isVideoMaterial(item) && isVideoUrl(currentUrl),
226
+ title: item.title || '素材预览',
114
227
  format: getItemFormat(item),
115
228
  meta: item
116
229
  })
@@ -119,7 +232,6 @@ function openPreview(item: MaterialItem, e?: Event) {
119
232
 
120
233
  previewList.value = validItems
121
234
 
122
- // 预览大图时,隐藏素材弹窗,并记录状态以便关闭预览后无缝恢复
123
235
  if (materialPopoverVisible && materialPopoverVisible.value) {
124
236
  wasPopoverOpenBeforePreview.value = true
125
237
  materialPopoverVisible.value = false
@@ -134,7 +246,6 @@ function closePreview() {
134
246
  previewVisible.value = false
135
247
  if (wasPopoverOpenBeforePreview.value && materialPopoverVisible) {
136
248
  wasPopoverOpenBeforePreview.value = false
137
- // 延时微任务以防预览弹窗的点击事件穿透触发 Popover 的外部关闭逻辑
138
249
  setTimeout(() => {
139
250
  materialPopoverVisible.value = true
140
251
  }, 60)
@@ -172,125 +283,6 @@ function handleKeydown(e: KeyboardEvent) {
172
283
  }
173
284
  }
174
285
 
175
- export interface MaterialItem {
176
- id: string | number
177
- title: string
178
- pptSource?: string
179
- category?: string
180
- type?: string
181
- format?: string
182
- color?: string
183
- slideIndex?: number
184
- fileName?: string
185
- url?: string
186
- previewUrl?: string
187
- downloadUrl?: string
188
- slide?: any
189
- elementsCount?: number
190
- originalWidth?: number
191
- originalHeight?: number
192
- originalSize?: { width: number; height: number }
193
- tags?: string[]
194
- aspectRatio?: string
195
- [key: string]: any
196
- }
197
-
198
- export interface MaterialCategory {
199
- id: string
200
- type: string
201
- name: string
202
- hasMore?: boolean
203
- items?: MaterialItem[]
204
- }
205
-
206
- // 统一筛选与搜索模型(红框1 + 红框2 + 红框3)
207
- const filterParams = reactive({
208
- query: '', // 红框1:搜索关键词
209
- activeType: 'all', // 红框2:分类 Tabs
210
- sort: 'recommend', // 红框3:综合排序
211
- format: '', // 红框3:格式筛选
212
- color: '' // 红框3:颜色筛选
213
- })
214
-
215
- // 分类 Tabs(红框2)
216
- const categoryTabs = [
217
- { label: '全部', value: 'all' },
218
- { label: '图片', value: 'image' },
219
- { label: '动图', value: 'gif' },
220
- { label: '免抠', value: 'sticker' },
221
- { label: '矢量', value: 'svg' },
222
- { label: '模板', value: 'slide' }
223
- ]
224
-
225
- // 排序选项(红框3)
226
- const sortOptions = [
227
- { label: '综合排序', value: 'recommend' },
228
- { label: '最新上传', value: 'latest' },
229
- { label: '热门下载', value: 'hot' }
230
- ]
231
-
232
- // 格式选项(红框3下拉)
233
- const formatOptions = ['JPG', 'PNG', 'GIF', 'SVG', 'EPS', 'PSD', 'MOV', 'AI']
234
-
235
- // 常用色系
236
- const colorOptions = [
237
- { name: '蓝色系', hex: '#2196F3' },
238
- { name: '青色系', hex: '#00BCD4' },
239
- { name: '绿色系', hex: '#4CAF50' },
240
- { name: '黄色系', hex: '#FFC107' },
241
- { name: '橙色系', hex: '#FF9800' },
242
- { name: '红色系', hex: '#F44336' },
243
- { name: '紫色系', hex: '#9C27B0' },
244
- { name: '白色系', hex: '#FFFFFF' },
245
- { name: '黑色系', hex: '#212121' }
246
- ]
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
-
272
- const loading = ref(false)
273
- const pagedMaterials = ref<MaterialItem[]>([])
274
- const currentPage = ref(1)
275
- const pageSize = 24
276
- const totalRecords = ref(0)
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
-
285
- // 预设类型中文名映射
286
- const typeNameMap: Record<string, string> = {
287
- image: '图库图片',
288
- gif: '动图GIF',
289
- sticker: '免抠贴纸',
290
- svg: '矢量图形',
291
- slide: '单页模板'
292
- }
293
-
294
286
  // 安全的 JSON 请求辅助函数
295
287
  async function safeFetchJson(url: string, options: RequestInit = {}): Promise<any> {
296
288
  const response = await fetch(url, options)
@@ -309,7 +301,6 @@ async function safeFetchJson(url: string, options: RequestInit = {}): Promise<an
309
301
  }
310
302
  }
311
303
 
312
- // 获取有效的公网素材搜索接口 URL(平滑兼容旧配置并默认使用 /jvs-dam/stock/search)
313
304
  function getEffectiveStockUrl(): string {
314
305
  let url = (materialPageApiUrl?.value && materialPageApiUrl.value.trim()) ||
315
306
  (materialApiUrl?.value && materialApiUrl.value.trim()) ||
@@ -320,38 +311,22 @@ function getEffectiveStockUrl(): string {
320
311
  return url
321
312
  }
322
313
 
323
- // 规范化素材数据项(支持 MediaResource 格式及自定义素材项)
324
- function normalizeMaterialItem(item: any, defaultCatName = '', defaultType = ''): MaterialItem {
325
- const subType = item.assetSubType || item.subType || ''
326
- let type = item.type || defaultType || ''
314
+ function normalizeMaterialItem(item: any, defaultSubType: StockSubType = 'Photos'): MaterialItem {
315
+ const subType = (item.assetSubType || item.subType || defaultSubType) as StockSubType
316
+ let type = item.type || ''
327
317
  if (!type) {
328
- if (subType === 'GIF') type = 'gif'
329
- else if (subType === 'Icons') type = 'sticker'
318
+ if (subType === 'Videos' || item.assetType === 'VIDEO') type = 'video'
330
319
  else if (subType === 'Vectors') type = 'svg'
331
- else if (subType === 'Photos' || subType === 'Illustrations') type = 'image'
332
- else if (item.assetType === 'VIDEO') type = 'video'
333
320
  else type = 'image'
334
321
  }
335
322
 
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
339
-
340
- if (type === 'slide') {
341
- if (!previewUrl || previewUrl.includes('gold-cover.png')) {
342
- previewUrl = DEFAULT_SLIDE_COVER
343
- }
344
- if (!url || url.includes('gold-cover.png')) {
345
- url = DEFAULT_SLIDE_COVER
346
- }
347
- }
323
+ const previewUrl = item.previewUrl || item.image || item.coverUrl || item.thumbnail || item.url || item.downloadUrl || item.sourceUrl || ''
324
+ const sourceUrl = item.sourceUrl || item.downloadUrl || item.url || previewUrl
325
+ const url = sourceUrl || previewUrl
348
326
 
349
- // 来源平台识别(如 PEXELS, PIXABAY, UNSPLASH, ICONIFY 等)
350
327
  const platform = item.sourcePlatform || (item.id && typeof item.id === 'string' && item.id.includes('_') ? item.id.split('_')[0].toUpperCase() : '')
351
-
352
328
  const fmt = item.format || getItemFormat({ ...item, url, previewUrl, sourceUrl, type, assetSubType: subType })
353
329
 
354
- // 宽高比解析
355
330
  let ratio = item.aspectRatio ? String(item.aspectRatio).replace(':', '/') : undefined
356
331
  if (!ratio && item.width && item.height) {
357
332
  ratio = `${item.width} / ${item.height}`
@@ -360,12 +335,13 @@ function normalizeMaterialItem(item: any, defaultCatName = '', defaultType = '')
360
335
  return {
361
336
  ...item,
362
337
  id: item.id || `stock-${Math.random().toString(36).slice(2, 9)}`,
363
- title: item.title || `${platform ? `${platform} ` : ''}${subType ? typeNameMap[type] || subType : '素材'}`,
338
+ title: item.title || `${platform ? `${platform} ` : ''}${subTypeNameMap[subType] || '素材'}`,
364
339
  type,
365
340
  assetSubType: subType,
341
+ subType,
366
342
  sourcePlatform: platform,
367
343
  format: fmt,
368
- category: item.category || defaultCatName || (subType ? typeNameMap[type] || subType : '') || type || '素材',
344
+ category: item.category || subTypeNameMap[subType] || '素材',
369
345
  previewUrl,
370
346
  url,
371
347
  sourceUrl,
@@ -378,8 +354,7 @@ function normalizeMaterialItem(item: any, defaultCatName = '', defaultType = '')
378
354
  }
379
355
  }
380
356
 
381
- // 统一数据格式解析(兼容 jvs-dam-2.5 StockController R.ok(list) 及各种分页结构)
382
- function parseMaterialList(data: any): MaterialItem[] {
357
+ function parseMaterialList(data: any, defaultSubType: StockSubType = 'Photos'): MaterialItem[] {
383
358
  if (!data) return []
384
359
  let rawList: any[] = []
385
360
  if (Array.isArray(data)) rawList = data
@@ -387,32 +362,199 @@ function parseMaterialList(data: any): MaterialItem[] {
387
362
  else if (Array.isArray(data?.records)) rawList = data.records
388
363
  else if (Array.isArray(data?.data?.list)) rawList = data.data.list
389
364
  else if (Array.isArray(data?.list)) rawList = data.list
390
- else if (Array.isArray(data?.templates)) rawList = data.templates
391
- else if (Array.isArray(data?.data?.templates)) rawList = data.data.templates
392
365
  else if (Array.isArray(data?.data)) {
393
366
  if (data.data.length > 0 && Array.isArray(data.data[0]?.items)) {
394
367
  return data.data.flatMap((cat: any) =>
395
- (cat.items || []).map((item: any) => normalizeMaterialItem(item, cat.name, cat.type))
368
+ (cat.items || []).map((item: any) => normalizeMaterialItem(item, defaultSubType))
396
369
  )
397
370
  }
398
371
  rawList = data.data
399
372
  }
400
- return rawList.map(item => normalizeMaterialItem(item))
373
+ return rawList.map(item => normalizeMaterialItem(item, defaultSubType))
401
374
  }
402
375
 
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
376
+ // ============================================================================
377
+ // 图二:概览页 4 大固定类型(Vectors / Photos / Illustrations / Videos),每个类型独立请求一次接口
378
+ // ============================================================================
379
+ const overviewItemsMap = reactive<Record<StockSubType, MaterialItem[]>>({
380
+ Vectors: [],
381
+ Photos: [],
382
+ Illustrations: [],
383
+ Videos: []
384
+ })
385
+
386
+ const overviewLoadingMap = reactive<Record<StockSubType, boolean>>({
387
+ Vectors: false,
388
+ Photos: false,
389
+ Illustrations: false,
390
+ Videos: false
391
+ })
392
+
393
+ const rowRefs = reactive<Record<string, HTMLElement | null>>({})
394
+ const rowScrollState = reactive<Record<StockSubType, { canLeft: boolean; canRight: boolean }>>({
395
+ Vectors: { canLeft: false, canRight: false },
396
+ Photos: { canLeft: false, canRight: false },
397
+ Illustrations: { canLeft: false, canRight: false },
398
+ Videos: { canLeft: false, canRight: false }
399
+ })
400
+
401
+ function setRowRef(key: StockSubType, el: any) {
402
+ rowRefs[key] = el as HTMLElement | null
403
+ }
404
+
405
+ function updateRowScrollState(key: StockSubType) {
406
+ const el = rowRefs[key]
407
+ if (!el) return
408
+ const canLeft = el.scrollLeft > 4
409
+ const canRight = el.scrollWidth - el.clientWidth - el.scrollLeft > 4
410
+ rowScrollState[key] = { canLeft, canRight }
411
+ }
412
+
413
+ function scrollCategoryRow(key: StockSubType, dir: 'left' | 'right') {
414
+ const el = rowRefs[key]
415
+ if (!el) return
416
+ const delta = dir === 'left' ? -180 : 180
417
+ el.scrollBy({ left: delta, behavior: 'smooth' })
418
+ setTimeout(() => updateRowScrollState(key), 260)
419
+ }
420
+
421
+ // 针对单个 subType 发起独立 API 请求
422
+ async function fetchOverviewCategory(cat: OverviewCategoryConfig, keyword = '') {
423
+ overviewLoadingMap[cat.key] = true
424
+ try {
425
+ const stockUrl = getEffectiveStockUrl()
426
+ if (!stockUrl) {
427
+ overviewItemsMap[cat.key] = []
428
+ return
429
+ }
430
+
431
+ const params = new URLSearchParams()
432
+ params.append('current', '1')
433
+ params.append('size', '12')
434
+ params.append('subType', cat.subType)
435
+ const trimmedKeyword = keyword.trim()
436
+ if (trimmedKeyword) {
437
+ params.append('keyword', trimmedKeyword)
438
+ }
439
+
440
+ const fullUrl = `${stockUrl}${stockUrl.includes('?') ? '&' : '?'}${params.toString()}`
441
+ const data = await safeFetchJson(fullUrl, {
442
+ headers: {
443
+ ...(materialHeaders.value || {})
444
+ }
445
+ })
446
+ if (data && data.code !== undefined && data.code !== 0 && data.code !== 200) {
447
+ overviewItemsMap[cat.key] = []
448
+ return
449
+ }
450
+ const list = parseMaterialList(data, cat.subType)
451
+ overviewItemsMap[cat.key] = list.slice(0, 12)
452
+ } catch (err) {
453
+ overviewItemsMap[cat.key] = []
454
+ } finally {
455
+ overviewLoadingMap[cat.key] = false
456
+ nextTick(() => {
457
+ updateRowScrollState(cat.key)
458
+ setTimeout(() => updateRowScrollState(cat.key), 300)
459
+ })
409
460
  }
461
+ }
462
+
463
+ // 遍历 4 种固定类型,每一个类型单独请求一次接口
464
+ function fetchAllOverviewCategories(keyword = overviewQuery.value) {
465
+ overviewCategories.forEach(cat => {
466
+ fetchOverviewCategory(cat, keyword)
467
+ })
468
+ }
469
+
470
+ // ============================================================================
471
+ // 图三:详情页标签栏滚动控制与双列瀑布流加载(带 subType 请求参数)
472
+ // ============================================================================
473
+ const tagTrackRef = ref<HTMLElement | null>(null)
474
+ const tagScrollState = reactive({
475
+ canLeft: false,
476
+ canRight: false
477
+ })
478
+
479
+ function updateTagScrollState() {
480
+ const el = tagTrackRef.value
481
+ if (!el) return
482
+ tagScrollState.canLeft = el.scrollLeft > 4
483
+ tagScrollState.canRight = el.scrollWidth - el.clientWidth - el.scrollLeft > 4
484
+ }
485
+
486
+ function scrollTagRow(dir: 'left' | 'right') {
487
+ const el = tagTrackRef.value
488
+ if (!el) return
489
+ const delta = dir === 'left' ? -140 : 140
490
+ el.scrollBy({ left: delta, behavior: 'smooth' })
491
+ setTimeout(() => updateTagScrollState(), 250)
492
+ }
493
+
494
+ // 从图二点击「查看全部」进入图三
495
+ async function openCategoryDetail(subType: StockSubType) {
496
+ const cat = overviewCategories.find(c => c.subType === subType)
497
+ filterParams.activeSubType = subType
498
+ const q = overviewQuery.value.trim()
499
+ baseDetailTitle.value = cat ? cat.label : subTypeNameMap[subType]
500
+ baseSearchQuery.value = q
501
+ activeTag.value = ''
502
+ filterParams.query = q
503
+ viewMode.value = 'detail'
504
+ currentPage.value = 1
505
+ pagedMaterials.value = []
506
+ reachEnd.value = false
507
+ loadFailed.value = false
508
+ await nextTick()
509
+ updateTagScrollState()
510
+ await fetchMaterials(1, false)
511
+ }
512
+
513
+ // 图二顶部搜索框回车:按关键词对 4 种固定类型分别重新请求一次接口
514
+ function handleOverviewSearch() {
515
+ fetchAllOverviewCategories(overviewQuery.value)
516
+ }
517
+
518
+ function clearOverviewQuery() {
519
+ overviewQuery.value = ''
520
+ fetchAllOverviewCategories('')
521
+ }
522
+
523
+ // 图三点击左上角返回箭头回到图二
524
+ function backToOverview() {
525
+ viewMode.value = 'overview'
526
+ nextTick(() => {
527
+ overviewCategories.forEach(cat => updateRowScrollState(cat.key))
528
+ })
529
+ }
410
530
 
531
+ // 图三点击细分标签胶囊
532
+ async function selectDetailTag(tag: string) {
533
+ activeTag.value = tag
534
+ filterParams.query = tag ? tag : baseSearchQuery.value
535
+ currentPage.value = 1
536
+ pagedMaterials.value = []
537
+ reachEnd.value = false
538
+ loadFailed.value = false
539
+ await fetchMaterials(1, false)
540
+ }
541
+
542
+ const loading = ref(false)
543
+ const pagedMaterials = ref<MaterialItem[]>([])
544
+ const currentPage = ref(1)
545
+ const pageSize = 24
546
+
547
+ const loadingMore = ref(false)
548
+ const loadFailed = ref(false)
549
+ const loadErrorMsg = ref('')
550
+ const reachEnd = ref(false)
551
+
552
+ // 图三分页加载:始终携带 subType 请求参数
553
+ async function fetchMaterials(page = 1, append = false) {
411
554
  const stockUrl = getEffectiveStockUrl()
412
555
  if (!stockUrl) {
413
556
  if (!append) {
414
557
  pagedMaterials.value = []
415
- totalRecords.value = 0
416
558
  }
417
559
  reachEnd.value = true
418
560
  return
@@ -427,17 +569,13 @@ async function fetchMaterials(page = 1, append = false) {
427
569
  const params = new URLSearchParams()
428
570
  params.append('current', String(page))
429
571
  params.append('size', String(pageSize))
572
+ params.append('subType', filterParams.activeSubType)
430
573
 
431
574
  const q = filterParams.query.trim()
432
575
  if (q) {
433
576
  params.append('keyword', q)
434
577
  }
435
578
 
436
- const subType = tabToSubTypeMap[filterParams.activeType]
437
- if (subType) {
438
- params.append('subType', subType)
439
- }
440
-
441
579
  const fullUrl = `${stockUrl}${stockUrl.includes('?') ? '&' : '?'}${params.toString()}`
442
580
  const data = await safeFetchJson(fullUrl, {
443
581
  headers: {
@@ -445,19 +583,18 @@ async function fetchMaterials(page = 1, append = false) {
445
583
  }
446
584
  })
447
585
 
448
- // 针对接口返回非成功状态(如未登录 code: -2 或业务异常)
449
586
  if (data && data.code !== undefined && data.code !== 0 && data.code !== 200) {
450
587
  if (!append) {
451
588
  pagedMaterials.value = []
452
- totalRecords.value = 0
453
589
  }
454
590
  reachEnd.value = true
455
591
  return
456
592
  }
457
593
 
458
- const newRecords = parseMaterialList(data)
594
+ const newRecords = parseMaterialList(data, filterParams.activeSubType)
459
595
 
460
- if (newRecords.length === 0 || newRecords.length < pageSize) {
596
+ // 如果本次请求返回的数据量不满 size,则证明没有下一页了
597
+ if (newRecords.length < pageSize) {
461
598
  reachEnd.value = true
462
599
  } else {
463
600
  reachEnd.value = false
@@ -472,12 +609,10 @@ async function fetchMaterials(page = 1, append = false) {
472
609
  }
473
610
 
474
611
  currentPage.value = page
475
- totalRecords.value = data?.data?.total ?? data?.total ?? (reachEnd.value ? pagedMaterials.value.length : 9999)
476
612
  } catch (err: any) {
477
613
  console.warn('[Stock Search] 加载公网素材出错:', err)
478
614
  if (!append) {
479
615
  pagedMaterials.value = []
480
- totalRecords.value = 0
481
616
  reachEnd.value = true
482
617
  } else {
483
618
  loadFailed.value = true
@@ -489,57 +624,51 @@ async function fetchMaterials(page = 1, append = false) {
489
624
  }
490
625
  }
491
626
 
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 = ''
627
+ const displayedMaterials = computed<MaterialItem[]>(() => {
628
+ return pagedMaterials.value
629
+ })
499
630
 
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
631
+ // 将瀑布流列表分配到左右两列(保持加载更多时的绝对稳定,不发生列跳动)
632
+ const waterfallColumns = computed<[MaterialItem[], MaterialItem[]]>(() => {
633
+ const left: MaterialItem[] = []
634
+ const right: MaterialItem[] = []
635
+ let leftHeight = 0
636
+ let rightHeight = 0
637
+
638
+ displayedMaterials.value.forEach((item, idx) => {
639
+ let ratio = 0.75
640
+ const w = Number(item.originalWidth || item.width)
641
+ const h = Number(item.originalHeight || item.height)
642
+ if (w > 0 && h > 0) {
643
+ ratio = Math.max(0.45, Math.min(1.8, h / w))
644
+ } else if (idx % 3 === 1) {
645
+ ratio = 1.25
646
+ } else if (idx % 4 === 2) {
647
+ ratio = 0.65
648
+ }
649
+
650
+ if (leftHeight <= rightHeight) {
651
+ left.push(item)
652
+ leftHeight += ratio
506
653
  } else {
507
- pagedMaterials.value = []
508
- totalRecords.value = 0
509
- reachEnd.value = true
654
+ right.push(item)
655
+ rightHeight += ratio
510
656
  }
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
518
- }
519
- }
657
+ })
520
658
 
521
- // 聚合当前素材列表
522
- const rawSourceMaterials = computed<MaterialItem[]>(() => {
523
- return pagedMaterials.value
659
+ return [left, right]
524
660
  })
525
661
 
526
- // 是否还有更多数据可供加载(若已失败或已到底,立即返回 false,防止触底循环)
527
662
  const hasMore = computed(() => {
528
- if (reachEnd.value || loadFailed.value) return false
529
- if (totalRecords.value > 0) {
530
- return pagedMaterials.value.length < totalRecords.value
531
- }
532
- return !reachEnd.value
663
+ return !reachEnd.value && !loadFailed.value
533
664
  })
534
665
 
535
- // 加载更多分页数据
536
666
  async function loadMore() {
537
667
  if (loading.value || loadingMore.value || !hasMore.value || loadFailed.value || reachEnd.value) return
538
668
  const nextPage = currentPage.value + 1
539
669
  await fetchMaterials(nextPage, true)
540
670
  }
541
671
 
542
- // 手动点击重试(重置失败状态并重新触发加载)
543
672
  async function retryLoadMore() {
544
673
  loadFailed.value = false
545
674
  loadErrorMsg.value = ''
@@ -547,500 +676,378 @@ async function retryLoadMore() {
547
676
  await fetchMaterials(targetPage, true)
548
677
  }
549
678
 
550
- // 瀑布流滚动触底自动加载监听(带 250ms 节流与熔断保护)
551
- function handleScroll(e: Event) {
552
- if (loading.value || loadingMore.value || loadFailed.value || reachEnd.value || !hasMore.value) {
679
+ function formatMaterialTitle(title?: string, fallback = '素材', maxLen = 12): string {
680
+ const text = (title || '').trim()
681
+ if (!text) return fallback
682
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text
683
+ }
684
+
685
+ // 点击素材卡片插入画布(区分视频与图片/矢量/插画)
686
+ function handleSelectMaterial(meta: MaterialItem) {
687
+ const isVid = isVideoMaterial(meta)
688
+ const targetUrl = meta.sourceUrl || meta.url || meta.downloadUrl || meta.previewUrl
689
+
690
+ if (!targetUrl) {
691
+ notify('该素材暂无可用的资源地址')
553
692
  return
554
693
  }
555
694
 
556
- if (scrollThrottleTimer) return
557
- scrollThrottleTimer = window.setTimeout(() => {
558
- scrollThrottleTimer = null
559
- }, 250)
695
+ let initialDimensions: { width?: number; height?: number } | undefined = undefined
696
+ const rawW = meta.originalWidth || meta.width
697
+ const rawH = meta.originalHeight || meta.height
698
+ if (rawW && rawH && rawW > 0 && rawH > 0) {
699
+ const maxWidth = 480
700
+ const maxHeight = 360
701
+ const scale = Math.min(maxWidth / rawW, maxHeight / rawH, 1)
702
+ initialDimensions = {
703
+ width: Math.round(rawW * scale),
704
+ height: Math.round(rawH * scale)
705
+ }
706
+ }
560
707
 
561
- const el = e.target as HTMLElement
562
- if (!el) return
563
- if (el.scrollHeight - (el.scrollTop + el.clientHeight) < 140) {
564
- loadMore()
708
+ if (isVid) {
709
+ addElement('video', targetUrl, undefined, undefined, undefined, initialDimensions)
710
+ notify(`已成功添加视频 "${formatMaterialTitle(meta.title, '高清视频', 12)}" 到画布`)
711
+ } else {
712
+ addElement('image', targetUrl, undefined, undefined, undefined, initialDimensions)
713
+ notify(`已成功添加素材 "${formatMaterialTitle(meta.title, '图片', 12)}" 到画布`)
565
714
  }
566
715
  }
567
716
 
568
- // 根据红框筛选条件过滤后的瀑布流素材列表(红框4)
569
- const displayedMaterials = computed<MaterialItem[]>(() => {
570
- return rawSourceMaterials.value.filter(item => {
571
- // 1. 类型过滤 (红框2)
572
- if (filterParams.activeType !== 'all') {
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
- }
582
- }
583
- // 2. 格式过滤 (红框3)
584
- if (filterParams.format) {
585
- const fmt = getItemFormat(item)
586
- if (fmt.toUpperCase() !== filterParams.format.toUpperCase()) return false
587
- }
588
- // 3. 颜色过滤 (红框3)
589
- if (filterParams.color) {
590
- const c = (item.color || item.primaryColor || '').toLowerCase()
591
- if (!c || c !== filterParams.color.toLowerCase()) return false
592
- }
593
- return true
594
- })
595
- })
717
+ function handleCloseDrawer() {
718
+ activeTab.value = null
719
+ }
596
720
 
597
- // 筛选标签计算
598
- const currentSortLabel = computed(() => {
599
- const match = sortOptions.find(o => o.value === filterParams.sort)
600
- return match ? match.label : '综合排序'
721
+ onMounted(() => {
722
+ fetchAllOverviewCategories()
723
+ window.addEventListener('keydown', handleKeydown)
601
724
  })
602
725
 
603
- const currentColorLabel = computed(() => {
604
- if (!filterParams.color) return '颜色'
605
- const match = colorOptions.find(c => c.hex.toLowerCase() === filterParams.color.toLowerCase())
606
- return match ? match.name : '已选色'
726
+ onUnmounted(() => {
727
+ window.removeEventListener('keydown', handleKeydown)
607
728
  })
608
729
 
609
- const hasActiveFilters = computed(() => {
610
- return !!filterParams.format || !!filterParams.color || filterParams.sort !== 'recommend'
730
+ watch([() => materialApiUrl?.value, () => materialPageApiUrl?.value, () => materialTreeApiUrl?.value], () => {
731
+ fetchAllOverviewCategories()
732
+ if (viewMode.value === 'detail') {
733
+ fetchMaterials(1, false)
734
+ }
611
735
  })
736
+ </script>
612
737
 
613
- // 切换分类 Tab(红框2)
614
- async function switchCategoryTab(typeValue: string) {
615
- filterParams.activeType = typeValue
616
- currentPage.value = 1
617
- pagedMaterials.value = []
618
- reachEnd.value = false
619
- loadFailed.value = false
620
- await fetchMaterials(1, false)
621
- }
738
+ <template>
739
+ <div class="material-drawer-container">
740
+ <!-- 顶部抽屉标题栏:图二显示「素材库」,图三显示「← 分类/搜索词」 -->
741
+ <div class="material-drawer-header">
742
+ <div v-if="viewMode === 'overview'" class="drawer-header-left">
743
+ <span class="drawer-header-title">素材库</span>
744
+ </div>
745
+ <button v-else type="button" class="drawer-back-btn" @click="backToOverview">
746
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
747
+ stroke-linecap="round" stroke-linejoin="round">
748
+ <line x1="19" y1="12" x2="5" y2="12" />
749
+ <polyline points="12 19 5 12 12 5" />
750
+ </svg>
751
+ <span class="drawer-header-title">{{ detailHeaderTitle }}</span>
752
+ </button>
753
+
754
+ <button type="button" class="drawer-close-btn" title="关闭面板" @click="handleCloseDrawer">
755
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
756
+ stroke-linecap="round" stroke-linejoin="round">
757
+ <line x1="18" y1="6" x2="6" y2="18" />
758
+ <line x1="6" y1="6" x2="18" y2="18" />
759
+ </svg>
760
+ </button>
761
+ </div>
622
762
 
623
- // 搜索处理(红框1)
624
- async function handleSearch() {
625
- currentPage.value = 1
626
- pagedMaterials.value = []
627
- reachEnd.value = false
628
- loadFailed.value = false
629
- await fetchMaterials(1, false)
630
- }
763
+ <!-- ==================== 图二:素材库分类概览视图 ==================== -->
764
+ <div v-if="viewMode === 'overview'" class="material-overview-body">
765
+ <!-- 搜索框 -->
766
+ <div class="overview-search-wrap">
767
+ <div class="overview-search-box">
768
+ <el-icon class="overview-search-icon" @click="handleOverviewSearch">
769
+ <Search />
770
+ </el-icon>
771
+ <input
772
+ v-model="overviewQuery"
773
+ type="text"
774
+ placeholder="搜索"
775
+ class="overview-search-input"
776
+ @keyup.enter="handleOverviewSearch"
777
+ />
778
+ <el-icon
779
+ v-if="overviewQuery"
780
+ class="overview-search-clear"
781
+ @click="clearOverviewQuery"
782
+ >
783
+ <CircleCloseFilled />
784
+ </el-icon>
785
+ </div>
786
+ </div>
631
787
 
632
- function clearQuery() {
633
- filterParams.query = ''
634
- handleSearch()
635
- }
788
+ <!-- 4大固定分类横向预览列表(Vectors / Photos / Illustrations / Videos) -->
789
+ <div class="overview-sections-scroll">
790
+ <div
791
+ v-for="cat in overviewCategories"
792
+ :key="cat.key"
793
+ class="overview-category-section"
794
+ >
795
+ <div class="section-header-row">
796
+ <span class="section-cat-title">{{ cat.label }}</span>
797
+ <button type="button" class="section-view-all-btn" @click="openCategoryDetail(cat.subType)">
798
+ 查看全部
799
+ </button>
800
+ </div>
636
801
 
637
- function handleSortChange(val: string) {
638
- filterParams.sort = val
639
- }
802
+ <div class="section-carousel-container">
803
+ <div
804
+ :ref="el => setRowRef(cat.key, el)"
805
+ class="section-carousel-track"
806
+ @scroll="updateRowScrollState(cat.key)"
807
+ >
808
+ <!-- 加载占位骨架 -->
809
+ <template v-if="overviewLoadingMap[cat.key] && (!overviewItemsMap[cat.key] || overviewItemsMap[cat.key].length === 0)">
810
+ <div v-for="n in 3" :key="n" class="overview-skeleton-card"></div>
811
+ </template>
640
812
 
641
- function handleFormatChange(val: string) {
642
- filterParams.format = val
643
- }
813
+ <!-- 横向缩略图卡片 -->
814
+ <template v-else-if="overviewItemsMap[cat.key] && overviewItemsMap[cat.key].length > 0">
815
+ <div
816
+ v-for="(item, idx) in overviewItemsMap[cat.key]"
817
+ :key="item.id || idx"
818
+ class="overview-thumb-card"
819
+ @click="handleSelectMaterial(item)"
820
+ >
821
+ <video
822
+ v-if="isVideoUrl(getMaterialThumb(item))"
823
+ :src="getMaterialThumb(item)"
824
+ muted
825
+ preload="metadata"
826
+ playsinline
827
+ class="overview-thumb-img"
828
+ @loadeddata="updateRowScrollState(cat.key)"
829
+ @mouseenter="($event.target as HTMLVideoElement)?.play?.()"
830
+ @mouseleave="($event.target as HTMLVideoElement)?.pause?.()"
831
+ />
832
+ <img
833
+ v-else-if="getMaterialThumb(item)"
834
+ :src="getMaterialThumb(item)"
835
+ :alt="item.title || cat.label"
836
+ loading="lazy"
837
+ class="overview-thumb-img"
838
+ @load="updateRowScrollState(cat.key)"
839
+ />
840
+ <span v-if="cat.subType === 'Videos' || isVideoMaterial(item)" class="video-badge-icon">
841
+ <el-icon><VideoPlay /></el-icon>
842
+ </span>
843
+ </div>
844
+ </template>
644
845
 
645
- function handleColorChange(val: string) {
646
- filterParams.color = val
647
- }
648
-
649
- function resetFilters() {
650
- filterParams.sort = 'recommend'
651
- filterParams.format = ''
652
- filterParams.color = ''
653
- }
654
-
655
- function openItemMenu(item: MaterialItem) {
656
- notify(`素材: ${item.title || '无标题'} (${getItemFormat(item)})`)
657
- }
658
-
659
- // 点击使用/插入素材至画布
660
- function handleSelectMaterial(meta: MaterialItem) {
661
- if (meta.type === 'slide' || meta.slide) {
662
- handleInsertSlide(meta)
663
- } else {
664
- // 优先采用高清 sourceUrl,回退到 url / previewUrl / downloadUrl
665
- const imgUrl = meta.sourceUrl || meta.url || meta.previewUrl || meta.downloadUrl
666
- if (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)
681
- notify(`已成功添加素材 "${meta.title || '图片'}" 到画布`)
682
- } else {
683
- notify('该素材暂无可用的图片地址')
684
- }
685
- }
686
- }
687
-
688
- // 异步加载单页物理 JSON 并执行插入
689
- async function handleInsertSlide(meta: MaterialItem) {
690
- try {
691
- notify(`正在读取页面素材 [${meta.title || '单页模板'}]...`)
692
- const fallbackSize = (typeof meta.originalWidth === 'number' && typeof meta.originalHeight === 'number')
693
- ? { width: meta.originalWidth, height: meta.originalHeight }
694
- : undefined
695
- const inlineSize = meta.originalSize || fallbackSize
696
- if (meta.slide) {
697
- insertExternalSlide(meta.slide, inlineSize)
698
- return
699
- }
700
-
701
- let detailUrl = meta.url || meta.downloadUrl || ''
702
- if (!detailUrl && meta.fileName) {
703
- const baseUrl = (materialApiUrl.value && materialApiUrl.value.trim()) || DEFAULT_MATERIAL_API_URL
704
- if (baseUrl.includes('/') && baseUrl.endsWith('.json')) {
705
- const dir = baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)
706
- detailUrl = `${dir}slides/${meta.fileName}`
707
- } else {
708
- detailUrl = `/templates/slides/${meta.fileName}`
709
- }
710
- }
711
-
712
- if (!detailUrl) {
713
- throw new Error('未找到单页素材地址')
714
- }
715
-
716
- const data = await safeFetchJson(detailUrl, {
717
- headers: {
718
- ...(materialHeaders.value || {})
719
- }
720
- })
721
- const slideObj = data.slide || data.data?.slide || (data.elements ? data : null)
722
- const originalSize = data.originalSize || data.data?.originalSize || fallbackSize
723
-
724
- if (!slideObj) {
725
- throw new Error('单页数据格式异常')
726
- }
727
-
728
- insertExternalSlide(slideObj, originalSize)
729
- notify(`已成功插入单页模板 [${meta.title || '素材'}]`)
730
- } catch (err: any) {
731
- console.error('加载单页模板失败:', err)
732
- notify(err.message || '加载单页模板失败')
733
- }
734
- }
735
-
736
- onMounted(() => {
737
- fetchMaterials(1, false)
738
-
739
- // 监听容器大小以实现极致自适应
740
- if (containerRef.value) {
741
- resizeObserver = new ResizeObserver((entries) => {
742
- for (const entry of entries) {
743
- isNarrow.value = entry.contentRect.width < 520
744
- }
745
- })
746
- resizeObserver.observe(containerRef.value)
747
- }
748
-
749
- // 键盘快捷键监听
750
- window.addEventListener('keydown', handleKeydown)
751
- })
752
-
753
- onUnmounted(() => {
754
- if (resizeObserver) {
755
- resizeObserver.disconnect()
756
- resizeObserver = null
757
- }
758
- window.removeEventListener('keydown', handleKeydown)
759
- })
846
+ <!-- 空状态兜底 -->
847
+ <div v-else class="overview-empty-row" @click="openCategoryDetail(cat.subType)">
848
+ <span>暂无{{ cat.label }}数据</span>
849
+ </div>
850
+ </div>
760
851
 
761
- watch([() => materialApiUrl?.value, () => materialPageApiUrl?.value, () => materialTreeApiUrl?.value], () => {
762
- fetchMaterials(1, false)
763
- })
764
- </script>
852
+ <!-- 左侧渐变与左滑箭头 -->
853
+ <div
854
+ v-if="rowScrollState[cat.key]?.canLeft"
855
+ class="carousel-edge-mask left-mask"
856
+ @click.stop="scrollCategoryRow(cat.key, 'left')"
857
+ >
858
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"
859
+ stroke-linecap="round" stroke-linejoin="round">
860
+ <polyline points="15 18 9 12 15 6" />
861
+ </svg>
862
+ </div>
765
863
 
766
- <template>
767
- <div
768
- ref="containerRef"
769
- class="template-gallery-container"
770
- :class="{ 'is-narrow-view': isNarrow }"
771
- >
772
- <!-- 1. 顶部现代化搜索栏(红框1) -->
773
- <div class="gallery-top-search">
774
- <div class="search-input-capsule">
775
- <!-- 搜索输入框 -->
776
- <input
777
- v-model="filterParams.query"
778
- type="text"
779
- :placeholder="isNarrow ? '搜索素材、关键词...' : '搜索素材标题、关键词,按回车检索...'"
780
- class="native-search-input"
781
- @keyup.enter="handleSearch"
782
- />
783
-
784
- <!-- 清空图标 -->
785
- <el-icon
786
- v-if="filterParams.query"
787
- class="search-clear-icon"
788
- @click="clearQuery"
789
- >
790
- <CircleCloseFilled />
791
- </el-icon>
792
-
793
- <!-- 搜索胶囊按钮(窄屏下仅显示放大镜图标,腾出输入空间) -->
794
- <button
795
- type="button"
796
- class="capsule-search-btn"
797
- @click="handleSearch"
798
- :disabled="loading"
799
- :title="isNarrow ? '搜索素材' : ''"
800
- >
801
- <el-icon v-if="!loading"><Search /></el-icon>
802
- <span v-else class="btn-spinner-ring"></span>
803
- <span class="btn-text">搜索</span>
804
- </button>
864
+ <!-- 右侧渐变与右滑箭头 -->
865
+ <div
866
+ v-if="rowScrollState[cat.key]?.canRight"
867
+ class="carousel-edge-mask right-mask"
868
+ @click.stop="scrollCategoryRow(cat.key, 'right')"
869
+ >
870
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"
871
+ stroke-linecap="round" stroke-linejoin="round">
872
+ <polyline points="9 18 15 12 9 6" />
873
+ </svg>
874
+ </div>
875
+ </div>
876
+ </div>
805
877
  </div>
806
878
  </div>
807
879
 
808
- <!-- 2. 分类 Tabs 与多维筛选工具栏(红框2 + 红框3) -->
809
- <div class="gallery-toolbar-row">
810
- <!-- 红框2:分类类型胶囊组(支持横向触摸/滚轮滑动,不换行截断) -->
811
- <div class="category-pill-group">
812
- <button
813
- v-for="tab in categoryTabs"
814
- :key="tab.value"
815
- class="pill-tab-btn"
816
- :class="{ active: filterParams.activeType === tab.value }"
817
- @click="switchCategoryTab(tab.value)"
880
+ <!-- ==================== 图三:分类/搜索详情瀑布流视图 ==================== -->
881
+ <div v-else class="material-detail-body">
882
+ <!-- 顶部横向细分标签栏 -->
883
+ <div class="detail-tags-bar-wrap">
884
+ <div
885
+ ref="tagTrackRef"
886
+ class="detail-tags-track"
887
+ @scroll="updateTagScrollState"
818
888
  >
819
- {{ tab.label }}
820
- </button>
821
- </div>
889
+ <button
890
+ type="button"
891
+ class="detail-tag-pill"
892
+ :class="{ active: activeTag === '' }"
893
+ @click="selectDetailTag('')"
894
+ >
895
+ 全部
896
+ </button>
897
+ <button
898
+ v-for="tag in currentCategoryTags"
899
+ :key="tag"
900
+ type="button"
901
+ class="detail-tag-pill"
902
+ :class="{ active: activeTag === tag }"
903
+ @click="selectDetailTag(tag)"
904
+ >
905
+ {{ tag }}
906
+ </button>
907
+ </div>
822
908
 
823
- <!-- 红框3:右侧筛选下拉项 -->
824
- <div class="filter-dropdown-group">
825
- <!-- 综合排序 -->
826
- <el-dropdown trigger="click" @command="handleSortChange">
827
- <span class="toolbar-dropdown-btn" :class="{ 'is-active': filterParams.sort !== 'recommend' }">
828
- {{ currentSortLabel }}
829
- <el-icon class="dropdown-arrow"><ArrowDown /></el-icon>
830
- </span>
831
- <template #dropdown>
832
- <el-dropdown-menu>
833
- <el-dropdown-item
834
- v-for="item in sortOptions"
835
- :key="item.value"
836
- :command="item.value"
837
- :class="{ 'is-selected': filterParams.sort === item.value }"
838
- >
839
- {{ item.label }}
840
- </el-dropdown-item>
841
- </el-dropdown-menu>
842
- </template>
843
- </el-dropdown>
844
-
845
- <!-- 格式筛选 -->
846
- <el-dropdown trigger="click" @command="handleFormatChange">
847
- <span class="toolbar-dropdown-btn" :class="{ 'is-active': !!filterParams.format }">
848
- {{ filterParams.format ? `格式: ${filterParams.format}` : '格式' }}
849
- <el-icon class="dropdown-arrow"><ArrowDown /></el-icon>
850
- </span>
851
- <template #dropdown>
852
- <el-dropdown-menu class="custom-dropdown-menu">
853
- <el-dropdown-item command="" :class="{ 'is-selected': !filterParams.format }">
854
- 全部格式
855
- </el-dropdown-item>
856
- <el-dropdown-item
857
- v-for="fmt in formatOptions"
858
- :key="fmt"
859
- :command="fmt"
860
- :class="{ 'is-selected': filterParams.format === fmt }"
861
- >
862
- {{ fmt }}
863
- </el-dropdown-item>
864
- </el-dropdown-menu>
865
- </template>
866
- </el-dropdown>
867
-
868
- <!-- 颜色筛选 -->
869
- <el-dropdown trigger="click" @command="handleColorChange">
870
- <span class="toolbar-dropdown-btn" :class="{ 'is-active': !!filterParams.color }">
871
- <span v-if="filterParams.color" class="color-badge-dot" :style="{ backgroundColor: filterParams.color }"></span>
872
- {{ currentColorLabel }}
873
- <el-icon class="dropdown-arrow"><ArrowDown /></el-icon>
874
- </span>
875
- <template #dropdown>
876
- <div class="color-picker-palette">
877
- <div class="palette-header-label">按主色调筛选</div>
878
- <div class="palette-swatch-grid">
879
- <div
880
- class="swatch-item all-swatch"
881
- :class="{ active: !filterParams.color }"
882
- @click="handleColorChange('')"
883
- title="全部颜色"
884
- >
885
- 全部
886
- </div>
887
- <div
888
- v-for="color in colorOptions"
889
- :key="color.hex"
890
- class="swatch-item"
891
- :class="{ active: filterParams.color === color.hex }"
892
- :style="{ backgroundColor: color.hex }"
893
- :title="color.name"
894
- @click="handleColorChange(color.hex)"
895
- ></div>
896
- </div>
897
- </div>
898
- </template>
899
- </el-dropdown>
900
-
901
- <!-- 一键重置 -->
902
- <button
903
- v-if="hasActiveFilters"
904
- class="reset-filters-link"
905
- @click="resetFilters"
906
- title="清除所有筛选"
909
+ <!-- 标签栏左侧滚动箭头 -->
910
+ <div
911
+ v-if="tagScrollState.canLeft"
912
+ class="tags-edge-arrow left-arrow"
913
+ @click.stop="scrollTagRow('left')"
914
+ >
915
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"
916
+ stroke-linecap="round" stroke-linejoin="round">
917
+ <polyline points="15 18 9 12 15 6" />
918
+ </svg>
919
+ </div>
920
+
921
+ <!-- 标签栏右侧滚动箭头 -->
922
+ <div
923
+ v-if="tagScrollState.canRight"
924
+ class="tags-edge-arrow right-arrow"
925
+ @click.stop="scrollTagRow('right')"
907
926
  >
908
- 重置
909
- </button>
927
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"
928
+ stroke-linecap="round" stroke-linejoin="round">
929
+ <polyline points="9 18 15 12 9 6" />
930
+ </svg>
931
+ </div>
910
932
  </div>
911
- </div>
912
933
 
913
- <!-- 加载中动画 -->
914
- <div v-if="loading && displayedMaterials.length === 0" class="gallery-loading-box">
915
- <div class="loading-ring-spinner"></div>
916
- <span class="loading-tip-text">正在检索素材媒体库...</span>
917
- </div>
934
+ <!-- 加载中状态 -->
935
+ <div v-if="loading && displayedMaterials.length === 0" class="gallery-loading-box">
936
+ <div class="loading-ring-spinner"></div>
937
+ <span class="loading-tip-text">正在加载{{ subTypeNameMap[filterParams.activeSubType] }}...</span>
938
+ </div>
918
939
 
919
- <!-- 3. 主体内容区:纯瀑布流排版(红框4),支持滚动触底自动加载 -->
920
- <div v-else class="waterfall-scroll-container" @scroll="handleScroll">
921
- <div v-if="displayedMaterials.length > 0" class="waterfall-masonry-wrapper">
922
- <div class="waterfall-masonry-layout">
923
- <div
924
- v-for="(item, idx) in displayedMaterials"
925
- :key="item.id || idx"
926
- class="waterfall-card-item"
927
- @click="handleSelectMaterial(item)"
928
- >
929
- <div class="card-box">
930
- <!-- 图片视口 -->
931
- <div class="card-media-viewport">
932
- <img
933
- v-if="getMaterialThumb(item)"
934
- :src="getMaterialThumb(item)"
935
- :alt="item.title || '素材'"
936
- loading="lazy"
937
- class="card-thumb-image"
940
+ <!-- 双列瀑布流列表 -->
941
+ <div v-else class="detail-waterfall-scroll">
942
+ <div v-if="displayedMaterials.length > 0" class="detail-masonry-wrapper">
943
+ <div class="detail-masonry-columns">
944
+ <div
945
+ v-for="(colItems, colIdx) in waterfallColumns"
946
+ :key="colIdx"
947
+ class="detail-masonry-col"
948
+ >
949
+ <div
950
+ v-for="(item, idx) in colItems"
951
+ :key="item.id || idx"
952
+ class="detail-masonry-card"
953
+ @click="handleSelectMaterial(item)"
954
+ >
955
+ <video
956
+ v-if="isVideoUrl(getMaterialThumb(item))"
957
+ :src="getMaterialThumb(item)"
958
+ muted
959
+ preload="metadata"
960
+ playsinline
961
+ class="detail-card-img"
962
+ @mouseenter="($event.target as HTMLVideoElement)?.play?.()"
963
+ @mouseleave="($event.target as HTMLVideoElement)?.pause?.()"
938
964
  />
939
- <div v-else class="card-empty-placeholder">
940
- <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#94a3b8" stroke-width="1.5">
965
+ <img
966
+ v-else-if="getMaterialThumb(item)"
967
+ :src="getMaterialThumb(item)"
968
+ :alt="item.title || '素材'"
969
+ loading="lazy"
970
+ class="detail-card-img"
971
+ />
972
+ <div v-else class="detail-card-placeholder">
973
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#94a3b8" stroke-width="1.5">
941
974
  <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
942
975
  <circle cx="8.5" cy="8.5" r="1.5" />
943
976
  <polyline points="21 15 16 10 5 21" />
944
977
  </svg>
945
978
  </div>
946
979
 
947
- <!-- hover 悬浮层:中央大加号插入 + 右上角大图预览 -->
948
- <div class="card-hover-mask">
949
- <button
950
- v-if="getMaterialThumb(item)"
951
- type="button"
952
- class="preview-float-btn"
953
- title="大图预览"
954
- @click.stop.prevent="openPreview(item, $event)"
955
- >
956
- <el-icon><View /></el-icon>
957
- </button>
958
- <div class="insert-float-action">
959
- <div class="insert-circle-btn">
960
- <el-icon><Plus /></el-icon>
961
- </div>
962
- <span class="insert-tip">点击插入画布</span>
963
- </div>
964
- </div>
965
- </div>
980
+ <span v-if="filterParams.activeSubType === 'Videos' || isVideoMaterial(item)" class="video-badge-icon">
981
+ <el-icon><VideoPlay /></el-icon>
982
+ </span>
966
983
 
967
- <!-- 卡片底部信息条 -->
968
- <div class="card-bottom-info">
969
- <div class="format-badge-box">
970
- <span class="format-tag">{{ getItemFormat(item) }}</span>
971
- <span v-if="item.sourcePlatform" class="platform-tag">{{ item.sourcePlatform }}</span>
972
- <span v-if="item.title" class="format-title-hint" :title="item.title">{{ item.title }}</span>
973
- </div>
974
- <button type="button" class="more-options-dot-btn" @click.stop="openItemMenu(item)">
975
- <el-icon><MoreFilled /></el-icon>
984
+ <!-- 悬浮预览小按钮 -->
985
+ <button
986
+ v-if="getMaterialThumb(item)"
987
+ type="button"
988
+ class="detail-card-preview-btn"
989
+ title="大图预览"
990
+ @click.stop.prevent="openPreview(item, $event)"
991
+ >
992
+ <el-icon><View /></el-icon>
976
993
  </button>
977
994
  </div>
978
995
  </div>
979
996
  </div>
980
- </div>
981
-
982
- <!-- 瀑布流底部加载更多与完成状态 -->
983
- <div class="waterfall-footer-status">
984
- <!-- 1. 正在加载中 -->
985
- <div v-if="loadingMore" class="loading-more-row">
986
- <span class="btn-spinner-ring dark"></span>
987
- <span>正在加载更多素材...</span>
988
- </div>
989
997
 
990
- <!-- 2. 加载失败:展示重试按钮,彻底阻断滚动死循环 -->
991
- <div v-else-if="loadFailed" class="load-failed-row">
992
- <span class="load-error-text">加载失败: {{ loadErrorMsg || '网络连接或接口异常' }}</span>
993
- <button type="button" class="retry-load-btn" @click="retryLoadMore">
994
- 点击重试
998
+ <!-- 瀑布流底部状态 -->
999
+ <div class="waterfall-footer-status">
1000
+ <div v-if="loadingMore" class="loading-more-row">
1001
+ <span class="btn-spinner-ring dark"></span>
1002
+ <span>正在加载更多...</span>
1003
+ </div>
1004
+ <div v-else-if="loadFailed" class="load-failed-row">
1005
+ <span class="load-error-text">加载失败</span>
1006
+ <button type="button" class="retry-load-btn" @click="retryLoadMore">
1007
+ 重试
1008
+ </button>
1009
+ </div>
1010
+ <button
1011
+ v-else-if="hasMore"
1012
+ type="button"
1013
+ class="load-more-action-btn"
1014
+ @click="loadMore"
1015
+ >
1016
+ 加载更多
995
1017
  </button>
996
- </div>
997
-
998
- <!-- 3. 还有更多数据可供加载 -->
999
- <button
1000
- v-else-if="hasMore"
1001
- type="button"
1002
- class="load-more-action-btn"
1003
- @click="loadMore"
1004
- >
1005
- <span>加载更多素材</span>
1006
- <span v-if="totalRecords > 0" class="load-more-count">({{ displayedMaterials.length }} / {{ totalRecords }})</span>
1007
- </button>
1008
-
1009
- <!-- 4. 已全部加载完毕 -->
1010
- <div v-else class="all-loaded-divider">
1011
- <span>— 已展示全部素材 (共 {{ displayedMaterials.length }} 项) —</span>
1018
+ <div v-else class="all-loaded-divider">
1019
+ <span>— 已加载全部 —</span>
1020
+ </div>
1012
1021
  </div>
1013
1022
  </div>
1014
- </div>
1015
1023
 
1016
- <!-- 空状态 -->
1017
- <div v-else class="gallery-empty-state">
1018
- <svg viewBox="0 0 24 24" width="44" height="44" fill="none" stroke="#cbd5e1" stroke-width="1.2">
1019
- <circle cx="11" cy="11" r="8" />
1020
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
1021
- </svg>
1022
- <p class="empty-title">{{ filterParams.query ? `未检索到与 "${filterParams.query}" 相关的素材` : '当前分类暂无素材推荐' }}</p>
1023
- <span class="empty-subtitle">尝试切换上方「全部/图片」分类或调整筛选条件</span>
1024
+ <!-- 空状态 -->
1025
+ <div v-else class="gallery-empty-state">
1026
+ <svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="#cbd5e1" stroke-width="1.2">
1027
+ <circle cx="11" cy="11" r="8" />
1028
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
1029
+ </svg>
1030
+ <p class="empty-title">{{ filterParams.query ? `未找到与 "${filterParams.query}" 相关的素材` : '暂无相关素材' }}</p>
1031
+ </div>
1024
1032
  </div>
1025
1033
  </div>
1026
1034
 
1027
- <!-- 100% 自研可靠的高清全屏大图预览 Modal(Teleport 挂载到 body 顶层) -->
1035
+ <!-- 全屏大图/视频预览 Modal -->
1028
1036
  <teleport to="body">
1029
1037
  <transition name="preview-backdrop-fade">
1030
- <div
1031
- v-if="previewVisible"
1038
+ <div
1039
+ v-if="previewVisible"
1032
1040
  class="custom-fullscreen-preview-modal"
1033
1041
  @click.self="closePreview"
1034
1042
  >
1035
- <!-- 顶部浮动信息条 -->
1036
1043
  <div class="preview-header-bar">
1037
1044
  <div class="preview-title-wrap">
1038
1045
  <span class="preview-format-pill">{{ currentPreviewItem?.format || 'IMG' }}</span>
1039
1046
  <span class="preview-title-text">{{ currentPreviewItem?.title || '素材大图预览' }}</span>
1040
1047
  </div>
1041
- <button
1042
- type="button"
1043
- class="preview-close-cross-btn"
1048
+ <button
1049
+ type="button"
1050
+ class="preview-close-cross-btn"
1044
1051
  title="关闭预览 (Esc)"
1045
1052
  @click="closePreview"
1046
1053
  >
@@ -1048,45 +1055,49 @@ watch([() => materialApiUrl?.value, () => materialPageApiUrl?.value, () => mater
1048
1055
  </button>
1049
1056
  </div>
1050
1057
 
1051
- <!-- 左侧上一张 -->
1052
- <button
1053
- v-if="previewList.length > 1"
1054
- type="button"
1055
- class="preview-arrow-btn prev-arrow"
1058
+ <button
1059
+ v-if="previewList.length > 1"
1060
+ type="button"
1061
+ class="preview-arrow-btn prev-arrow"
1056
1062
  title="上一张 (←)"
1057
1063
  @click.stop="prevPreview"
1058
1064
  >
1059
1065
  <el-icon><ArrowLeft /></el-icon>
1060
1066
  </button>
1061
1067
 
1062
- <!-- 右侧下一张 -->
1063
- <button
1064
- v-if="previewList.length > 1"
1065
- type="button"
1066
- class="preview-arrow-btn next-arrow"
1068
+ <button
1069
+ v-if="previewList.length > 1"
1070
+ type="button"
1071
+ class="preview-arrow-btn next-arrow"
1067
1072
  title="下一张 (→)"
1068
1073
  @click.stop="nextPreview"
1069
1074
  >
1070
1075
  <el-icon><ArrowRight /></el-icon>
1071
1076
  </button>
1072
1077
 
1073
- <!-- 中央超清大图视口 -->
1074
1078
  <div class="preview-stage-viewport" @click.self="closePreview">
1075
- <img
1076
- v-if="currentPreviewItem?.url"
1077
- :src="currentPreviewItem.url"
1078
- :alt="currentPreviewItem.title"
1079
+ <video
1080
+ v-if="currentPreviewItem?.isVideo && currentPreviewItem?.url"
1081
+ :src="currentPreviewItem.url"
1082
+ controls
1083
+ autoplay
1084
+ playsinline
1085
+ class="preview-stage-img"
1086
+ />
1087
+ <img
1088
+ v-else-if="currentPreviewItem?.url"
1089
+ :src="currentPreviewItem.url"
1090
+ :alt="currentPreviewItem.title"
1079
1091
  class="preview-stage-img"
1080
1092
  />
1081
1093
  </div>
1082
1094
 
1083
- <!-- 底部工具动作条 -->
1084
1095
  <div class="preview-footer-bar">
1085
1096
  <span v-if="previewList.length > 1" class="preview-page-indicator">
1086
1097
  {{ previewInitialIndex + 1 }} / {{ previewList.length }}
1087
1098
  </span>
1088
- <button
1089
- type="button"
1099
+ <button
1100
+ type="button"
1090
1101
  class="preview-quick-insert-btn"
1091
1102
  @click="handleInsertFromPreview"
1092
1103
  >
@@ -1101,746 +1112,609 @@ watch([() => materialApiUrl?.value, () => materialPageApiUrl?.value, () => mater
1101
1112
  </template>
1102
1113
 
1103
1114
  <style scoped lang="scss">
1104
- .template-gallery-container {
1115
+ .material-drawer-container {
1105
1116
  display: flex;
1106
1117
  flex-direction: column;
1118
+ width: 288px;
1119
+ max-width: 100%;
1107
1120
  height: 100%;
1108
- padding: 14px 16px 16px;
1121
+ background: #ffffff;
1109
1122
  box-sizing: border-box;
1110
- background: var(--bg-surface, #ffffff);
1111
- gap: 12px;
1112
1123
  overflow: hidden;
1124
+ user-select: none;
1113
1125
  }
1114
1126
 
1115
- /* 1. 顶部现代搜索栏(红框1) */
1116
- .gallery-top-search {
1117
- width: 100%;
1118
- }
1119
-
1120
- .search-input-capsule {
1127
+ /* 顶部栏(图二:素材库 / 图三:← 分类名) */
1128
+ .material-drawer-header {
1121
1129
  display: flex;
1122
1130
  align-items: center;
1123
- width: 100%;
1124
- height: 42px;
1125
- background: #f4f6f8;
1126
- border: 1.5px solid transparent;
1127
- border-radius: 21px;
1128
- padding: 3px 4px 3px 14px;
1131
+ justify-content: space-between;
1132
+ height: 48px;
1133
+ padding: 0 16px;
1134
+ border-bottom: 1px solid #f1f5f9;
1135
+ flex-shrink: 0;
1129
1136
  box-sizing: border-box;
1130
- transition: all 0.25s ease;
1131
1137
  }
1132
1138
 
1133
- .search-input-capsule:focus-within {
1134
- background: #ffffff;
1135
- border-color: var(--el-color-primary, #ff5000);
1136
- box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
1139
+ .drawer-header-left {
1140
+ display: flex;
1141
+ align-items: center;
1142
+ min-width: 0;
1137
1143
  }
1138
1144
 
1145
+ .drawer-header-title {
1146
+ font-size: 14px;
1147
+ font-weight: 600;
1148
+ color: #1e293b;
1149
+ white-space: nowrap;
1150
+ overflow: hidden;
1151
+ text-overflow: ellipsis;
1152
+ }
1139
1153
 
1140
- .native-search-input {
1141
- flex: 1;
1154
+ .drawer-back-btn {
1155
+ display: inline-flex;
1156
+ align-items: center;
1157
+ gap: 6px;
1142
1158
  border: none;
1143
- outline: none;
1144
1159
  background: transparent;
1145
- font-size: 13.5px;
1160
+ padding: 4px 0;
1146
1161
  color: #1e293b;
1147
- min-width: 0;
1148
- }
1149
-
1150
- .native-search-input::placeholder {
1151
- color: #94a3b8;
1152
- }
1153
-
1154
- .search-clear-icon {
1155
- font-size: 16px;
1156
- color: #94a3b8;
1157
1162
  cursor: pointer;
1158
- margin-right: 8px;
1159
- transition: color 0.2s;
1160
- flex-shrink: 0;
1161
- }
1163
+ min-width: 0;
1164
+ transition: color 0.15s ease;
1162
1165
 
1163
- .search-clear-icon:hover {
1164
- color: #64748b;
1166
+ &:hover {
1167
+ color: #1e6fff;
1168
+ }
1165
1169
  }
1166
1170
 
1167
- .capsule-search-btn {
1168
- display: inline-flex;
1171
+ .drawer-close-btn {
1172
+ display: flex;
1169
1173
  align-items: center;
1170
- gap: 5px;
1171
- height: 34px;
1172
- padding: 0 16px;
1173
- background: #1e293b;
1174
- color: #ffffff;
1174
+ justify-content: center;
1175
+ width: 28px;
1176
+ height: 28px;
1175
1177
  border: none;
1176
- border-radius: 17px;
1177
- font-size: 13px;
1178
- font-weight: 500;
1178
+ background: transparent;
1179
+ color: #475569;
1179
1180
  cursor: pointer;
1180
- transition: all 0.2s ease;
1181
- flex-shrink: 0;
1182
- }
1181
+ border-radius: 6px;
1182
+ padding: 0;
1183
+ transition: all 0.15s ease;
1183
1184
 
1184
- .capsule-search-btn:hover:not(:disabled) {
1185
- background: #0f172a;
1185
+ &:hover {
1186
+ background: #f1f5f9;
1187
+ color: #0f172a;
1188
+ }
1186
1189
  }
1187
1190
 
1188
- .capsule-search-btn:disabled {
1189
- opacity: 0.6;
1190
- cursor: not-allowed;
1191
+ /* ==================== 图二:概览视图样式 ==================== */
1192
+ .material-overview-body {
1193
+ flex: 1;
1194
+ min-height: 0;
1195
+ display: flex;
1196
+ flex-direction: column;
1197
+ overflow: hidden;
1191
1198
  }
1192
1199
 
1193
- .btn-spinner-ring {
1194
- display: inline-block;
1195
- width: 12px;
1196
- height: 12px;
1197
- border: 2px solid rgba(255, 255, 255, 0.4);
1198
- border-top-color: #ffffff;
1199
- border-radius: 50%;
1200
- animation: spin 0.8s linear infinite;
1200
+ .overview-search-wrap {
1201
+ padding: 12px 16px 8px;
1202
+ flex-shrink: 0;
1201
1203
  }
1202
1204
 
1203
- /* 2. 工具栏行:分类 Tabs + 多维筛选(红框2 + 红框3) */
1204
- .gallery-toolbar-row {
1205
+ .overview-search-box {
1205
1206
  display: flex;
1206
1207
  align-items: center;
1207
- justify-content: space-between;
1208
- gap: 10px;
1209
- padding-bottom: 4px;
1210
- border-bottom: 1px solid #f1f5f9;
1211
- flex-shrink: 0;
1208
+ height: 36px;
1209
+ background: #f5f6f7;
1210
+ border: 1px solid #e2e8f0;
1211
+ border-radius: 6px;
1212
+ padding: 0 10px;
1213
+ gap: 8px;
1214
+ box-sizing: border-box;
1215
+ transition: all 0.2s ease;
1216
+
1217
+ &:focus-within {
1218
+ background: #ffffff;
1219
+ border-color: #1e6fff;
1220
+ }
1212
1221
  }
1213
1222
 
1214
- /* 红框2:分类类型胶囊组 */
1215
- .category-pill-group {
1216
- display: flex;
1217
- align-items: center;
1218
- gap: 4px;
1219
- background: #f1f5f9;
1220
- padding: 3px;
1221
- border-radius: 18px;
1223
+ .overview-search-icon {
1224
+ font-size: 15px;
1225
+ color: #94a3b8;
1226
+ cursor: pointer;
1222
1227
  flex-shrink: 0;
1223
1228
  }
1224
1229
 
1225
- .pill-tab-btn {
1230
+ .overview-search-input {
1231
+ flex: 1;
1232
+ min-width: 0;
1226
1233
  border: none;
1227
1234
  outline: none;
1228
1235
  background: transparent;
1229
- padding: 5px 12px;
1230
1236
  font-size: 13px;
1231
- color: #64748b;
1232
- font-weight: 500;
1233
- border-radius: 15px;
1234
- cursor: pointer;
1235
- transition: all 0.2s ease;
1236
- white-space: nowrap;
1237
- }
1238
-
1239
- .pill-tab-btn:hover {
1240
- color: #0f172a;
1241
- }
1237
+ color: #1e293b;
1242
1238
 
1243
- .pill-tab-btn.active {
1244
- background: #0f172a;
1245
- color: #ffffff;
1246
- font-weight: 600;
1247
- box-shadow: 0 2px 6px rgba(15, 23, 42, 0.15);
1239
+ &::placeholder {
1240
+ color: #94a3b8;
1241
+ }
1248
1242
  }
1249
1243
 
1250
- /* 红框3:筛选下拉项 */
1251
- .filter-dropdown-group {
1252
- display: flex;
1253
- align-items: center;
1254
- gap: 8px;
1244
+ .overview-search-clear {
1245
+ font-size: 14px;
1246
+ color: #94a3b8;
1247
+ cursor: pointer;
1255
1248
  flex-shrink: 0;
1256
- }
1257
1249
 
1258
- .toolbar-dropdown-btn {
1259
- display: inline-flex;
1260
- align-items: center;
1261
- gap: 4px;
1262
- padding: 4px 8px;
1263
- font-size: 12.5px;
1264
- color: #475569;
1265
- border-radius: 6px;
1266
- cursor: pointer;
1267
- transition: all 0.2s;
1268
- user-select: none;
1269
- white-space: nowrap;
1250
+ &:hover {
1251
+ color: #64748b;
1252
+ }
1270
1253
  }
1271
1254
 
1272
- .toolbar-dropdown-btn:hover {
1273
- background: #f8fafc;
1274
- color: #0f172a;
1255
+ .overview-sections-scroll {
1256
+ flex: 1;
1257
+ min-height: 0;
1258
+ overflow-y: auto;
1259
+ padding: 8px 16px 20px;
1260
+ display: flex;
1261
+ flex-direction: column;
1262
+ gap: 18px;
1263
+
1264
+ &::-webkit-scrollbar {
1265
+ width: 4px;
1266
+ }
1267
+ &::-webkit-scrollbar-thumb {
1268
+ background: #e2e8f0;
1269
+ border-radius: 2px;
1270
+ }
1275
1271
  }
1276
1272
 
1277
- .toolbar-dropdown-btn.is-active {
1278
- color: #ff5000;
1279
- font-weight: 600;
1280
- background: rgba(255, 80, 0, 0.08);
1273
+ .overview-category-section {
1274
+ display: flex;
1275
+ flex-direction: column;
1276
+ gap: 8px;
1281
1277
  }
1282
1278
 
1283
- .dropdown-arrow {
1284
- font-size: 11px;
1279
+ .section-header-row {
1280
+ display: flex;
1281
+ align-items: center;
1282
+ justify-content: space-between;
1285
1283
  }
1286
1284
 
1287
- .color-badge-dot {
1288
- display: inline-block;
1289
- width: 9px;
1290
- height: 9px;
1291
- border-radius: 50%;
1292
- border: 1px solid rgba(0, 0, 0, 0.15);
1285
+ .section-cat-title {
1286
+ font-size: 13px;
1287
+ font-weight: 500;
1288
+ color: #1e293b;
1293
1289
  }
1294
1290
 
1295
- .reset-filters-link {
1291
+ .section-view-all-btn {
1296
1292
  border: none;
1297
- background: none;
1293
+ background: transparent;
1294
+ padding: 0;
1298
1295
  font-size: 12px;
1299
1296
  color: #94a3b8;
1300
1297
  cursor: pointer;
1301
- padding: 3px 6px;
1302
- text-decoration: underline;
1303
- transition: color 0.2s;
1304
- white-space: nowrap;
1298
+ transition: color 0.15s ease;
1299
+
1300
+ &:hover {
1301
+ color: #1e6fff;
1302
+ }
1305
1303
  }
1306
1304
 
1307
- .reset-filters-link:hover {
1308
- color: #ef4444;
1305
+ .section-carousel-container {
1306
+ position: relative;
1307
+ width: 100%;
1308
+ overflow: hidden;
1309
1309
  }
1310
1310
 
1311
- /* 颜色选择面板 */
1312
- .color-picker-palette {
1313
- padding: 10px;
1314
- width: 170px;
1311
+ .section-carousel-track {
1312
+ display: flex;
1313
+ align-items: center;
1314
+ gap: 8px;
1315
+ overflow-x: auto;
1316
+ scroll-behavior: smooth;
1317
+ scrollbar-width: none;
1318
+ -ms-overflow-style: none;
1319
+
1320
+ &::-webkit-scrollbar {
1321
+ display: none;
1322
+ }
1315
1323
  }
1316
1324
 
1317
- .palette-header-label {
1318
- font-size: 12px;
1319
- color: #64748b;
1320
- margin-bottom: 8px;
1321
- font-weight: 500;
1325
+ .overview-skeleton-card {
1326
+ width: 96px;
1327
+ height: 84px;
1328
+ border-radius: 8px;
1329
+ background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
1330
+ background-size: 200% 100%;
1331
+ animation: shimmer 1.4s infinite;
1332
+ flex-shrink: 0;
1322
1333
  }
1323
1334
 
1324
- .palette-swatch-grid {
1325
- display: grid;
1326
- grid-template-columns: repeat(5, 1fr);
1327
- gap: 6px;
1335
+ @keyframes shimmer {
1336
+ 0% { background-position: 200% 0; }
1337
+ 100% { background-position: -200% 0; }
1328
1338
  }
1329
1339
 
1330
- .swatch-item {
1331
- width: 24px;
1332
- height: 24px;
1333
- border-radius: 6px;
1340
+ .overview-thumb-card {
1341
+ height: 84px;
1342
+ border-radius: 8px;
1343
+ overflow: hidden;
1344
+ background: #f8fafc;
1345
+ flex-shrink: 0;
1334
1346
  cursor: pointer;
1335
- border: 1px solid rgba(0, 0, 0, 0.12);
1336
- transition: all 0.2s ease;
1337
- }
1347
+ position: relative;
1338
1348
 
1339
- .swatch-item:hover {
1340
- transform: scale(1.15);
1341
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
1349
+ &:hover .overview-thumb-img {
1350
+ transform: scale(1.04);
1351
+ }
1342
1352
  }
1343
1353
 
1344
- .swatch-item.active {
1345
- box-shadow: 0 0 0 2px #ff5000;
1354
+ .overview-thumb-img {
1355
+ height: 84px;
1356
+ width: auto;
1357
+ min-width: 72px;
1358
+ max-width: 148px;
1359
+ object-fit: cover;
1360
+ display: block;
1361
+ border-radius: 8px;
1362
+ transition: transform 0.25s ease;
1346
1363
  }
1347
1364
 
1348
- .swatch-item.all-swatch {
1365
+ .video-badge-icon {
1366
+ position: absolute;
1367
+ left: 6px;
1368
+ bottom: 6px;
1369
+ width: 20px;
1370
+ height: 20px;
1371
+ border-radius: 50%;
1372
+ background: rgba(15, 23, 42, 0.55);
1373
+ color: #ffffff;
1349
1374
  display: flex;
1350
1375
  align-items: center;
1351
1376
  justify-content: center;
1352
- font-size: 10px;
1353
- color: #64748b;
1354
- background: #f1f5f9;
1377
+ font-size: 12px;
1378
+ pointer-events: none;
1355
1379
  }
1356
1380
 
1357
- /* 加载中状态 */
1358
- .gallery-loading-box {
1381
+ .overview-empty-row {
1382
+ width: 100%;
1383
+ height: 84px;
1384
+ border-radius: 8px;
1385
+ background: #f8fafc;
1386
+ border: 1px dashed #e2e8f0;
1359
1387
  display: flex;
1360
- flex-direction: column;
1361
1388
  align-items: center;
1362
1389
  justify-content: center;
1363
- padding: 60px 0;
1364
- gap: 12px;
1390
+ font-size: 12px;
1391
+ color: #94a3b8;
1392
+ cursor: pointer;
1365
1393
  }
1366
1394
 
1367
- .loading-ring-spinner {
1368
- width: 28px;
1369
- height: 28px;
1370
- border: 3px solid #e2e8f0;
1371
- border-top-color: #0f172a;
1372
- border-radius: 50%;
1373
- animation: spin 0.8s linear infinite;
1374
- }
1395
+ .carousel-edge-mask {
1396
+ position: absolute;
1397
+ top: 0;
1398
+ bottom: 0;
1399
+ width: 30px;
1400
+ display: flex;
1401
+ align-items: center;
1402
+ justify-content: center;
1403
+ cursor: pointer;
1404
+ color: #334155;
1405
+ z-index: 2;
1406
+ transition: opacity 0.15s ease;
1375
1407
 
1376
- .loading-tip-text {
1377
- font-size: 13px;
1378
- color: #64748b;
1408
+ &.left-mask {
1409
+ left: 0;
1410
+ background: linear-gradient(90deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0) 100%);
1411
+ justify-content: flex-start;
1412
+ padding-left: 4px;
1413
+ }
1414
+
1415
+ &.right-mask {
1416
+ right: 0;
1417
+ background: linear-gradient(270deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0) 100%);
1418
+ justify-content: flex-end;
1419
+ padding-right: 4px;
1420
+ }
1421
+
1422
+ &:hover {
1423
+ color: #0f172a;
1424
+ }
1379
1425
  }
1380
1426
 
1381
- /* 3. 主体内容区:纯瀑布流排版(红框4) */
1382
- .waterfall-scroll-container {
1427
+ /* ==================== 图三:详情视图样式 ==================== */
1428
+ .material-detail-body {
1383
1429
  flex: 1;
1384
1430
  min-height: 0;
1385
- overflow-y: auto;
1386
- padding-right: 4px;
1431
+ display: flex;
1432
+ flex-direction: column;
1433
+ overflow: hidden;
1387
1434
  }
1388
1435
 
1389
- .waterfall-scroll-container::-webkit-scrollbar {
1390
- width: 5px;
1391
- }
1392
- .waterfall-scroll-container::-webkit-scrollbar-thumb {
1393
- background: #cbd5e1;
1394
- border-radius: 3px;
1395
- }
1396
- .waterfall-scroll-container::-webkit-scrollbar-thumb:hover {
1397
- background: #94a3b8;
1436
+ .detail-tags-bar-wrap {
1437
+ position: relative;
1438
+ padding: 12px 16px 8px;
1439
+ flex-shrink: 0;
1440
+ overflow: hidden;
1398
1441
  }
1399
1442
 
1400
- .waterfall-masonry-layout {
1401
- column-count: 4;
1402
- column-gap: 12px;
1403
- }
1443
+ .detail-tags-track {
1444
+ display: flex;
1445
+ align-items: center;
1446
+ gap: 8px;
1447
+ overflow-x: auto;
1448
+ scroll-behavior: smooth;
1449
+ scrollbar-width: none;
1450
+ -ms-overflow-style: none;
1404
1451
 
1405
- @media (max-width: 820px) {
1406
- .waterfall-masonry-layout {
1407
- column-count: 3;
1452
+ &::-webkit-scrollbar {
1453
+ display: none;
1408
1454
  }
1409
1455
  }
1410
1456
 
1411
- .waterfall-card-item {
1412
- break-inside: avoid;
1413
- margin-bottom: 12px;
1457
+ .detail-tag-pill {
1458
+ height: 30px;
1459
+ padding: 0 14px;
1460
+ border-radius: 6px;
1461
+ border: 1px solid #e2e8f0;
1462
+ background: #ffffff;
1463
+ color: #475569;
1464
+ font-size: 12.5px;
1465
+ font-weight: 400;
1466
+ white-space: nowrap;
1467
+ flex-shrink: 0;
1414
1468
  cursor: pointer;
1415
- }
1469
+ transition: all 0.15s ease;
1416
1470
 
1417
- .card-box {
1418
- border-radius: 10px;
1419
- overflow: hidden;
1420
- background: #ffffff;
1421
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
1422
- border: 1px solid #f1f5f9;
1423
- transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
1424
- display: flex;
1425
- flex-direction: column;
1426
- }
1471
+ &:hover {
1472
+ border-color: #cbd5e1;
1473
+ color: #1e293b;
1474
+ }
1427
1475
 
1428
- .card-box:hover {
1429
- transform: translateY(-3px);
1430
- box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
1431
- border-color: #e2e8f0;
1476
+ &.active {
1477
+ background: #ebf3ff;
1478
+ border-color: transparent;
1479
+ color: #1e6fff;
1480
+ font-weight: 500;
1481
+ }
1432
1482
  }
1433
1483
 
1434
- .card-media-viewport {
1435
- position: relative;
1436
- width: 100%;
1437
- overflow: hidden;
1438
- background: #f1f5f9;
1439
- min-height: 90px;
1440
- max-height: 260px;
1484
+ .tags-edge-arrow {
1485
+ position: absolute;
1486
+ top: 12px;
1487
+ height: 30px;
1488
+ width: 28px;
1441
1489
  display: flex;
1442
1490
  align-items: center;
1443
- justify-content: center;
1444
- }
1491
+ cursor: pointer;
1492
+ color: #334155;
1493
+ z-index: 2;
1445
1494
 
1446
- .card-thumb-image {
1447
- width: 100%;
1448
- height: auto;
1449
- max-height: 260px;
1450
- display: block;
1451
- object-fit: cover;
1452
- transition: transform 0.4s ease;
1495
+ &.left-arrow {
1496
+ left: 16px;
1497
+ background: linear-gradient(90deg, #ffffff 45%, rgba(255, 255, 255, 0) 100%);
1498
+ justify-content: flex-start;
1499
+ }
1500
+
1501
+ &.right-arrow {
1502
+ right: 16px;
1503
+ background: linear-gradient(270deg, #ffffff 45%, rgba(255, 255, 255, 0) 100%);
1504
+ justify-content: flex-end;
1505
+ }
1453
1506
  }
1454
1507
 
1455
- .card-box:hover .card-thumb-image {
1456
- transform: scale(1.04);
1508
+ .detail-waterfall-scroll {
1509
+ flex: 1;
1510
+ min-height: 0;
1511
+ overflow-y: auto;
1512
+ padding: 4px 16px 16px;
1513
+
1514
+ &::-webkit-scrollbar {
1515
+ width: 4px;
1516
+ }
1517
+ &::-webkit-scrollbar-thumb {
1518
+ background: #cbd5e1;
1519
+ border-radius: 2px;
1520
+ }
1457
1521
  }
1458
1522
 
1459
- .card-empty-placeholder {
1460
- padding: 30px 0;
1523
+ .detail-masonry-columns {
1461
1524
  display: flex;
1462
- align-items: center;
1463
- justify-content: center;
1525
+ align-items: flex-start;
1526
+ gap: 8px;
1464
1527
  }
1465
1528
 
1466
- /* 悬浮遮罩 */
1467
- .card-hover-mask {
1468
- position: absolute;
1469
- top: 0;
1470
- left: 0;
1471
- width: 100%;
1472
- height: 100%;
1473
- background: rgba(15, 23, 42, 0.55);
1474
- backdrop-filter: blur(2px);
1529
+ .detail-masonry-col {
1530
+ flex: 1;
1531
+ min-width: 0;
1475
1532
  display: flex;
1476
1533
  flex-direction: column;
1477
- align-items: center;
1478
- justify-content: center;
1479
- opacity: 0;
1480
- transition: opacity 0.2s ease;
1481
- }
1482
-
1483
- .card-box:hover .card-hover-mask {
1484
- opacity: 1;
1534
+ gap: 8px;
1485
1535
  }
1486
1536
 
1487
- .preview-float-btn {
1488
- position: absolute;
1489
- top: 8px;
1490
- right: 8px;
1491
- width: 26px;
1492
- height: 26px;
1493
- border-radius: 50%;
1494
- background: rgba(255, 255, 255, 0.85);
1495
- color: #1e293b;
1496
- border: none;
1537
+ .detail-masonry-card {
1538
+ position: relative;
1539
+ width: 100%;
1540
+ border-radius: 8px;
1541
+ overflow: hidden;
1542
+ background: #f1f5f9;
1497
1543
  cursor: pointer;
1498
- display: flex;
1499
- align-items: center;
1500
- justify-content: center;
1501
- font-size: 13px;
1502
- transition: all 0.2s;
1544
+
1545
+ &:hover .detail-card-img {
1546
+ transform: scale(1.03);
1547
+ }
1548
+
1549
+ &:hover .detail-card-preview-btn {
1550
+ opacity: 1;
1551
+ }
1503
1552
  }
1504
1553
 
1505
- .preview-float-btn:hover {
1506
- background: #ffffff;
1507
- transform: scale(1.1);
1554
+ .detail-card-img {
1555
+ width: 100%;
1556
+ height: auto;
1557
+ min-height: 54px;
1558
+ max-height: 240px;
1559
+ object-fit: cover;
1560
+ display: block;
1561
+ border-radius: 8px;
1562
+ transition: transform 0.25s ease;
1508
1563
  }
1509
1564
 
1510
- .insert-float-action {
1565
+ .detail-card-placeholder {
1566
+ height: 90px;
1511
1567
  display: flex;
1512
- flex-direction: column;
1513
1568
  align-items: center;
1514
- gap: 6px;
1515
- color: #ffffff;
1516
- font-size: 12px;
1517
- font-weight: 600;
1569
+ justify-content: center;
1518
1570
  }
1519
1571
 
1520
- .insert-circle-btn {
1521
- width: 34px;
1522
- height: 34px;
1572
+ .detail-card-preview-btn {
1573
+ position: absolute;
1574
+ top: 6px;
1575
+ right: 6px;
1576
+ width: 24px;
1577
+ height: 24px;
1523
1578
  border-radius: 50%;
1524
- background: #ff5000;
1579
+ border: none;
1580
+ background: rgba(15, 23, 42, 0.55);
1525
1581
  color: #ffffff;
1526
1582
  display: flex;
1527
1583
  align-items: center;
1528
1584
  justify-content: center;
1529
- font-size: 18px;
1530
- box-shadow: 0 2px 8px rgba(255, 80, 0, 0.4);
1531
- transition: transform 0.2s;
1532
- }
1533
-
1534
- .card-box:hover .insert-circle-btn {
1535
- transform: scale(1.08);
1536
- }
1537
-
1538
- .insert-tip {
1539
1585
  font-size: 12px;
1540
- }
1586
+ cursor: pointer;
1587
+ opacity: 0;
1588
+ transition: all 0.15s ease;
1541
1589
 
1542
- /* 卡片底部栏(红框4版权信息条) */
1543
- .card-bottom-info {
1544
- display: flex;
1545
- align-items: center;
1546
- justify-content: space-between;
1547
- padding: 8px 10px;
1548
- background: #ffffff;
1549
- border-top: 1px solid #f8fafc;
1590
+ &:hover {
1591
+ background: rgba(15, 23, 42, 0.8);
1592
+ transform: scale(1.08);
1593
+ }
1550
1594
  }
1551
1595
 
1552
- .format-badge-box {
1596
+ /* 加载中与底部状态 */
1597
+ .gallery-loading-box {
1553
1598
  display: flex;
1599
+ flex-direction: column;
1554
1600
  align-items: center;
1555
- gap: 6px;
1556
- min-width: 0;
1557
- overflow: hidden;
1558
- }
1559
-
1560
- .format-tag {
1561
- font-size: 10px;
1562
- font-weight: 600;
1563
- color: #475569;
1564
- background: #f1f5f9;
1565
- padding: 1px 5px;
1566
- border-radius: 3px;
1567
- letter-spacing: 0.5px;
1568
- flex-shrink: 0;
1601
+ justify-content: center;
1602
+ padding: 60px 0;
1603
+ gap: 10px;
1569
1604
  }
1570
1605
 
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;
1606
+ .loading-ring-spinner {
1607
+ width: 24px;
1608
+ height: 24px;
1609
+ border: 2.5px solid #e2e8f0;
1610
+ border-top-color: #1e6fff;
1611
+ border-radius: 50%;
1612
+ animation: spin 0.8s linear infinite;
1582
1613
  }
1583
1614
 
1584
- .format-title-hint {
1585
- font-size: 11px;
1615
+ .loading-tip-text {
1616
+ font-size: 12.5px;
1586
1617
  color: #64748b;
1587
- white-space: nowrap;
1588
- overflow: hidden;
1589
- text-overflow: ellipsis;
1590
- max-width: 140px;
1591
- }
1592
-
1593
- .more-options-dot-btn {
1594
- border: none;
1595
- background: transparent;
1596
- color: #94a3b8;
1597
- cursor: pointer;
1598
- padding: 2px 4px;
1599
- border-radius: 4px;
1600
- display: flex;
1601
- align-items: center;
1602
- transition: all 0.2s;
1603
- }
1604
-
1605
- .more-options-dot-btn:hover {
1606
- color: #334155;
1607
- background: #f1f5f9;
1608
1618
  }
1609
1619
 
1610
- /* 瀑布流底部加载更多区域 */
1611
1620
  .waterfall-footer-status {
1612
- padding: 16px 0 24px;
1621
+ padding: 14px 0 8px;
1613
1622
  display: flex;
1614
1623
  align-items: center;
1615
1624
  justify-content: center;
1616
- width: 100%;
1617
1625
  }
1618
1626
 
1619
1627
  .loading-more-row {
1620
1628
  display: flex;
1621
1629
  align-items: center;
1622
- gap: 8px;
1623
- font-size: 13px;
1630
+ gap: 6px;
1631
+ font-size: 12px;
1624
1632
  color: #64748b;
1625
1633
  }
1626
1634
 
1635
+ .btn-spinner-ring {
1636
+ display: inline-block;
1637
+ width: 12px;
1638
+ height: 12px;
1639
+ border: 2px solid rgba(30, 111, 255, 0.2);
1640
+ border-top-color: #1e6fff;
1641
+ border-radius: 50%;
1642
+ animation: spin 0.8s linear infinite;
1643
+ }
1644
+
1627
1645
  .load-failed-row {
1628
1646
  display: flex;
1629
1647
  align-items: center;
1630
- gap: 10px;
1631
- background: #fef2f2;
1632
- border: 1px solid #fecaca;
1633
- padding: 6px 14px;
1634
- border-radius: 20px;
1635
- }
1636
-
1637
- .load-error-text {
1648
+ gap: 8px;
1638
1649
  font-size: 12px;
1639
- color: #b91c1c;
1650
+ color: #ef4444;
1640
1651
  }
1641
1652
 
1642
1653
  .retry-load-btn {
1643
- border: none;
1644
- background: #dc2626;
1645
- color: #ffffff;
1646
- padding: 3px 10px;
1647
- border-radius: 12px;
1654
+ border: 1px solid #fecaca;
1655
+ background: #fef2f2;
1656
+ color: #dc2626;
1657
+ padding: 2px 8px;
1658
+ border-radius: 4px;
1648
1659
  font-size: 11.5px;
1649
1660
  cursor: pointer;
1650
- font-weight: 500;
1651
- transition: all 0.2s;
1652
- }
1653
-
1654
- .retry-load-btn:hover {
1655
- background: #b91c1c;
1656
- }
1657
-
1658
- .btn-spinner-ring.dark {
1659
- border-color: rgba(15, 23, 42, 0.15);
1660
- border-top-color: #0f172a;
1661
1661
  }
1662
1662
 
1663
1663
  .load-more-action-btn {
1664
- display: inline-flex;
1665
- align-items: center;
1666
- gap: 6px;
1667
- padding: 8px 24px;
1668
- background: #f1f5f9;
1664
+ width: 100%;
1665
+ height: 34px;
1666
+ border-radius: 6px;
1669
1667
  border: 1px solid #e2e8f0;
1670
- border-radius: 20px;
1668
+ background: #f8fafc;
1671
1669
  color: #334155;
1672
1670
  font-size: 12.5px;
1673
1671
  font-weight: 500;
1674
1672
  cursor: pointer;
1675
- transition: all 0.2s ease;
1676
- }
1673
+ display: inline-flex;
1674
+ align-items: center;
1675
+ justify-content: center;
1676
+ transition: all 0.18s ease;
1677
1677
 
1678
- .load-more-action-btn:hover {
1679
- background: #0f172a;
1680
- color: #ffffff;
1681
- border-color: #0f172a;
1682
- transform: translateY(-1px);
1683
- box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
1684
- }
1678
+ &:hover {
1679
+ background: #eff6ff;
1680
+ border-color: #bfdbfe;
1681
+ color: #1e6fff;
1682
+ }
1685
1683
 
1686
- .load-more-count {
1687
- font-size: 11px;
1688
- opacity: 0.8;
1684
+ &:active {
1685
+ background: #dbeafe;
1686
+ }
1689
1687
  }
1690
1688
 
1691
1689
  .all-loaded-divider {
1692
- font-size: 12px;
1690
+ font-size: 11.5px;
1693
1691
  color: #94a3b8;
1694
- letter-spacing: 0.5px;
1695
1692
  }
1696
1693
 
1697
- /* 空状态 */
1698
1694
  .gallery-empty-state {
1699
1695
  display: flex;
1700
1696
  flex-direction: column;
1701
1697
  align-items: center;
1702
1698
  justify-content: center;
1703
1699
  padding: 60px 16px;
1704
- gap: 10px;
1700
+ gap: 8px;
1705
1701
  text-align: center;
1706
1702
  }
1707
1703
 
1708
1704
  .empty-title {
1709
- font-size: 13.5px;
1705
+ font-size: 12.5px;
1710
1706
  color: #64748b;
1711
1707
  margin: 0;
1712
- font-weight: 500;
1713
- }
1714
-
1715
- .empty-subtitle {
1716
- font-size: 12px;
1717
- color: #94a3b8;
1718
1708
  }
1719
1709
 
1720
1710
  @keyframes spin {
1721
1711
  from { transform: rotate(0deg); }
1722
1712
  to { transform: rotate(360deg); }
1723
1713
  }
1724
-
1725
- /* ==========================================================================
1726
- 重点优化:当在右侧边栏(宽度 <= 520px / is-narrow-view)打开时的响应式完美适配
1727
- ========================================================================== */
1728
- .template-gallery-container.is-narrow-view {
1729
- padding: 10px 12px 14px;
1730
- gap: 10px;
1731
-
1732
- /* 1. 搜索栏收敛紧凑 */
1733
- .search-input-capsule {
1734
- height: 38px;
1735
- padding: 2px 4px 2px 10px;
1736
- }
1737
-
1738
- .native-search-input {
1739
- font-size: 12.5px;
1740
- }
1741
-
1742
- .capsule-search-btn {
1743
- height: 30px;
1744
- padding: 0 10px;
1745
- font-size: 12px;
1746
- }
1747
-
1748
- /* 在狭窄侧边栏下,隐藏“搜索”文字,只保留放大镜图标,彻底防止截断输入框 */
1749
- .capsule-search-btn .btn-text {
1750
- display: none;
1751
- }
1752
-
1753
- /* 2. 工具栏分为上下两行,彻底解决挤压溢出 */
1754
- .gallery-toolbar-row {
1755
- flex-direction: column;
1756
- align-items: stretch;
1757
- gap: 8px;
1758
- padding-bottom: 8px;
1759
- }
1760
-
1761
- /* 第一行:分类 Tabs 单行横向平滑滚动 */
1762
- .category-pill-group {
1763
- width: 100%;
1764
- overflow-x: auto;
1765
- scrollbar-width: none;
1766
- -ms-overflow-style: none;
1767
- display: flex;
1768
- flex-wrap: nowrap;
1769
- padding: 2px 4px;
1770
- box-sizing: border-box;
1771
- }
1772
-
1773
- .category-pill-group::-webkit-scrollbar {
1774
- display: none;
1775
- }
1776
-
1777
- .pill-tab-btn {
1778
- flex-shrink: 0;
1779
- padding: 4px 10px;
1780
- font-size: 12px;
1781
- }
1782
-
1783
- /* 第二行:多维筛选下拉平铺 */
1784
- .filter-dropdown-group {
1785
- width: 100%;
1786
- display: flex;
1787
- align-items: center;
1788
- justify-content: flex-start;
1789
- gap: 6px;
1790
- overflow-x: auto;
1791
- scrollbar-width: none;
1792
- }
1793
-
1794
- .filter-dropdown-group::-webkit-scrollbar {
1795
- display: none;
1796
- }
1797
-
1798
- .toolbar-dropdown-btn {
1799
- padding: 3px 6px;
1800
- font-size: 11.5px;
1801
- flex-shrink: 0;
1802
- }
1803
-
1804
- /* 3. 瀑布流自适应为精致的双列(2列)排版 */
1805
- .waterfall-masonry-layout {
1806
- column-count: 2 !important;
1807
- column-gap: 8px !important;
1808
- }
1809
-
1810
- .waterfall-card-item {
1811
- margin-bottom: 8px !important;
1812
- }
1813
-
1814
- .card-media-viewport {
1815
- min-height: 60px;
1816
- max-height: 180px;
1817
- }
1818
-
1819
- .card-bottom-info {
1820
- padding: 6px 8px;
1821
- }
1822
-
1823
- .format-title-hint {
1824
- max-width: 80px;
1825
- font-size: 10px;
1826
- }
1827
-
1828
- .insert-circle-btn {
1829
- width: 28px;
1830
- height: 28px;
1831
- font-size: 15px;
1832
- }
1833
-
1834
- .insert-tip {
1835
- font-size: 10px;
1836
- }
1837
- }
1838
1714
  </style>
1839
1715
 
1840
1716
  <style>
1841
- /* ==========================================================================
1842
- 全屏大图预览 Modal(脱离一切容器,z-index 最高层级)
1843
- ========================================================================== */
1717
+ /* 全屏大图预览 Modal */
1844
1718
  .custom-fullscreen-preview-modal {
1845
1719
  position: fixed !important;
1846
1720
  inset: 0 !important;
@@ -1872,7 +1746,7 @@ watch([() => materialApiUrl?.value, () => materialPageApiUrl?.value, () => mater
1872
1746
  }
1873
1747
 
1874
1748
  .preview-format-pill {
1875
- background: #ff5000;
1749
+ background: #1e6fff;
1876
1750
  color: #ffffff;
1877
1751
  font-size: 11px;
1878
1752
  font-weight: 700;
@@ -1983,24 +1857,23 @@ watch([() => materialApiUrl?.value, () => materialPageApiUrl?.value, () => mater
1983
1857
  align-items: center;
1984
1858
  gap: 6px;
1985
1859
  padding: 8px 20px;
1986
- background: #ff5000;
1860
+ background: #1e6fff;
1987
1861
  color: #ffffff;
1988
1862
  border: none;
1989
1863
  border-radius: 20px;
1990
1864
  font-size: 13px;
1991
1865
  font-weight: 600;
1992
1866
  cursor: pointer;
1993
- box-shadow: 0 4px 14px rgba(255, 80, 0, 0.4);
1867
+ box-shadow: 0 4px 14px rgba(30, 111, 255, 0.4);
1994
1868
  transition: all 0.2s;
1995
1869
  }
1996
1870
 
1997
1871
  .preview-quick-insert-btn:hover {
1998
- background: #ff6a00;
1872
+ background: #1557d0;
1999
1873
  transform: translateY(-2px);
2000
- box-shadow: 0 6px 18px rgba(255, 80, 0, 0.5);
1874
+ box-shadow: 0 6px 18px rgba(30, 111, 255, 0.5);
2001
1875
  }
2002
1876
 
2003
- /* 过渡动效 */
2004
1877
  .preview-backdrop-fade-enter-active,
2005
1878
  .preview-backdrop-fade-leave-active {
2006
1879
  transition: opacity 0.25s ease;