jvs-ppt-ui 0.0.8 → 0.0.10

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