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,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, watch, defineAsyncComponent } from 'vue'
2
+ import { ref, computed, watch, nextTick, defineAsyncComponent } from 'vue'
3
3
  // 样式构建器大弹层(~100KB 源码)异步化:首次打开才加载
4
4
  const StyleBuilderDialog = defineAsyncComponent(() => import('../../StyleBuilderDialog.vue'))
5
5
  import LayoutThumbSvg from './LayoutThumbSvg.vue'
@@ -7,6 +7,7 @@ import QuickColorPopover from '../../QuickColorPopover.vue'
7
7
  import GradientEditor from './GradientEditor.vue'
8
8
  import { useSlides, gradientPresets, normalizeSlideBackground } from '../../../composables/useSlides'
9
9
  import { SYS_FONTS } from '../../../configs/fonts'
10
+ import { PATTERN_PRESETS, parseColor, hexToRgba, generateGradientEndColor, buildPatternSvgDataUrl, parsePatternSvgMeta } from '../../../composables/sidebar/useSidebarShapeFormat'
10
11
 
11
12
  const props = defineProps<{
12
13
  designActiveTab: string
@@ -56,16 +57,23 @@ const {
56
57
  activeMasterId,
57
58
  slideMasters,
58
59
  getThemeUsageLabel,
60
+ getThemeUsageSlides,
59
61
  addHistorySnapshot,
60
- uploadResource
62
+ uploadResource,
63
+ logicalWidth,
64
+ logicalHeight
61
65
  } = useSlides()
62
66
 
63
67
  const currentActiveThemeId = computed(() => {
68
+ let themeId = ''
64
69
  if (isMasterEditingView.value) {
65
70
  const master = slideMasters.value.find(m => m.id === activeMasterId.value) || slideMasters.value[0]
66
- return (master as any)?.themeId || 'default-recipe'
71
+ themeId = (master as any)?.themeId || 'tech-blue'
72
+ } else {
73
+ themeId = props.slides[props.activeIndex]?.themeId || 'tech-blue'
67
74
  }
68
- return props.slides[props.activeIndex]?.themeId || 'default-recipe'
75
+ if (themeId === 'default-recipe') return 'tech-blue'
76
+ return themeId
69
77
  })
70
78
 
71
79
  // Quick Color Popover recent colors
@@ -73,20 +81,287 @@ const quickRecentColors = ref<string[]>([])
73
81
  const globalFontColorPopoverVisible = ref(false)
74
82
  const globalBorderColorPopoverVisible = ref(false)
75
83
  const bgColorPopoverVisible = ref(false)
84
+ const bgPatternFgPopoverVisible = ref(false)
85
+ const bgPatternBgPopoverVisible = ref(false)
76
86
  const isCustomColorPickerOpen = ref(false)
77
87
 
88
+ // 画布尺寸下拉 Popover 状态
89
+ const isRatioPopoverVisible = ref(false)
90
+ const ratioTriggerRef = ref<HTMLElement | null>(null)
91
+ const ratioTriggerWidth = ref(258)
92
+
93
+ const ratioOptions = [
94
+ { label: '宽屏 16:9', value: '16:9' },
95
+ { label: '正宽 4:3', value: '4:3' },
96
+ { label: '宽屏 16:10', value: '16:10' },
97
+ { label: '手机竖屏 9:16 (移动端)', value: '9:16' }
98
+ ]
99
+
100
+ const currentRatioLabel = computed(() => {
101
+ const match = ratioOptions.find(opt => opt.value === props.slideRatio)
102
+ if (match) return match.label
103
+ if (props.slideRatio === 'custom') return '自定义尺寸'
104
+ return props.slideRatio || '宽屏 16:9'
105
+ })
106
+
107
+ function onRatioPopoverShow() {
108
+ if (ratioTriggerRef.value) {
109
+ ratioTriggerWidth.value = ratioTriggerRef.value.offsetWidth
110
+ }
111
+ }
112
+
113
+ function selectRatio(val: string) {
114
+ emit('update:slideRatio', val)
115
+ isRatioPopoverVisible.value = false
116
+ }
117
+
118
+ // 背景图片适配模式下拉 Popover 状态
119
+ const isImageModePopoverVisible = ref(false)
120
+ const imageModeTriggerRef = ref<HTMLElement | null>(null)
121
+ const imageModeTriggerWidth = ref(180)
122
+
123
+ const imageModeOptions = [
124
+ { label: '拉伸铺满', value: 'stretch' },
125
+ { label: '等比缩放', value: 'contain' },
126
+ { label: '居中', value: 'cover' },
127
+ { label: '平铺', value: 'tile' }
128
+ ]
129
+
130
+ const currentImageModeLabel = computed(() => {
131
+ const match = imageModeOptions.find(opt => opt.value === bgImageMode.value)
132
+ return match ? match.label : '拉伸铺满'
133
+ })
134
+
135
+ function onImageModePopoverShow() {
136
+ if (imageModeTriggerRef.value) {
137
+ imageModeTriggerWidth.value = imageModeTriggerRef.value.offsetWidth
138
+ }
139
+ }
140
+
141
+ function selectImageMode(val: string) {
142
+ bgImageMode.value = val as any
143
+ isImageModePopoverVisible.value = false
144
+ }
145
+
146
+ // 字体下拉 Popover 状态
147
+ const isFontPopoverVisible = ref(false)
148
+ const fontTriggerRef = ref<HTMLElement | null>(null)
149
+ const fontTriggerWidth = ref(258)
150
+
151
+ const currentFontFamilyLabel = computed(() => {
152
+ if (!globalFontFamily.value || globalFontFamily.value === '默认字体') return '默认字体'
153
+ return fontLabel(globalFontFamily.value)
154
+ })
155
+
156
+ function onFontPopoverShow() {
157
+ if (fontTriggerRef.value) {
158
+ fontTriggerWidth.value = fontTriggerRef.value.offsetWidth
159
+ }
160
+ }
161
+
162
+ function selectFont(fontVal: string) {
163
+ globalFontFamily.value = fontVal
164
+ isFontPopoverVisible.value = false
165
+ }
166
+
78
167
  // 主题色色盘 Popover
79
168
  const themePalettePopoverVisible = ref(false)
80
169
  const globalThemeColorPopoverVisible = ref<boolean[]>(Array(5).fill(false))
81
170
 
82
- // 格式化 hex 显示(去掉 # 并大写)
83
- const formatHexDisplay = (color: string) => {
84
- if (!color || typeof color !== 'string') return 'FFFFFF'
85
- return color.replace('#', '').toUpperCase()
171
+ // 格式化 hex 显示(去掉 # 并大写,保留 6 位纯色)
172
+ const formatHexDisplay = (color: any) => {
173
+ if (!color) return 'FFFFFF'
174
+ let str = ''
175
+ if (typeof color === 'string') {
176
+ str = color
177
+ } else if (typeof color === 'object' && color !== null) {
178
+ if (color.type === 'color' && typeof color.value === 'string') {
179
+ str = color.value
180
+ } else if (typeof color.value === 'string') {
181
+ str = color.value
182
+ }
183
+ }
184
+ if (!str || str === 'transparent' || str === 'none') return 'FFFFFF'
185
+ const parsed = parseColor(str)
186
+ let clean = (parsed.hex || str).replace('#', '').trim().toUpperCase()
187
+ if (clean.length === 8) clean = clean.slice(0, 6)
188
+ return clean
86
189
  }
87
190
 
88
191
  // 字体显示名称
89
- const fontLabel = (font: string) => SYS_FONTS.find(f => f.value === font || f.label === font)?.label || font
192
+ const fontLabel = (font?: string) => {
193
+ if (!font) return '思源黑体 CN'
194
+ if (font === 'SourceHanSansCN' || font === '思源黑体') return '思源黑体 CN'
195
+ return SYS_FONTS.find(f => f.value === font || f.label === font)?.label || font
196
+ }
197
+
198
+ const getThemeSlideCount = (recipeId: string) => {
199
+ const pages = getThemeUsageSlides(recipeId)
200
+ return pages ? pages.length : 0
201
+ }
202
+
203
+ const getThemeFontColors = (recipe: any) => {
204
+ if (recipe.id === 'tech-blue') return { aa: '#2563eb', font: '#64748b' }
205
+ if (recipe.id === 'fresh-green') return { aa: '#047857', font: '#059669' }
206
+ if (recipe.id === 'fashion-purple') return { aa: '#7c3aed', font: '#8b5cf6' }
207
+ if (recipe.id === 'dark-gold') return { aa: '#fbbf24', font: '#d97706' }
208
+ if (recipe.id === 'warm-amber') return { aa: '#ea580c', font: '#c2410c' }
209
+
210
+ const isDark = recipe.backgroundColor && (recipe.backgroundColor === '#121826' || recipe.backgroundColor === '#0f172a' || recipe.backgroundColor === '#18181b')
211
+ return {
212
+ aa: recipe.styleColors?.[0] || '#2563eb',
213
+ font: isDark ? '#e2e8f0' : (recipe.textStyles?.subheadline?.color || '#64748b')
214
+ }
215
+ }
216
+
217
+ function handleSelectTheme(recipe: any) {
218
+ emit('applyStyleRecipeToSlides', recipe, false)
219
+ }
220
+
221
+ function handleEditTheme(recipe: any) {
222
+ emit('openStyleBuilder', isMasterEditingView.value ? 'all' : 'single', recipe.id)
223
+ }
224
+
225
+ function handleDuplicateTheme(recipe: any) {
226
+ const newRecipe = JSON.parse(JSON.stringify(recipe))
227
+ newRecipe.id = 'theme-' + Date.now()
228
+ newRecipe.name = `${recipe.name} 副本`
229
+ const list = props.builtinStyleRecipes
230
+ const idx = list.findIndex((r: any) => r.id === recipe.id)
231
+ if (idx >= 0) {
232
+ list.splice(idx + 1, 0, newRecipe)
233
+ } else {
234
+ list.push(newRecipe)
235
+ }
236
+ notify?.(`已复制主题「${recipe.name}」`)
237
+ }
238
+
239
+ function handleApplyToAllSlides(recipe: any) {
240
+ emit('applyStyleRecipeToSlides', recipe, true)
241
+ }
242
+
243
+ function handleDeleteTheme(recipe: any) {
244
+ const list = props.builtinStyleRecipes
245
+ const idx = list.findIndex((r: any) => r.id === recipe.id)
246
+ if (idx >= 0) {
247
+ list.splice(idx, 1)
248
+ notify?.(`已删除主题「${recipe.name}」`)
249
+ }
250
+ }
251
+
252
+ // 缓存背景图片的原始宽高,用于居中/平铺模式的精确比例计算
253
+ const bgImageNaturalSizes = ref<Record<string, { w: number; h: number }>>({})
254
+ const loadingBgImageUrls = new Set<string>()
255
+
256
+ function extractUrlFromBgCss(bg: any): string {
257
+ if (!bg) return ''
258
+ if (typeof bg === 'string') {
259
+ const startIdx = bg.indexOf('url(')
260
+ if (startIdx === -1) return ''
261
+ const closeIdx = bg.lastIndexOf(')')
262
+ if (closeIdx <= startIdx + 4) return ''
263
+ let inner = bg.slice(startIdx + 4, closeIdx).trim()
264
+ if ((inner.startsWith('"') && inner.endsWith('"')) || (inner.startsWith("'") && inner.endsWith("'"))) {
265
+ inner = inner.slice(1, -1).trim()
266
+ }
267
+ return inner
268
+ }
269
+ if (typeof bg === 'object' && bg !== null) {
270
+ const norm = normalizeSlideBackground(bg)
271
+ if (norm.bgType === 'image') {
272
+ return extractUrlFromBgCss(norm.background)
273
+ }
274
+ }
275
+ return ''
276
+ }
277
+
278
+ function ensureBgImageSizeLoaded(url: string) {
279
+ if (!url || url.startsWith('data:image/svg+xml') || bgImageNaturalSizes.value[url] || loadingBgImageUrls.has(url)) {
280
+ return
281
+ }
282
+ loadingBgImageUrls.add(url)
283
+ const img = new Image()
284
+ img.onload = () => {
285
+ loadingBgImageUrls.delete(url)
286
+ const w = img.naturalWidth || img.width
287
+ const h = img.naturalHeight || img.height
288
+ if (w > 0 && h > 0) {
289
+ bgImageNaturalSizes.value = {
290
+ ...bgImageNaturalSizes.value,
291
+ [url]: { w, h }
292
+ }
293
+ // 若当前幻灯片正在使用该图片且处于居中或平铺模式,按真实尺寸比例刷新背景 CSS
294
+ const curUrl = extractUrlFromBgCss(activeSlide.value?.background)
295
+ const curMode = bgImageMode.value
296
+ if (activeSlide.value && curUrl === url && (curMode === 'cover' || curMode === 'tile')) {
297
+ const formatted = formatSlideBgImageCss(url, curMode)
298
+ if (activeSlide.value.background !== formatted) {
299
+ activeSlide.value.background = formatted
300
+ }
301
+ }
302
+ }
303
+ }
304
+ img.onerror = () => {
305
+ loadingBgImageUrls.delete(url)
306
+ }
307
+ img.src = url
308
+ }
309
+
310
+ function formatSlideBgImageCss(url: string, mode: string): string {
311
+ if (!url) return ''
312
+ if (url.startsWith('data:image/svg+xml')) {
313
+ if (mode === 'stretch') return `url("${url}") center center / 100% 100% no-repeat`
314
+ if (mode === 'contain') return `url("${url}") center center / contain no-repeat`
315
+ if (mode === 'cover') return `url("${url}") center center / auto no-repeat`
316
+ return `url("${url}") left top / auto repeat`
317
+ }
318
+
319
+ if (mode === 'stretch') {
320
+ return `url("${url}") center center / 100% 100% no-repeat`
321
+ }
322
+ if (mode === 'contain') {
323
+ return `url("${url}") center center / contain no-repeat`
324
+ }
325
+ if (mode === 'cover') {
326
+ ensureBgImageSizeLoaded(url)
327
+ const dim = bgImageNaturalSizes.value[url]
328
+ const slideW = logicalWidth?.value || 1280
329
+ const slideH = logicalHeight?.value || 720
330
+ if (dim && dim.w > 0 && dim.h > 0) {
331
+ if (dim.w <= slideW * 0.75 && dim.h <= slideH * 0.75) {
332
+ const wPct = ((dim.w / slideW) * 100).toFixed(2)
333
+ const hPct = ((dim.h / slideH) * 100).toFixed(2)
334
+ return `url("${url}") center center / ${wPct}% ${hPct}% no-repeat`
335
+ } else {
336
+ const scale = Math.min((slideW * 0.6) / dim.w, (slideH * 0.6) / dim.h)
337
+ const wPct = ((dim.w * scale / slideW) * 100).toFixed(2)
338
+ const hPct = ((dim.h * scale / slideH) * 100).toFixed(2)
339
+ return `url("${url}") center center / ${wPct}% ${hPct}% no-repeat`
340
+ }
341
+ }
342
+ return `url("${url}") center center / 60% auto no-repeat`
343
+ }
344
+ if (mode === 'tile') {
345
+ ensureBgImageSizeLoaded(url)
346
+ const dim = bgImageNaturalSizes.value[url]
347
+ const slideW = logicalWidth?.value || 1280
348
+ const slideH = logicalHeight?.value || 720
349
+ if (dim && dim.w > 0 && dim.h > 0) {
350
+ if (dim.w <= slideW * 0.35 && dim.h <= slideH * 0.35) {
351
+ const wPct = ((dim.w / slideW) * 100).toFixed(2)
352
+ const hPct = ((dim.h / slideH) * 100).toFixed(2)
353
+ return `url("${url}") left top / ${wPct}% ${hPct}% repeat`
354
+ } else {
355
+ const scale = Math.min((slideW * 0.25) / dim.w, (slideH * 0.3333) / dim.h)
356
+ const wPct = ((dim.w * scale / slideW) * 100).toFixed(2)
357
+ const hPct = ((dim.h * scale / slideH) * 100).toFixed(2)
358
+ return `url("${url}") left top / ${wPct}% ${hPct}% repeat`
359
+ }
360
+ }
361
+ return `url("${url}") left top / 25% auto repeat`
362
+ }
363
+ return `url("${url}") center center / 100% 100% no-repeat`
364
+ }
90
365
 
91
366
  // 背景类型与颜色
92
367
  const bgFillType = computed({
@@ -102,13 +377,37 @@ const bgFillType = computed({
102
377
  if (activeSlide.value) {
103
378
  activeSlide.value.bgType = val
104
379
  if (val === 'color') {
105
- if (typeof activeSlide.value.background === 'string' && (activeSlide.value.background.startsWith('linear-gradient') || activeSlide.value.background.startsWith('radial-gradient') || activeSlide.value.background.startsWith('url'))) {
380
+ if (typeof activeSlide.value.background === 'string' && (activeSlide.value.background.startsWith('linear-gradient') || activeSlide.value.background.startsWith('radial-gradient') || activeSlide.value.background.includes('url('))) {
106
381
  activeSlide.value.background = '#ffffff'
107
382
  }
108
383
  } else if (val === 'gradient') {
109
- activeSlide.value.background = 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
384
+ if (typeof activeSlide.value.background !== 'string' || !activeSlide.value.background.includes('gradient')) {
385
+ activeSlide.value.background = 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
386
+ }
110
387
  } else if (val === 'image') {
111
- activeSlide.value.background = ''
388
+ const existingUrl = extractUrlFromBgCss(activeSlide.value.background)
389
+ const isExistingSvgPattern = existingUrl.startsWith('data:image/svg+xml')
390
+ if (activeBgTab.value === 'image') {
391
+ const targetUrl = (!isExistingSvgPattern && existingUrl) ? existingUrl : ((activeSlide.value as any)._lastBgImageUrl || '')
392
+ ; (activeSlide.value as any).isPattern = false
393
+ if (targetUrl) {
394
+ const mode = (activeSlide.value as any).bgImageMode || 'stretch'
395
+ activeSlide.value.background = formatSlideBgImageCss(targetUrl, mode)
396
+ } else {
397
+ activeSlide.value.background = ''
398
+ }
399
+ } else if (activeBgTab.value === 'pattern') {
400
+ ; (activeSlide.value as any).isPattern = true
401
+ if (isExistingSvgPattern && existingUrl) {
402
+ activeSlide.value.background = `url("${existingUrl}") left top / auto repeat`
403
+ } else {
404
+ const pType = (activeSlide.value as any).bgPatternType || 'dots-sparse'
405
+ const pFg = (activeSlide.value as any).bgPatternFg || '#3B82F6'
406
+ const pBg = (activeSlide.value as any).bgPatternBg || '#FFFFFF'
407
+ const patUrl = buildPatternSvgDataUrl(pType, pFg, pBg)
408
+ activeSlide.value.background = `url("${patUrl}") left top / auto repeat`
409
+ }
410
+ }
112
411
  }
113
412
  addHistorySnapshot()
114
413
  }
@@ -140,21 +439,78 @@ const activeBgColor = computed({
140
439
  return '#ffffff'
141
440
  },
142
441
  set(val: string) {
442
+ onSelectBgColor(val)
443
+ }
444
+ })
445
+
446
+ const activeBgHex = computed(() => {
447
+ return parseColor(activeBgColor.value).hex || '#FFFFFF'
448
+ })
449
+
450
+ const displayBgOpacity = computed(() => {
451
+ const bg = activeSlide.value?.background
452
+ if (!bg) return 100
453
+ const parsed = parseColor(bg)
454
+ return parsed.alpha !== undefined ? Math.max(0, Math.min(100, Math.round(parsed.alpha))) : 100
455
+ })
456
+
457
+ function handleBgOpacityInput(e: Event) {
458
+ const target = e.target as HTMLInputElement
459
+ let val = parseInt(target.value)
460
+ if (isNaN(val)) val = 100
461
+ val = Math.max(0, Math.min(100, val))
462
+ setBgColorWithOpacity(activeBgHex.value, val)
463
+ }
464
+
465
+ function onSelectBgColor(color: string) {
466
+ if (!color || color === 'transparent' || color === 'none') {
143
467
  if (activeSlide.value) {
144
- activeSlide.value.background = val
468
+ activeSlide.value.background = '#ffffff'
145
469
  activeSlide.value.bgType = 'color'
146
470
  addHistorySnapshot()
147
471
  }
472
+ return
148
473
  }
149
- })
474
+ const parsed = parseColor(color)
475
+ const alpha = (parsed.alpha !== undefined && parsed.alpha < 100) ? parsed.alpha : displayBgOpacity.value
476
+ setBgColorWithOpacity(parsed.hex, alpha)
477
+ }
478
+
479
+ function setBgColorWithOpacity(hexColor: string, alpha: number) {
480
+ if (!activeSlide.value) return
481
+ const cleanHex = hexColor.startsWith('#') ? hexColor.slice(0, 7) : `#${hexColor.slice(0, 6)}`
482
+ if (alpha >= 100) {
483
+ activeSlide.value.background = cleanHex
484
+ } else {
485
+ const alphaHex = Math.round(alpha * 255 / 100).toString(16).padStart(2, '0').toUpperCase()
486
+ activeSlide.value.background = `${cleanHex}${alphaHex}`
487
+ }
488
+ activeSlide.value.bgType = 'color'
489
+ addHistorySnapshot()
490
+ }
150
491
 
151
492
  const bgImageMode = computed({
152
493
  get() {
153
- return (activeSlide.value as any)?.bgImageMode || 'stretch'
494
+ if ((activeSlide.value as any)?.bgImageMode) {
495
+ return (activeSlide.value as any).bgImageMode
496
+ }
497
+ const bg = activeSlide.value?.background
498
+ if (typeof bg === 'string' && bg.includes('url(')) {
499
+ if (/\brepeat\b/i.test(bg) && !/\bno-repeat\b/i.test(bg)) return 'tile'
500
+ if (/\bcontain\b/i.test(bg)) return 'contain'
501
+ if (/\bcover\b/i.test(bg) || /\bauto\s+no-repeat\b/i.test(bg)) return 'cover'
502
+ }
503
+ return 'stretch'
154
504
  },
155
505
  set(val: string) {
156
506
  if (activeSlide.value) {
157
- (activeSlide.value as any).bgImageMode = val
507
+ ; (activeSlide.value as any).bgImageMode = val
508
+ const currentUrl = bgImageUrl.value || (activeSlide.value as any)._lastBgImageUrl || ''
509
+ if (currentUrl) {
510
+ ; (activeSlide.value as any).isPattern = false
511
+ activeSlide.value.background = formatSlideBgImageCss(currentUrl, val)
512
+ activeSlide.value.bgType = 'image'
513
+ }
158
514
  addHistorySnapshot()
159
515
  }
160
516
  }
@@ -162,17 +518,32 @@ const bgImageMode = computed({
162
518
 
163
519
  const bgImageUrl = computed({
164
520
  get() {
165
- if (!activeSlide.value?.background || typeof activeSlide.value.background !== 'string') return ''
166
- const match = activeSlide.value.background.match(/url\(['"]?(.*?)['"]?\)/)
167
- return match ? match[1] : ''
521
+ const rawUrl = extractUrlFromBgCss(activeSlide.value?.background)
522
+ if (activeBgTab.value === 'pattern') {
523
+ return rawUrl
524
+ }
525
+ if (rawUrl && !rawUrl.startsWith('data:image/svg+xml')) {
526
+ return rawUrl
527
+ }
528
+ return (activeSlide.value as any)?._lastBgImageUrl || rawUrl || ''
168
529
  },
169
530
  set(url: string) {
170
531
  if (activeSlide.value) {
171
532
  if (!url) {
172
- activeSlide.value.background = '#ffffff'
173
- activeSlide.value.bgType = 'color'
533
+ ; (activeSlide.value as any)._lastBgImageUrl = ''
534
+ ; (activeSlide.value as any).isPattern = false
535
+ activeSlide.value.background = ''
536
+ activeSlide.value.bgType = 'image'
174
537
  } else {
175
- activeSlide.value.background = `url("${url}")`
538
+ const isSvgPattern = url.startsWith('data:image/svg+xml')
539
+ ; (activeSlide.value as any).isPattern = isSvgPattern
540
+ if (!isSvgPattern) {
541
+ ; (activeSlide.value as any)._lastBgImageUrl = url
542
+ const mode = bgImageMode.value || 'stretch'
543
+ activeSlide.value.background = formatSlideBgImageCss(url, mode)
544
+ } else {
545
+ activeSlide.value.background = `url("${url}") left top / auto repeat`
546
+ }
176
547
  activeSlide.value.bgType = 'image'
177
548
  }
178
549
  addHistorySnapshot()
@@ -180,6 +551,89 @@ const bgImageUrl = computed({
180
551
  }
181
552
  })
182
553
 
554
+ const bgImagePreviewStyle = computed(() => {
555
+ const url = bgImageUrl.value
556
+ if (!url) return {}
557
+ const css = formatSlideBgImageCss(url, bgImageMode.value)
558
+ return {
559
+ background: `transparent ${css}`
560
+ }
561
+ })
562
+
563
+ const activeBgPatternMeta = computed(() => {
564
+ const rawUrl = extractUrlFromBgCss(activeSlide.value?.background)
565
+ return parsePatternSvgMeta(rawUrl)
566
+ })
567
+
568
+ const activeBgPatternType = computed(() => {
569
+ return (activeSlide.value as any)?.bgPatternType || activeBgPatternMeta.value.type || 'dots-sparse'
570
+ })
571
+
572
+ const activeBgPatternFg = computed(() => {
573
+ return (activeSlide.value as any)?.bgPatternFg || activeBgPatternMeta.value.fg || '#3B82F6'
574
+ })
575
+
576
+ const activeBgPatternBg = computed(() => {
577
+ return (activeSlide.value as any)?.bgPatternBg || activeBgPatternMeta.value.bg || '#FFFFFF'
578
+ })
579
+
580
+ const parsedBgPatternFg = computed(() => parseColor(activeBgPatternFg.value))
581
+ const parsedBgPatternBg = computed(() => parseColor(activeBgPatternBg.value))
582
+
583
+ function getBgPatternPresetPreviewUrl(p: { key: string; url: string }) {
584
+ return buildPatternSvgDataUrl(p.key, activeBgPatternFg.value, activeBgPatternBg.value)
585
+ }
586
+
587
+ function isBgPatternPresetActive(p: { key: string; url: string }) {
588
+ return activeBgPatternType.value === p.key
589
+ }
590
+
591
+ function applyBgPattern(type: string, fg: string, bg: string) {
592
+ if (!activeSlide.value) return
593
+ ;(activeSlide.value as any).bgPatternType = type
594
+ ;(activeSlide.value as any).bgPatternFg = fg
595
+ ;(activeSlide.value as any).bgPatternBg = bg
596
+ ;(activeSlide.value as any).isPattern = true
597
+ const patUrl = buildPatternSvgDataUrl(type, fg, bg)
598
+ activeSlide.value.background = `url("${patUrl}") left top / auto repeat`
599
+ activeSlide.value.bgType = 'image'
600
+ addHistorySnapshot()
601
+ }
602
+
603
+ function selectBgPatternPreset(p: { key: string; url: string }) {
604
+ applyBgPattern(p.key, activeBgPatternFg.value, activeBgPatternBg.value)
605
+ }
606
+
607
+ function onSelectBgPatternFgColor(color: string) {
608
+ const fg = (!color || color === 'transparent' || color === 'none') ? 'transparent' : color
609
+ applyBgPattern(activeBgPatternType.value, fg, activeBgPatternBg.value)
610
+ }
611
+
612
+ function handleBgPatternFgOpacityInput(e: Event) {
613
+ const target = e.target as HTMLInputElement
614
+ let val = parseInt(target.value)
615
+ if (isNaN(val)) val = 100
616
+ val = Math.max(0, Math.min(100, val))
617
+ const hex = parsedBgPatternFg.value.hex === 'transparent' ? '#3B82F6' : parsedBgPatternFg.value.hex
618
+ const fg = val === 0 ? 'transparent' : (val === 100 ? hex : hexToRgba(hex, val))
619
+ applyBgPattern(activeBgPatternType.value, fg, activeBgPatternBg.value)
620
+ }
621
+
622
+ function onSelectBgPatternBgColor(color: string) {
623
+ const bg = (!color || color === 'transparent' || color === 'none') ? 'transparent' : color
624
+ applyBgPattern(activeBgPatternType.value, activeBgPatternFg.value, bg)
625
+ }
626
+
627
+ function handleBgPatternBgOpacityInput(e: Event) {
628
+ const target = e.target as HTMLInputElement
629
+ let val = parseInt(target.value)
630
+ if (isNaN(val)) val = 100
631
+ val = Math.max(0, Math.min(100, val))
632
+ const hex = parsedBgPatternBg.value.hex === 'transparent' ? '#FFFFFF' : parsedBgPatternBg.value.hex
633
+ const bg = val === 0 ? 'transparent' : (val === 100 ? hex : hexToRgba(hex, val))
634
+ applyBgPattern(activeBgPatternType.value, activeBgPatternFg.value, bg)
635
+ }
636
+
183
637
  const bgFileInputRef = ref<HTMLInputElement | null>(null)
184
638
  const uploadBgImage = () => {
185
639
  bgFileInputRef.value?.click()
@@ -215,6 +669,82 @@ const applyBackground = (css: string) => {
215
669
  }
216
670
  }
217
671
 
672
+ type BgFillMode = 'solid' | 'gradient-linear' | 'gradient-radial' | 'gradient-rect' | 'image' | 'pattern'
673
+
674
+ const activeBgTab = ref<BgFillMode>('solid')
675
+
676
+ const isSolidBg = computed(() => activeBgTab.value === 'solid' || (activeBgTab.value as string) === 'color')
677
+ const isGradientBg = computed(() => activeBgTab.value.startsWith('gradient'))
678
+ const isImageBg = computed(() => activeBgTab.value === 'image')
679
+ const isPatternBg = computed(() => activeBgTab.value === 'pattern')
680
+
681
+ const bgGradientEditorRef = ref<any>(null)
682
+
683
+ watch(
684
+ [() => activeSlide.value?.bgType, () => activeSlide.value?.background, () => activeIndex.value],
685
+ () => {
686
+ const bg = activeSlide.value?.background
687
+ const effectiveType = activeSlide.value?.bgType || (bg ? normalizeSlideBackground(bg).bgType : 'color')
688
+ if (effectiveType === 'image' || (typeof bg === 'string' && bg.includes('url('))) {
689
+ const url = extractUrlFromBgCss(bg)
690
+ if (url && !url.startsWith('data:image/svg+xml') && activeSlide.value) {
691
+ ; (activeSlide.value as any)._lastBgImageUrl = url
692
+ ensureBgImageSizeLoaded(url)
693
+ if (!(activeSlide.value as any)?.isPattern) {
694
+ const formatted = formatSlideBgImageCss(url, bgImageMode.value)
695
+ if (typeof bg === 'string' && bg !== formatted) {
696
+ activeSlide.value.background = formatted
697
+ }
698
+ }
699
+ }
700
+ if ((typeof bg === 'string' && bg.includes('data:image/svg+xml')) || (activeSlide.value as any)?.isPattern) {
701
+ activeBgTab.value = 'pattern'
702
+ } else {
703
+ activeBgTab.value = 'image'
704
+ }
705
+ } else if (effectiveType === 'gradient' || (typeof bg === 'string' && bg.includes('gradient('))) {
706
+ if (typeof bg === 'string') {
707
+ if (bg.includes('closest-side') || bg.includes('rect') || bg.includes('ellipse')) activeBgTab.value = 'gradient-rect'
708
+ else if (bg.includes('radial') || bg.includes('circle')) activeBgTab.value = 'gradient-radial'
709
+ else activeBgTab.value = 'gradient-linear'
710
+ } else {
711
+ activeBgTab.value = 'gradient-linear'
712
+ }
713
+ } else {
714
+ if (!(activeBgTab.value === 'image' && activeSlide.value?.bgType === 'image' && !bg)) {
715
+ activeBgTab.value = 'solid'
716
+ }
717
+ }
718
+ },
719
+ { immediate: true }
720
+ )
721
+
722
+ function selectBgTab(tab: BgFillMode) {
723
+ activeBgTab.value = tab
724
+ if (tab === 'solid') {
725
+ bgFillType.value = 'color'
726
+ } else if (tab === 'image') {
727
+ bgFillType.value = 'image'
728
+ } else if (tab === 'pattern') {
729
+ bgFillType.value = 'image'
730
+ } else if (tab.startsWith('gradient-')) {
731
+ bgFillType.value = 'gradient'
732
+ const gType = tab.replace('gradient-', '') as 'linear' | 'radial' | 'rect'
733
+ if (gType === 'linear') {
734
+ applyBackground('linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%)')
735
+ } else if (gType === 'radial') {
736
+ applyBackground('radial-gradient(circle farthest-corner at 50% 50%, #f8fafc 0%, #cbd5e1 100%)')
737
+ } else if (gType === 'rect') {
738
+ applyBackground('radial-gradient(closest-side at 50% 50%, #ffffff 0%, #cbd5e1 100%)')
739
+ }
740
+ nextTick(() => {
741
+ if (bgGradientEditorRef.value?.changeType) {
742
+ bgGradientEditorRef.value.changeType(gType)
743
+ }
744
+ })
745
+ }
746
+ }
747
+
218
748
  const activeGradientCss = computed(() => {
219
749
  const bg = activeSlide.value?.background
220
750
  if (!bg) return 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
@@ -229,6 +759,36 @@ const activeGradientCss = computed(() => {
229
759
  return 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
230
760
  })
231
761
 
762
+ const activeGradientStart = computed(() => {
763
+ const bg = activeSlide.value?.background
764
+ if (typeof bg === 'string' && bg.includes('gradient')) {
765
+ const match = bg.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)/g)
766
+ if (match && match.length > 0) return parseColor(match[0]).hex
767
+ }
768
+ return '#3b82f6'
769
+ })
770
+
771
+ const activeGradientEnd = computed(() => {
772
+ const bg = activeSlide.value?.background
773
+ if (typeof bg === 'string' && bg.includes('gradient')) {
774
+ const match = bg.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)/g)
775
+ if (match && match.length > 1) return parseColor(match[match.length - 1]).hex
776
+ }
777
+ return generateGradientEndColor(activeGradientStart.value)
778
+ })
779
+
780
+ const activeGradientMid = computed(() => {
781
+ const bg = activeSlide.value?.background
782
+ if (typeof bg === 'string' && bg.includes('gradient')) {
783
+ const match = bg.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)/g)
784
+ if (match && match.length > 2) {
785
+ const midIdx = Math.floor(match.length / 2)
786
+ return parseColor(match[midIdx]).hex
787
+ }
788
+ }
789
+ return '#cbd5e1'
790
+ })
791
+
232
792
  const onGradientEditorUpdate = (css: string) => {
233
793
  if (activeSlide.value) {
234
794
  activeSlide.value.background = css
@@ -391,351 +951,441 @@ watch(extractTab, () => {
391
951
  <input ref="bgFileInputRef" type="file" accept="image/*" style="display: none;" @change="onBgFileChange" />
392
952
 
393
953
  <!-- 1. 全局设置 Tab (Global Settings) -->
394
- <div v-if="designActiveTab === 'global'" style="display: flex; flex-direction: column; gap: 14px; padding: 16px;">
954
+ <div v-if="designActiveTab === 'global'" class="global-tab-content">
395
955
  <!-- 画布尺寸 -->
396
- <div class="design-property-row">
397
- <span class="shape-property-label">画布尺寸</span>
398
- <el-select :model-value="slideRatio" @update:model-value="emit('update:slideRatio', $event)" size="small"
399
- style="flex: 1;" class="design-select" placement="bottom-start"
400
- :fallback-placements="['bottom-start', 'bottom', 'top-start']">
401
- <el-option label="宽屏 16:9" value="16:9" />
402
- <el-option label="正宽 4:3" value="4:3" />
403
- <el-option label="宽屏 16:10" value="16:10" />
404
- <el-option label="手机竖屏 9:16 (移动端)" value="9:16" />
405
- <el-option v-if="slideRatio === 'custom'" label="自定义尺寸" value="custom" />
406
- </el-select>
956
+ <div class="global-form-item">
957
+ <div class="global-item-label">画布尺寸</div>
958
+ <el-popover v-model:visible="isRatioPopoverVisible" placement="bottom-start" :width="ratioTriggerWidth"
959
+ trigger="click" popper-class="autofit-popover-panel" :show-arrow="false" :offset="4"
960
+ @before-enter="onRatioPopoverShow">
961
+ <template #reference>
962
+ <div ref="ratioTriggerRef" class="custom-select-trigger" :class="{ 'is-active': isRatioPopoverVisible }">
963
+ <span class="select-trigger-text">{{ currentRatioLabel }}</span>
964
+ <svg viewBox="0 0 1024 1024" width="10" height="10" fill="currentColor" class="select-trigger-arrow"
965
+ :class="{ 'is-open': isRatioPopoverVisible }">
966
+ <path
967
+ d="M831.872 340.777L512 652.672 192.128 340.777a31.968 31.968 0 0 0-45.12 45.312l342.4 333.568a32 32 0 0 0 45.184 0l342.4-333.568a31.968 31.968 0 0 0-45.12-45.312z">
968
+ </path>
969
+ </svg>
970
+ </div>
971
+ </template>
972
+
973
+ <div class="autofit-menu-container">
974
+ <div v-for="opt in ratioOptions" :key="opt.value" class="autofit-menu-item"
975
+ :class="{ 'is-selected': slideRatio === opt.value }" @click="selectRatio(opt.value)">
976
+ <span class="autofit-item-label">{{ opt.label }}</span>
977
+ <svg v-if="slideRatio === opt.value" viewBox="0 0 24 24" width="15" height="15" fill="none"
978
+ stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
979
+ class="autofit-check-icon">
980
+ <polyline points="20 6 9 17 4 12" />
981
+ </svg>
982
+ </div>
983
+ </div>
984
+ </el-popover>
407
985
  </div>
408
986
 
987
+ <!-- 分割线 -->
988
+ <div class="global-divider"></div>
989
+
409
990
  <!-- 字体 -->
410
- <div class="design-property-row">
411
- <span class="shape-property-label">字体</span>
412
- <el-select v-model="globalFontFamily" size="small" style="flex: 1;" class="design-select" filterable
413
- placement="bottom-start" :fallback-placements="['bottom-start', 'bottom', 'top-start']">
414
- <el-option label="默认字体" value="默认字体" />
415
- <el-option v-for="font in SYS_FONTS" :key="font.value" :label="font.label" :value="font.value">
416
- <span :style="{ fontFamily: font.value }">{{ font.label }}</span>
417
- </el-option>
418
- </el-select>
991
+ <div class="global-form-item">
992
+ <div class="global-item-label">字体</div>
993
+ <el-popover v-model:visible="isFontPopoverVisible" placement="bottom-start" :width="fontTriggerWidth"
994
+ trigger="click" popper-class="autofit-popover-panel font-select-popover" :show-arrow="false" :offset="4"
995
+ @before-enter="onFontPopoverShow">
996
+ <template #reference>
997
+ <div ref="fontTriggerRef" class="custom-select-trigger" :class="{ 'is-active': isFontPopoverVisible }">
998
+ <span class="select-trigger-text"
999
+ :style="{ fontFamily: (globalFontFamily && globalFontFamily !== '默认字体') ? globalFontFamily : undefined }">
1000
+ {{ currentFontFamilyLabel }}
1001
+ </span>
1002
+ <svg viewBox="0 0 1024 1024" width="10" height="10" fill="currentColor" class="select-trigger-arrow"
1003
+ :class="{ 'is-open': isFontPopoverVisible }">
1004
+ <path
1005
+ d="M831.872 340.777L512 652.672 192.128 340.777a31.968 31.968 0 0 0-45.12 45.312l342.4 333.568a32 32 0 0 0 45.184 0l342.4-333.568a31.968 31.968 0 0 0-45.12-45.312z">
1006
+ </path>
1007
+ </svg>
1008
+ </div>
1009
+ </template>
1010
+
1011
+ <div class="autofit-menu-container font-menu-scrollable">
1012
+ <div class="autofit-menu-item" :class="{ 'is-selected': !globalFontFamily || globalFontFamily === '默认字体' }"
1013
+ @click="selectFont('默认字体')">
1014
+ <span class="autofit-item-label">默认字体</span>
1015
+ <svg v-if="!globalFontFamily || globalFontFamily === '默认字体'" viewBox="0 0 24 24" width="15" height="15"
1016
+ fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
1017
+ class="autofit-check-icon">
1018
+ <polyline points="20 6 9 17 4 12" />
1019
+ </svg>
1020
+ </div>
1021
+ <div v-for="font in SYS_FONTS" :key="font.value" class="autofit-menu-item"
1022
+ :class="{ 'is-selected': globalFontFamily === font.value }" @click="selectFont(font.value)">
1023
+ <span class="autofit-item-label" :style="{ fontFamily: font.value }">{{ font.label }}</span>
1024
+ <svg v-if="globalFontFamily === font.value" viewBox="0 0 24 24" width="15" height="15" fill="none"
1025
+ stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
1026
+ class="autofit-check-icon">
1027
+ <polyline points="20 6 9 17 4 12" />
1028
+ </svg>
1029
+ </div>
1030
+ </div>
1031
+ </el-popover>
419
1032
  </div>
420
1033
 
421
1034
  <!-- 字体颜色 -->
422
- <div class="design-property-row">
423
- <span class="shape-property-label">字体颜色</span>
1035
+ <div class="global-form-item" style="margin-top: 14px;">
1036
+ <div class="global-item-label">字体颜色</div>
424
1037
  <QuickColorPopover v-model:visible="globalFontColorPopoverVisible" :color="globalFontColor"
425
1038
  v-model:recentColors="quickRecentColors" @select-color="(color) => globalFontColor = color">
426
1039
  <template #reference>
427
- <div class="color-picker-pill-wrapper" style="flex: 1;">
428
- <div class="color-pill-inner" style="height: 32px;">
429
- <div :style="{ background: globalFontColor }" class="color-square-preview"></div>
430
- <span style="font-size: 11px; color: var(--text-primary); font-family: monospace; font-weight: 500;">
431
- {{ formatHexDisplay(globalFontColor) }}
432
- </span>
1040
+ <div class="global-color-pill">
1041
+ <div class="color-pill-left">
1042
+ <div class="color-swatch" :style="{ backgroundColor: globalFontColor }"></div>
1043
+ <span class="color-hex-text">{{ formatHexDisplay(globalFontColor) }}</span>
433
1044
  </div>
1045
+ <div class="color-opacity-text">100%</div>
434
1046
  </div>
435
1047
  </template>
436
1048
  </QuickColorPopover>
1049
+
1050
+ <button class="global-action-btn" style="margin-top: 10px;" @click="applyFontToAll">
1051
+ 全局统一字体
1052
+ </button>
437
1053
  </div>
438
1054
 
439
- <!-- 背景颜色 -->
440
- <div :class="bgFillType === 'color' ? 'design-property-row-start' : 'design-property-row'">
441
- <span class="shape-property-label" :style="bgFillType === 'color' ? 'margin-top: 6px;' : ''">背景颜色</span>
442
- <div style="flex: 1; display: flex; flex-direction: column; gap: 8px;">
443
- <el-select v-model="bgFillType" size="small" style="width: 100%;" class="design-select"
444
- placement="bottom-start" :fallback-placements="['bottom-start', 'bottom', 'top-start']">
445
- <el-option label="纯色填充" value="color" />
446
- <el-option label="渐变填充" value="gradient" />
447
- <el-option label="图片填充" value="image" />
448
- </el-select>
449
-
450
- <!-- 纯色填充色块选择器 (独占一行,不与下拉框挤在一起) -->
451
- <QuickColorPopover v-if="bgFillType === 'color'" v-model:visible="bgColorPopoverVisible"
452
- :color="activeBgColor" v-model:recentColors="quickRecentColors"
453
- @select-color="(color) => activeBgColor = color">
454
- <template #reference>
455
- <div class="color-picker-pill-wrapper" style="width: 100%;">
456
- <div class="color-pill-inner" style="height: 32px;">
457
- <div :style="{ background: activeBgColor }" class="color-square-preview"></div>
458
- <span style="font-size: 11px; color: var(--text-primary); font-family: monospace; font-weight: 500;">
459
- {{ formatHexDisplay(activeBgColor) }}
460
- </span>
461
- </div>
462
- </div>
463
- </template>
464
- </QuickColorPopover>
1055
+ <!-- 分割线 -->
1056
+ <div class="global-divider"></div>
1057
+
1058
+ <!-- 背景色 -->
1059
+ <div class="global-form-item">
1060
+ <div class="global-item-label">背景色</div>
1061
+
1062
+ <!-- 7 个填充类型切换按钮 -->
1063
+ <div class="fill-types-bar">
1064
+ <button type="button" class="fill-type-icon-btn" :class="{ active: isSolidBg }" @click="selectBgTab('solid')"
1065
+ title="纯色填充">
1066
+ <svg viewBox="0 0 18 18" width="18" height="18">
1067
+ <rect x="0.5" y="0.5" width="17" height="17" rx="2"
1068
+ :fill="isSolidBg ? (activeBgColor && activeBgColor !== 'transparent' ? activeBgColor : '#3b82f6') : '#cbd5e1'"
1069
+ :stroke="isSolidBg ? '#1E6FFF' : '#94a3b8'" stroke-width="1" />
1070
+ </svg>
1071
+ </button>
1072
+ <button type="button" class="fill-type-icon-btn" :class="{ active: activeBgTab === 'gradient-linear' }"
1073
+ @click="selectBgTab('gradient-linear')" title="线性渐变">
1074
+ <svg viewBox="0 0 18 18" width="18" height="18">
1075
+ <defs>
1076
+ <linearGradient id="bgFillBarLinear" x1="0" y1="0" x2="0" y2="1">
1077
+ <stop offset="0%" :stop-color="activeBgTab === 'gradient-linear' ? activeGradientStart : '#94a3b8'" />
1078
+ <stop offset="100%" :stop-color="activeBgTab === 'gradient-linear' ? activeGradientEnd : '#ffffff'" />
1079
+ </linearGradient>
1080
+ </defs>
1081
+ <rect x="0.5" y="0.5" width="17" height="17" rx="2" fill="url(#bgFillBarLinear)"
1082
+ :stroke="activeBgTab === 'gradient-linear' ? '#1E6FFF' : '#94a3b8'" stroke-width="1" />
1083
+ </svg>
1084
+ </button>
1085
+ <button type="button" class="fill-type-icon-btn" :class="{ active: activeBgTab === 'gradient-radial' }"
1086
+ @click="selectBgTab('gradient-radial')" title="径向渐变">
1087
+ <svg viewBox="0 0 18 18" width="18" height="18">
1088
+ <defs>
1089
+ <radialGradient id="bgFillBarRadial" cx="50%" cy="50%" r="50%">
1090
+ <stop offset="15%" :stop-color="activeBgTab === 'gradient-radial' ? activeGradientEnd : '#ffffff'" />
1091
+ <stop offset="100%"
1092
+ :stop-color="activeBgTab === 'gradient-radial' ? activeGradientStart : '#cbd5e1'" />
1093
+ </radialGradient>
1094
+ </defs>
1095
+ <rect x="0.5" y="0.5" width="17" height="17" rx="2" fill="url(#bgFillBarRadial)"
1096
+ :stroke="activeBgTab === 'gradient-radial' ? '#1E6FFF' : '#94a3b8'" stroke-width="1" />
1097
+ </svg>
1098
+ </button>
1099
+ <button type="button" class="fill-type-icon-btn" :class="{ active: activeBgTab === 'gradient-rect' }"
1100
+ @click="selectBgTab('gradient-rect')" title="菱形渐变">
1101
+ <svg viewBox="0 0 18 18" width="18" height="18">
1102
+ <defs>
1103
+ <radialGradient id="bgFillBarRect" cx="50%" cy="50%" r="50%">
1104
+ <stop offset="0%" :stop-color="activeBgTab === 'gradient-rect' ? activeGradientEnd : '#ffffff'" />
1105
+ <stop offset="60%" :stop-color="activeBgTab === 'gradient-rect' ? activeGradientMid : '#e2e8f0'" />
1106
+ <stop offset="100%" :stop-color="activeBgTab === 'gradient-rect' ? activeGradientStart : '#94a3b8'" />
1107
+ </radialGradient>
1108
+ </defs>
1109
+ <rect x="0.5" y="0.5" width="17" height="17" rx="2" fill="url(#bgFillBarRect)"
1110
+ :stroke="activeBgTab === 'gradient-rect' ? '#1E6FFF' : '#94a3b8'" stroke-width="1" />
1111
+ </svg>
1112
+ </button>
1113
+ <button type="button" class="fill-type-icon-btn" :class="{ active: activeBgTab === 'image' }"
1114
+ @click="selectBgTab('image')" title="图片填充">
1115
+ <svg viewBox="0 0 18 18" width="18" height="18" fill="none">
1116
+ <rect x="0.5" y="0.5" width="17" height="17" rx="2"
1117
+ :stroke="activeBgTab === 'image' ? '#1E6FFF' : '#94a3b8'" stroke-width="1" fill="#ffffff" />
1118
+ <circle cx="6" cy="6" r="1.5" :fill="activeBgTab === 'image' ? '#1E6FFF' : '#94a3b8'" />
1119
+ <path d="M2.5 14L7 9.5L10.5 13L12.5 11L15.5 14" :stroke="activeBgTab === 'image' ? '#1E6FFF' : '#94a3b8'"
1120
+ stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" />
1121
+ </svg>
1122
+ </button>
1123
+ <button type="button" class="fill-type-icon-btn" :class="{ active: activeBgTab === 'pattern' }"
1124
+ @click="selectBgTab('pattern')" title="图案填充">
1125
+ <svg viewBox="0 0 18 18" width="18" height="18">
1126
+ <defs>
1127
+ <pattern id="bgFillBarPattern" width="4" height="4" patternUnits="userSpaceOnUse"
1128
+ patternTransform="rotate(45)">
1129
+ <line x1="0" y1="0" x2="0" y2="4" :stroke="activeBgTab === 'pattern' ? '#1E6FFF' : '#94a3b8'"
1130
+ stroke-width="1" />
1131
+ </pattern>
1132
+ </defs>
1133
+ <rect x="0.5" y="0.5" width="17" height="17" rx="2" fill="url(#bgFillBarPattern)"
1134
+ :stroke="activeBgTab === 'pattern' ? '#1E6FFF' : '#94a3b8'" stroke-width="1" />
1135
+ </svg>
1136
+ </button>
465
1137
  </div>
466
- </div>
467
1138
 
468
- <!-- 渐变填充专业设置 (与背景颜色同一级) -->
469
- <template v-if="bgFillType === 'gradient'">
470
- <GradientEditor
471
- :model-value="activeGradientCss"
472
- :recent-colors="quickRecentColors"
473
- :theme-colors="globalThemeColor"
474
- @update:model-value="onGradientEditorUpdate"
475
- @change="onGradientEditorChange"
476
- />
477
- <div class="design-property-row-start" style="margin-top: 2px;">
478
- <span class="shape-property-label" style="margin-top: 4px;">渐变预设</span>
479
- <div class="color-picker-grid" style="flex: 1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;">
480
- <div v-for="preset in gradientPresets" :key="preset.name" class="color-option"
481
- :style="{ background: preset.css, height: '24px', borderRadius: '4px', cursor: 'pointer', border: '1px solid rgba(0,0,0,0.1)' }"
482
- @click="applyBackground(preset.css)" :title="preset.name"></div>
1139
+ <!-- 纯色填充卡片 (对齐 SidebarShapeFormatPanel.vue) -->
1140
+ <template v-if="isSolidBg">
1141
+ <div class="full-color-pill">
1142
+ <QuickColorPopover v-model:visible="bgColorPopoverVisible" :color="activeBgHex" :alpha="displayBgOpacity"
1143
+ v-model:recentColors="quickRecentColors" @select-color="onSelectBgColor">
1144
+ <template #reference>
1145
+ <div class="color-left-info">
1146
+ <div class="color-square-preview" :style="{ backgroundColor: activeBgColor }"></div>
1147
+ <span class="color-hex-text">{{ formatHexDisplay(activeBgColor) }}</span>
1148
+ </div>
1149
+ </template>
1150
+ </QuickColorPopover>
1151
+ <div class="color-opacity-input-wrapper" @click.stop title="不透明度 (0%为完全透明,100%为完全不透明)">
1152
+ <input type="number" class="color-opacity-num-input" :value="displayBgOpacity" min="0" max="100"
1153
+ @input="handleBgOpacityInput" @keydown.enter="($event.target as HTMLElement).blur()" />
1154
+ <span class="percent-unit">%</span>
1155
+ </div>
483
1156
  </div>
484
- </div>
485
- </template>
486
-
487
- <!-- 图片填充模式 (与背景颜色同一级) -->
488
- <template v-else-if="bgFillType === 'image'">
489
- <div class="design-property-row">
490
- <span class="shape-property-label">图片排版</span>
491
- <el-select v-model="bgImageMode" size="small" style="flex: 1;" class="design-select">
492
- <el-option label="拉伸铺满" value="stretch" />
493
- <el-option label="平铺" value="tile" />
494
- </el-select>
495
- </div>
496
- <div class="design-property-row-start">
497
- <span class="shape-property-label" style="margin-top: 6px;">背景图片</span>
498
- <div class="bg-image-uploader-box"
499
- style="flex: 1; height: 72px; border: 1px dashed var(--border-color); border-radius: 6px; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; background: #fafafa; cursor: pointer;"
500
- @click="uploadBgImage">
1157
+ </template>
1158
+
1159
+ <!-- 渐变填充卡片 -->
1160
+ <template v-else-if="isGradientBg">
1161
+ <GradientEditor ref="bgGradientEditorRef" :hide-type-buttons="true" :model-value="activeGradientCss"
1162
+ :recent-colors="quickRecentColors" :theme-colors="globalThemeColor"
1163
+ @update:model-value="onGradientEditorUpdate" @change="onGradientEditorChange" />
1164
+ </template>
1165
+
1166
+ <!-- 图片填充卡片 (默认及选中态) -->
1167
+ <div v-else-if="isImageBg" class="global-image-card">
1168
+ <div class="image-mode-row">
1169
+ <el-popover v-model:visible="isImageModePopoverVisible" placement="bottom-start"
1170
+ :width="imageModeTriggerWidth" trigger="click" popper-class="autofit-popover-panel" :show-arrow="false"
1171
+ :offset="4" @before-enter="onImageModePopoverShow">
1172
+ <template #reference>
1173
+ <div ref="imageModeTriggerRef" class="custom-select-trigger image-mode-trigger"
1174
+ :class="{ 'is-active': isImageModePopoverVisible }">
1175
+ <span class="select-trigger-text">{{ currentImageModeLabel }}</span>
1176
+ <svg viewBox="0 0 1024 1024" width="10" height="10" fill="currentColor" class="select-trigger-arrow"
1177
+ :class="{ 'is-open': isImageModePopoverVisible }">
1178
+ <path
1179
+ d="M831.872 340.777L512 652.672 192.128 340.777a31.968 31.968 0 0 0-45.12 45.312l342.4 333.568a32 32 0 0 0 45.184 0l342.4-333.568a31.968 31.968 0 0 0-45.12-45.312z">
1180
+ </path>
1181
+ </svg>
1182
+ </div>
1183
+ </template>
1184
+
1185
+ <div class="autofit-menu-container">
1186
+ <div v-for="opt in imageModeOptions" :key="opt.value" class="autofit-menu-item"
1187
+ :class="{ 'is-selected': bgImageMode === opt.value }" @click="selectImageMode(opt.value)">
1188
+ <span class="autofit-item-label">{{ opt.label }}</span>
1189
+ <svg v-if="bgImageMode === opt.value" viewBox="0 0 24 24" width="15" height="15" fill="none"
1190
+ stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
1191
+ class="autofit-check-icon">
1192
+ <polyline points="20 6 9 17 4 12" />
1193
+ </svg>
1194
+ </div>
1195
+ </div>
1196
+ </el-popover>
1197
+
1198
+ <button class="image-fit-btn" @click="bgImageMode = 'stretch'" title="铺满模式">
1199
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3"
1200
+ stroke-linecap="round" stroke-linejoin="round">
1201
+ <path d="M2 5V2h3M14 5V2h-3M2 11v3h3M14 11v3h-3" />
1202
+ </svg>
1203
+ </button>
1204
+ </div>
1205
+
1206
+ <!-- 棋盘格预览框 -->
1207
+ <div class="checkerboard-preview-box" @click="uploadBgImage">
501
1208
  <template v-if="bgImageUrl">
502
- <img :src="bgImageUrl" style="width: 100%; height: 100%; object-fit: contain; pointer-events: none;" />
503
- <div class="bg-image-hover-overlay"
504
- style="position: absolute; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; gap: 8px;"
505
- @click.stop>
506
- <button class="design-action-btn"
507
- style="padding: 3px 8px; font-size: 11px; background: #fff; color: #333; border: none; border-radius: 4px; cursor: pointer;"
508
- @click="uploadBgImage">更换</button>
509
- <button class="design-action-btn"
510
- style="padding: 3px 8px; font-size: 11px; background: #ef4444; color: #fff; border: none; border-radius: 4px; cursor: pointer;"
511
- @click="bgImageUrl = ''">删除</button>
1209
+ <div class="bg-img-preview" :style="bgImagePreviewStyle"></div>
1210
+ <div class="bg-img-hover-actions" @click.stop>
1211
+ <button class="bg-img-action-btn" @click="uploadBgImage">更换图片</button>
1212
+ <button class="bg-img-action-btn btn-del" @click="bgImageUrl = ''">删除</button>
512
1213
  </div>
513
1214
  </template>
514
1215
  <template v-else>
515
- <div
516
- style="display: flex; flex-direction: column; align-items: center; gap: 4px; color: var(--text-muted);">
517
- <span style="font-size: 11px;">点击上传图片</span>
518
- </div>
1216
+ <button class="select-img-btn" @click.stop="uploadBgImage">
1217
+ 选择图片
1218
+ </button>
519
1219
  </template>
520
1220
  </div>
521
1221
  </div>
522
- </template>
523
-
524
- <!-- 主题色 -->
525
- <div class="design-property-row">
526
- <span class="shape-property-label">主题色</span>
527
- <div style="flex: 1; display: flex; align-items: center;">
528
- <el-popover placement="bottom-end" :width="220" trigger="click" popper-class="theme-palette-popover"
529
- v-model:visible="themePalettePopoverVisible">
530
- <template #reference>
531
- <div class="theme-color-trigger">
532
- <div class="theme-color-preview-list">
533
- <div v-for="(color, cIdx) in globalThemeColor" :key="cIdx" :style="{ background: color }"
534
- class="theme-preview-rect">
1222
+
1223
+ <!-- 图案填充卡片 -->
1224
+ <template v-else-if="isPatternBg">
1225
+ <div class="pattern-fill-container">
1226
+ <div class="pattern-presets-grid">
1227
+ <div v-for="p in PATTERN_PRESETS" :key="p.key" class="pattern-preset-card"
1228
+ :class="{ active: isBgPatternPresetActive(p) }" :title="p.name"
1229
+ :style="{ backgroundImage: `url('${getBgPatternPresetPreviewUrl(p)}')` }"
1230
+ @click="selectBgPatternPreset(p)"></div>
1231
+ </div>
1232
+
1233
+ <!-- 前景 / 背景 颜色选择 (对齐 PPT / WPS 图案填充) -->
1234
+ <div class="pattern-colors-section">
1235
+ <div class="pattern-color-row">
1236
+ <span class="pattern-color-label">前景</span>
1237
+ <div class="full-color-pill" style="flex: 1;">
1238
+ <QuickColorPopover v-model:visible="bgPatternFgPopoverVisible"
1239
+ :color="parsedBgPatternFg.hex === 'transparent' ? '#3B82F6' : parsedBgPatternFg.hex"
1240
+ :alpha="parsedBgPatternFg.alpha" v-model:recentColors="quickRecentColors"
1241
+ @select-color="onSelectBgPatternFgColor">
1242
+ <template #reference>
1243
+ <div class="color-left-info">
1244
+ <div class="color-square-preview"
1245
+ :style="{ backgroundColor: parsedBgPatternFg.hex === 'transparent' ? 'transparent' : parsedBgPatternFg.hex }">
1246
+ </div>
1247
+ <span class="color-hex-text">{{ formatHexDisplay(parsedBgPatternFg.hex) }}</span>
1248
+ </div>
1249
+ </template>
1250
+ </QuickColorPopover>
1251
+ <div class="color-opacity-input-wrapper" @click.stop title="前景不透明度">
1252
+ <input type="number" class="color-opacity-num-input" :value="parsedBgPatternFg.alpha" min="0"
1253
+ max="100" @input="handleBgPatternFgOpacityInput"
1254
+ @keydown.enter="($event.target as HTMLElement).blur()" />
1255
+ <span class="percent-unit">%</span>
535
1256
  </div>
536
1257
  </div>
537
- <span style="font-size: 10px; color: var(--text-muted);">▼</span>
538
1258
  </div>
539
- </template>
540
- <div style="display: flex; flex-direction: column; gap: 10px; padding: 4px 0;">
541
- <div v-for="cIdx in 5" :key="cIdx"
542
- style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
543
- <span style="font-size: 12px; color: var(--text-secondary); width: 64px;">色彩 {{ cIdx }}</span>
544
- <QuickColorPopover v-model:visible="globalThemeColorPopoverVisible[cIdx - 1]"
545
- :color="globalThemeColor[cIdx - 1]" v-model:recentColors="quickRecentColors" placement="left-start"
546
- @select-color="(color) => globalThemeColor[cIdx - 1] = color"
547
- @color-picker-show="isCustomColorPickerOpen = true"
548
- @color-picker-hide="() => { isCustomColorPickerOpen = false }">
549
- <template #reference>
550
- <div class="color-picker-pill-wrapper" style="width: 100%;">
551
- <div class="color-pill-inner" style="height: 28px;">
552
- <div
553
- style="width: 12px; height: 12px; border: 1px solid rgba(0,0,0,0.1); border-radius: 2px; flex-shrink: 0;"
554
- :style="{ background: globalThemeColor[cIdx - 1] }"></div>
555
- <span
556
- style="font-size: 11px; color: var(--text-primary); font-family: monospace; font-weight: 500;">
557
- {{ formatHexDisplay(globalThemeColor[cIdx - 1]) }}
558
- </span>
559
- </div>
560
- </div>
561
- </template>
562
- </QuickColorPopover>
563
- </div>
564
- </div>
565
- </el-popover>
566
- </div>
567
- </div>
568
1259
 
569
- <!-- 边框颜色 -->
570
- <div class="design-property-row">
571
- <span class="shape-property-label">边框颜色</span>
572
- <QuickColorPopover v-model:visible="globalBorderColorPopoverVisible" :color="globalBorderColor"
573
- v-model:recentColors="quickRecentColors" @select-color="(color) => globalBorderColor = color">
574
- <template #reference>
575
- <div class="color-picker-pill-wrapper" style="flex: 1;">
576
- <div class="color-pill-inner" style="height: 32px;">
577
- <div :style="{ background: globalBorderColor }" class="color-square-preview"></div>
578
- <span style="font-size: 11px; color: var(--text-primary); font-family: monospace; font-weight: 500;">
579
- {{ formatHexDisplay(globalBorderColor) }}
580
- </span>
1260
+ <div class="pattern-color-row">
1261
+ <span class="pattern-color-label">背景</span>
1262
+ <div class="full-color-pill" style="flex: 1;">
1263
+ <QuickColorPopover v-model:visible="bgPatternBgPopoverVisible"
1264
+ :color="parsedBgPatternBg.hex === 'transparent' ? '#FFFFFF' : parsedBgPatternBg.hex"
1265
+ :alpha="parsedBgPatternBg.alpha" v-model:recentColors="quickRecentColors"
1266
+ @select-color="onSelectBgPatternBgColor">
1267
+ <template #reference>
1268
+ <div class="color-left-info">
1269
+ <div class="color-square-preview"
1270
+ :style="{ backgroundColor: parsedBgPatternBg.hex === 'transparent' ? 'transparent' : parsedBgPatternBg.hex }">
1271
+ </div>
1272
+ <span class="color-hex-text">{{ formatHexDisplay(parsedBgPatternBg.hex) }}</span>
1273
+ </div>
1274
+ </template>
1275
+ </QuickColorPopover>
1276
+ <div class="color-opacity-input-wrapper" @click.stop title="背景不透明度">
1277
+ <input type="number" class="color-opacity-num-input" :value="parsedBgPatternBg.alpha" min="0"
1278
+ max="100" @input="handleBgPatternBgOpacityInput"
1279
+ @keydown.enter="($event.target as HTMLElement).blur()" />
1280
+ <span class="percent-unit">%</span>
1281
+ </div>
1282
+ </div>
581
1283
  </div>
582
1284
  </div>
583
- </template>
584
- </QuickColorPopover>
585
- </div>
586
-
587
- <!-- 边框样式 -->
588
- <div class="design-property-row">
589
- <span class="shape-property-label">边框样式</span>
590
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: center; flex: 1;">
591
- <el-select v-model="globalBorderStyle" size="small" style="flex: 1;" class="design-select"
592
- placement="bottom-start" :fallback-placements="['bottom-start', 'bottom', 'top-start']">
593
- <el-option value="solid" label="──────" />
594
- <el-option value="dashed" label="------" />
595
- <el-option value="dotted" label="······" />
596
- <el-option value="none" label="无" />
597
- </el-select>
598
- <div class="shape-border-width-wrapper"
599
- style="height: 32px; border: none; border-radius: 4px; padding: 0 8px; display: flex; align-items: center; box-sizing: border-box; background: #F5F6F7;">
600
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"
601
- style="margin-right: 4px; color: var(--text-secondary); flex-shrink: 0;">
602
- <line x1="3" y1="6" x2="21" y2="6" />
603
- <line x1="3" y1="12" x2="21" y2="12" />
604
- <line x1="3" y1="18" x2="21" y2="18" />
605
- </svg>
606
- <input type="number" v-model.number="globalBorderWidth" :min="0" :max="20" class="shape-num-field"
607
- style="width: 100%; border: none; background: transparent; outline: none; text-align: left; font-size: 12px; color: var(--text-primary); -moz-appearance: textfield; appearance: none;" />
608
1285
  </div>
609
- </div>
610
- </div>
1286
+ </template>
611
1287
 
612
- <!-- 4 个操作按钮网格 (2x2) -->
613
- <div class="design-btn-grid">
614
- <el-button type="primary" size="small" class="design-action-btn-primary"
615
- @click="applyBackgroundToAll">背景色应用到全部</el-button>
616
- <el-button type="primary" size="small" class="design-action-btn-primary"
617
- @click="applyThemeToAll">主题色应用到全部</el-button>
618
- <el-button type="primary" size="small" class="design-action-btn-secondary"
619
- @click="applyFontToAll">全局统一字体</el-button>
620
- <el-button type="primary" size="small" class="design-action-btn-secondary"
621
- @click="openThemeExtractor">从幻灯片提取主题</el-button>
1288
+ <!-- 背景色应用到全部 按钮 -->
1289
+ <button class="global-action-btn" style="margin-top: 10px;" @click="applyBackgroundToAll">
1290
+ 背景色应用到全部
1291
+ </button>
622
1292
  </div>
623
1293
  </div>
624
1294
 
625
1295
  <!-- 2. 主题设置 Tab (Theme Settings) -->
626
- <div v-else-if="designActiveTab === 'theme'"
627
- style="display: flex; flex-direction: column; gap: 14px; padding: 16px;">
628
- <StyleBuilderDialog v-if="styleBuilderMounted" :visible="styleBuilderVisible" @update:visible="emit('update:styleBuilderVisible', $event)"
629
- :target-mode="styleBuilderTargetMode" :initial-recipe-id="styleBuilderInitialRecipeId"
630
- :is-new-custom="styleBuilderIsNewCustom" />
631
-
632
- <!-- 主题样式套件 -->
633
- <div class="design-section" style="padding: 0;">
634
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
635
- <div class="section-title" style="margin: 0; font-weight: bold; color: var(--text-primary);">
636
- 主题样式套件
637
- </div>
638
- <span style="font-size: 11px; color: #6366f1; cursor: pointer; font-weight: 500;"
639
- @click="emit('openStyleBuilder', isMasterEditingView ? 'all' : 'single', 'new-custom', true)">
640
- + 定制主题
641
- </span>
642
- </div>
1296
+ <div v-else-if="designActiveTab === 'theme'" class="theme-tab-content">
1297
+ <StyleBuilderDialog v-if="styleBuilderMounted" :visible="styleBuilderVisible"
1298
+ @update:visible="emit('update:styleBuilderVisible', $event)" :target-mode="styleBuilderTargetMode"
1299
+ :initial-recipe-id="styleBuilderInitialRecipeId" :is-new-custom="styleBuilderIsNewCustom" />
1300
+
1301
+ <div class="theme-card-list">
1302
+ <div v-for="recipe in builtinStyleRecipes" :key="recipe.id" class="theme-recipe-card"
1303
+ :class="{ 'is-active': currentActiveThemeId === recipe.id }" @click="handleSelectTheme(recipe)">
1304
+ <!-- Card Header: Title + More Actions Menu -->
1305
+ <div class="theme-card-header">
1306
+ <span class="theme-name-text">{{ recipe.name }}</span>
1307
+ <el-popover trigger="click" placement="left-start" popper-class="theme-actions-popper" :width="156"
1308
+ :offset="175" :show-arrow="false" :teleported="true">
1309
+ <template #reference>
1310
+ <button class="theme-more-btn" @click.stop title="更多选项">
1311
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
1312
+ <circle cx="8" cy="3" r="1.5" />
1313
+ <circle cx="8" cy="8" r="1.5" />
1314
+ <circle cx="8" cy="13" r="1.5" />
1315
+ </svg>
1316
+ </button>
1317
+ </template>
643
1318
 
644
- <div class="style-recipes-grid" style="display: flex; flex-direction: column; gap: 10px;">
645
- <div v-for="recipe in builtinStyleRecipes" :key="recipe.id" class="recipe-theme-card" :class="{
646
- 'active-slide-theme': currentActiveThemeId === recipe.id
647
- }" :style="{ backgroundColor: recipe.backgroundColor || '#ffffff' }">
648
-
649
- <!-- Card Header -->
650
- <div class="recipe-card-header">
651
- <div style="display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1;">
652
- <span class="recipe-theme-name" :style="{ color: recipe.textStyles?.title?.color || '#0f172a' }">
653
- {{ recipe.name }}
654
- </span>
655
- <span v-if="currentActiveThemeId === recipe.id" class="current-slide-pill">
656
- {{ isMasterEditingView ? '当前母版' : '当前页' }}
657
- </span>
658
- </div>
659
- <div v-if="!isMasterEditingView" class="recipe-usage-badge" :class="getThemeUsageLabel(recipe.id).type">
660
- {{ getThemeUsageLabel(recipe.id).text }}
661
- </div>
662
- </div>
1319
+ <div class="theme-menu-list" @click.stop>
1320
+ <div class="theme-menu-item" @click="handleEditTheme(recipe)">
1321
+ <svg class="menu-item-icon" viewBox="0 0 16 16" width="14" height="14" fill="none"
1322
+ stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
1323
+ <rect x="2" y="2" width="12" height="12" rx="2" />
1324
+ <path d="M5 5h6M5 8h4M5 11h2" />
1325
+ </svg>
1326
+ <span>编辑</span>
1327
+ </div>
663
1328
 
664
- <!-- Preview Area -->
665
- <div class="recipe-card-preview" @click="emit('applyStyleRecipeToSlides', recipe, false)">
666
- <div class="recipe-font-preview"
667
- :style="{ fontFamily: recipe.styleFonts?.titleFont || 'PingFang SC', color: recipe.textStyles?.title?.color || '#1e293b' }">
668
- <span style="font-size: 16px; font-weight: 700;">Aa</span>
669
- <span style="font-size: 11px; opacity: 0.8; margin-left: 6px;">{{ recipe.styleFonts?.titleFont || '默认字体'
670
- }}</span>
671
- </div>
672
- <!-- 8 Color Palette Strip -->
673
- <div class="recipe-palette-strip">
674
- <div v-for="(color, cIdx) in recipe.styleColors" :key="cIdx" class="palette-strip-block"
675
- :style="{ backgroundColor: color }"></div>
1329
+ <div class="theme-menu-item" @click="handleDuplicateTheme(recipe)">
1330
+ <svg class="menu-item-icon" viewBox="0 0 16 16" width="14" height="14" fill="none"
1331
+ stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
1332
+ <rect x="5" y="5" width="9" height="9" rx="1.5" />
1333
+ <path d="M11 2H3.5A1.5 1.5 0 0 0 2 3.5V11" />
1334
+ </svg>
1335
+ <span>复制</span>
1336
+ </div>
1337
+
1338
+ <div class="theme-menu-item" @click="handleApplyToAllSlides(recipe)">
1339
+ <svg class="menu-item-icon" viewBox="0 0 16 16" width="14" height="14" fill="none"
1340
+ stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
1341
+ <rect x="2" y="2" width="12" height="12" rx="2" />
1342
+ <path d="M8 5v5M6 8l2 2 2-2" />
1343
+ </svg>
1344
+ <span>应用到所有幻灯片</span>
1345
+ </div>
1346
+
1347
+ <div class="theme-menu-item item-danger" @click="handleDeleteTheme(recipe)">
1348
+ <svg class="menu-item-icon" viewBox="0 0 16 16" width="14" height="14" fill="none"
1349
+ stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
1350
+ <path
1351
+ d="M3 4h10M6 4V2.5A1.5 1.5 0 0 1 7.5 1h1A1.5 1.5 0 0 1 10 2.5V4M4 4l.8 9.2A1.5 1.5 0 0 0 6.3 14.5h3.4a1.5 1.5 0 0 0 1.5-1.3L12 4" />
1352
+ </svg>
1353
+ <span>删除</span>
1354
+ </div>
1355
+
1356
+ <div class="theme-menu-divider"></div>
1357
+
1358
+ <div class="theme-menu-footer">
1359
+ 用于 {{ getThemeSlideCount(recipe.id) }} 张幻灯片
1360
+ </div>
676
1361
  </div>
1362
+ </el-popover>
1363
+ </div>
1364
+
1365
+ <!-- Card Inner Preview Box -->
1366
+ <div class="theme-preview-box" :style="{ backgroundColor: recipe.backgroundColor || '#f8fafc' }">
1367
+ <div class="theme-font-row">
1368
+ <span class="font-preview-aa" :style="{
1369
+ color: getThemeFontColors(recipe).aa,
1370
+ fontFamily: recipe.styleFonts?.titleFont || 'SourceHanSansCN'
1371
+ }">
1372
+ Aa
1373
+ </span>
1374
+ <span class="font-preview-name" :style="{
1375
+ color: getThemeFontColors(recipe).font,
1376
+ fontFamily: recipe.styleFonts?.titleFont || 'SourceHanSansCN'
1377
+ }">
1378
+ {{ fontLabel(recipe.styleFonts?.titleFont) }}
1379
+ </span>
677
1380
  </div>
678
-
679
- <!-- Quick Actions -->
680
- <div class="recipe-card-actions">
681
- <template v-if="isMasterEditingView">
682
- <button class="recipe-act-btn" @click.stop="emit('applyStyleRecipeToSlides', recipe, false)"
683
- title="仅应用到当前选中的母版及下属版式">
684
- ⚡ 当前母版
685
- </button>
686
- <button class="recipe-act-btn" @click.stop="emit('applyStyleRecipeToSlides', recipe, true)"
687
- title="应用到所有母版">
688
- 🌐 全部母版
689
- </button>
690
- <button class="recipe-act-btn btn-edit" @click.stop="emit('openStyleBuilder', 'all', recipe.id)"
691
- title="进入 Style Builder 编辑该主题">
692
- ✏️ 编辑
693
- </button>
694
- </template>
695
- <template v-else>
696
- <button class="recipe-act-btn" @click.stop="emit('applyStyleRecipeToSlides', recipe, false)"
697
- :title="`仅应用到第 ${activeIndex + 1} 页`">
698
- ⚡ 仅当前页 (P{{ activeIndex + 1 }})
699
- </button>
700
- <button class="recipe-act-btn" @click.stop="emit('applyStyleRecipeToSlides', recipe, true)"
701
- title="应用到全部幻灯片">
702
- 🌐 全部幻灯片
703
- </button>
704
- <button class="recipe-act-btn btn-edit" @click.stop="emit('openStyleBuilder', 'all', recipe.id)"
705
- title="进入 Style Builder 编辑该主题">
706
- ✏️ 编辑
707
- </button>
708
- </template>
709
- </div>
710
- </div>
711
- </div>
712
- </div>
713
-
714
- <!-- 预置主题列表 -->
715
- <div v-if="presetThemes && presetThemes.length > 0" class="design-section" style="padding: 0; margin-top: 10px;">
716
- <div class="section-title" style="margin-bottom: 12px; font-weight: bold; color: var(--text-primary);">预置配色主题
717
- </div>
718
- <div class="preset-themes-grid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">
719
- <div v-for="preset in presetThemes" :key="preset.name" class="preset-theme-card"
720
- :style="{ background: preset.bgColor }" @click="applyPresetTheme(preset)"
721
- style="width: 100%; box-sizing: border-box; padding: 10px 8px; border-radius: 8px; border: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 6px; cursor: pointer;">
722
- <div class="preset-theme-title" :style="{ fontFamily: preset.fontFamily, color: preset.fontColor }"
723
- style="font-size: 13px; font-weight: bold; margin: 0; text-align: left;">
724
- 文字 Aa
725
- </div>
726
- <div class="preset-theme-palette"
727
- style="display: flex; gap: 2px; border-radius: 2px; overflow: hidden; height: 8px;">
728
- <div v-for="color in preset.palette" :key="color" :style="{ background: color, flex: 1, height: '100%' }">
729
- </div>
730
- </div>
731
- <div class="preset-theme-name" :style="{ color: preset.isDark ? '#cbd5e1' : 'var(--text-muted)' }"
732
- style="font-size: 10px; margin-top: auto; opacity: 0.8; text-align: left;">
733
- {{ preset.name }}
734
- </div>
735
- </div>
736
- </div>
737
- </div>
738
- </div>
1381
+ <div class="theme-palette-strip">
1382
+ <div v-for="(color, cIdx) in (recipe.styleColors || []).slice(0, 8)" :key="cIdx"
1383
+ class="palette-color-block" :style="{ backgroundColor: color }"></div>
1384
+ </div>
1385
+ </div>
1386
+ </div>
1387
+ </div>
1388
+ </div>
739
1389
 
740
1390
  <!-- 3. 版式设置 Tab (Layout Settings) -->
741
1391
  <div v-else-if="designActiveTab === 'layout' && !isMasterEditingView"
@@ -748,7 +1398,9 @@ watch(extractTab, () => {
748
1398
 
749
1399
  <div v-for="masterGroup in masterGroupedLayouts" :key="masterGroup.id" class="preset-layout-master-group"
750
1400
  style="margin-bottom: 16px;">
751
- <div style="font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 8px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="masterGroup.name">
1401
+ <div
1402
+ style="font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 8px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"
1403
+ :title="masterGroup.name">
752
1404
  {{ masterGroup.name }}
753
1405
  </div>
754
1406
  <div class="preset-layout-grid"
@@ -1172,156 +1824,729 @@ watch(extractTab, () => {
1172
1824
  font-size: 12px;
1173
1825
  }
1174
1826
 
1175
- /* Style Builder Theme Recipe Cards in Sidebar */
1176
- .recipe-theme-card {
1177
- width: 100%;
1178
- box-sizing: border-box;
1179
- border-radius: 10px;
1180
- border: 1.5px solid var(--border-color, #e2e8f0);
1181
- padding: 12px;
1827
+ /* Global Tab New UI Styles (Matching Reference Screenshot) */
1828
+ .global-tab-content {
1829
+ padding: 16px 15px;
1182
1830
  display: flex;
1183
1831
  flex-direction: column;
1184
- gap: 10px;
1185
- transition: all 0.2s ease;
1186
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
1187
1832
  }
1188
1833
 
1189
- .recipe-theme-card:hover {
1190
- border-color: #a5b4fc;
1191
- box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1);
1834
+ .global-form-item {
1835
+ display: flex;
1836
+ flex-direction: column;
1837
+ gap: 6px;
1192
1838
  }
1193
1839
 
1194
- .recipe-theme-card.active-slide-theme {
1195
- border-color: #6366f1;
1196
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.25);
1840
+ .global-item-label {
1841
+ font-size: 13px;
1842
+ font-weight: 500;
1843
+ color: #374151;
1844
+ line-height: 1.2;
1197
1845
  }
1198
1846
 
1199
- .recipe-card-header {
1847
+ .global-select-full {
1848
+ width: 100%;
1849
+
1850
+ :deep(.el-select__wrapper) {
1851
+ background-color: #ffffff !important;
1852
+ border: 1px solid #e2e8f0 !important;
1853
+ border-radius: 6px !important;
1854
+ box-shadow: none !important;
1855
+ height: 32px !important;
1856
+ min-height: 32px !important;
1857
+ line-height: 32px !important;
1858
+ padding: 0 10px !important;
1859
+ transition: all 0.15s ease !important;
1860
+
1861
+ &:hover {
1862
+ border-color: #cbd5e1 !important;
1863
+ }
1864
+
1865
+ &.is-focused {
1866
+ border-color: #1e6fff !important;
1867
+ box-shadow: 0 0 0 2px rgba(30, 111, 255, 0.1) !important;
1868
+ }
1869
+ }
1870
+
1871
+ :deep(.el-select__selected-item) {
1872
+ font-size: 13px !important;
1873
+ font-weight: 500 !important;
1874
+ color: #1e293b !important;
1875
+ }
1876
+ }
1877
+
1878
+ .custom-select-trigger {
1200
1879
  display: flex;
1201
1880
  align-items: center;
1202
1881
  justify-content: space-between;
1203
- gap: 8px;
1882
+ width: 100%;
1883
+ height: 34px;
1884
+ padding: 0 10px;
1885
+ background-color: #ffffff;
1886
+ border: 1px solid #e2e8f0;
1887
+ border-radius: 6px;
1888
+ cursor: pointer;
1889
+ box-sizing: border-box;
1890
+ transition: all 0.15s ease;
1891
+
1892
+ &:hover {
1893
+ border-color: #cbd5e1;
1894
+ }
1895
+
1896
+ &.is-active {
1897
+ border-color: #1e6fff;
1898
+ box-shadow: 0 0 0 2px rgba(30, 111, 255, 0.1);
1899
+ }
1900
+
1901
+ &.image-mode-trigger {
1902
+ flex: 1;
1903
+ }
1204
1904
  }
1205
1905
 
1206
- .recipe-theme-name {
1906
+ .select-trigger-text {
1207
1907
  font-size: 13px;
1208
- font-weight: 700;
1209
- line-height: 1.2;
1210
- white-space: nowrap;
1211
- overflow: hidden;
1212
- text-overflow: ellipsis;
1908
+ font-weight: 500;
1909
+ color: #1e293b;
1213
1910
  }
1214
1911
 
1215
- .current-slide-pill {
1216
- font-size: 10px;
1217
- background: #6366f1;
1218
- color: #ffffff;
1219
- padding: 1px 6px;
1220
- border-radius: 10px;
1221
- font-weight: 600;
1222
- flex-shrink: 0;
1912
+ .select-trigger-arrow {
1913
+ color: #94a3b8;
1914
+ transition: transform 0.2s ease;
1915
+
1916
+ &.is-open {
1917
+ transform: rotate(180deg);
1918
+ }
1223
1919
  }
1224
1920
 
1225
- .recipe-usage-badge {
1226
- font-size: 10px;
1227
- padding: 2px 7px;
1228
- border-radius: 10px;
1229
- font-weight: 500;
1921
+ .autofit-menu-container {
1922
+ display: flex;
1923
+ flex-direction: column;
1924
+ gap: 2px;
1925
+ }
1926
+
1927
+ .font-menu-scrollable {
1928
+ max-height: 274px;
1929
+ overflow-y: auto;
1930
+ padding-right: 2px;
1931
+
1932
+ &::-webkit-scrollbar {
1933
+ width: 4px;
1934
+ }
1935
+
1936
+ &::-webkit-scrollbar-thumb {
1937
+ background-color: #cbd5e1;
1938
+ border-radius: 4px;
1939
+ }
1940
+ }
1941
+
1942
+ .autofit-menu-item {
1943
+ display: flex;
1944
+ align-items: center;
1945
+ justify-content: space-between;
1946
+ height: 34px;
1947
+ min-height: 34px;
1948
+ line-height: 34px;
1230
1949
  flex-shrink: 0;
1231
- white-space: nowrap;
1950
+ box-sizing: border-box;
1951
+ padding: 0 10px;
1952
+ border-radius: 6px;
1953
+ cursor: pointer;
1954
+ transition: all 0.15s ease;
1955
+ user-select: none;
1956
+
1957
+ &:hover {
1958
+ background-color: #f8fafc;
1959
+ }
1960
+
1961
+ &.is-selected {
1962
+ background-color: #eef2ff;
1963
+
1964
+ .autofit-item-label {
1965
+ color: #1e6fff;
1966
+ font-weight: 500;
1967
+ }
1968
+ }
1232
1969
  }
1233
1970
 
1234
- .recipe-usage-badge.all {
1235
- background: #ecfdf5;
1236
- color: #059669;
1237
- border: 1px solid #a7f3d0;
1971
+ .autofit-item-label {
1972
+ font-size: 13px;
1973
+ line-height: 34px;
1974
+ color: #1e293b;
1975
+ transition: color 0.15s ease;
1238
1976
  }
1239
1977
 
1240
- .recipe-usage-badge.partial {
1241
- background: #eff6ff;
1242
- color: #2563eb;
1243
- border: 1px solid #bfdbfe;
1978
+ .autofit-check-icon {
1979
+ color: #1e6fff;
1980
+ flex-shrink: 0;
1244
1981
  }
1245
1982
 
1246
- .recipe-usage-badge.none {
1247
- background: #f8fafc;
1248
- color: #94a3b8;
1249
- border: 1px solid #e2e8f0;
1983
+ .global-divider {
1984
+ height: 1px;
1985
+ background-color: #f1f5f9;
1986
+ margin: 14px 0;
1987
+ width: 100%;
1250
1988
  }
1251
1989
 
1252
- .recipe-card-preview {
1990
+ .global-color-pill {
1991
+ width: 100%;
1992
+ box-sizing: border-box;
1993
+ height: 34px;
1994
+ border: 1px solid #e5e7eb;
1995
+ border-radius: 6px;
1996
+ background: #ffffff;
1997
+ padding: 0 10px;
1998
+ display: flex;
1999
+ align-items: center;
2000
+ justify-content: space-between;
1253
2001
  cursor: pointer;
1254
- padding: 8px 10px;
1255
- background: rgba(0, 0, 0, 0.02);
2002
+ transition: all 0.15s ease;
2003
+
2004
+ &:hover {
2005
+ border-color: #cbd5e1;
2006
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
2007
+ }
2008
+
2009
+ .color-pill-left {
2010
+ display: flex;
2011
+ align-items: center;
2012
+ gap: 8px;
2013
+
2014
+ .color-swatch {
2015
+ width: 18px;
2016
+ height: 18px;
2017
+ border-radius: 4px;
2018
+ border: 1px solid rgba(0, 0, 0, 0.08);
2019
+ flex-shrink: 0;
2020
+ }
2021
+
2022
+ .color-hex-text {
2023
+ font-size: 13px;
2024
+ font-weight: 500;
2025
+ color: #1f2937;
2026
+ font-family: inherit;
2027
+ }
2028
+ }
2029
+
2030
+ .color-opacity-text {
2031
+ font-size: 13px;
2032
+ color: #4b5563;
2033
+ }
2034
+ }
2035
+
2036
+ .global-action-btn {
2037
+ width: 100%;
2038
+ box-sizing: border-box;
2039
+ height: 34px;
2040
+ border: 1px solid #e5e7eb;
1256
2041
  border-radius: 6px;
2042
+ background: #ffffff;
2043
+ color: #374151;
2044
+ font-size: 13px;
2045
+ font-weight: 500;
2046
+ cursor: pointer;
1257
2047
  display: flex;
1258
- flex-direction: column;
1259
- gap: 8px;
1260
- transition: background 0.15s ease;
2048
+ align-items: center;
2049
+ justify-content: center;
2050
+ transition: all 0.15s ease;
2051
+
2052
+ &:hover {
2053
+ background: #f9fafb;
2054
+ border-color: #cbd5e1;
2055
+ color: #111827;
2056
+ }
2057
+
2058
+ &:active {
2059
+ background: #f3f4f6;
2060
+ }
1261
2061
  }
1262
2062
 
1263
- .recipe-card-preview:hover {
1264
- background: rgba(99, 102, 241, 0.05);
2063
+ .fill-types-bar {
2064
+ display: flex;
2065
+ align-items: center;
2066
+ justify-content: space-between;
2067
+ margin-bottom: 12px;
2068
+ width: 100%;
2069
+ height: 40px;
2070
+ background: #f8f9fa;
2071
+ border: 1px solid #edf0f3;
2072
+ border-radius: 10px;
2073
+ padding: 3px 4px;
2074
+ box-sizing: border-box;
1265
2075
  }
1266
2076
 
1267
- .recipe-font-preview {
2077
+ .fill-type-icon-btn {
2078
+ width: 32px;
2079
+ height: 32px;
2080
+ flex: 0 0 32px;
2081
+ border-radius: 9px;
2082
+ background: transparent;
2083
+ border: 1px solid transparent;
1268
2084
  display: flex;
1269
- align-items: baseline;
2085
+ align-items: center;
2086
+ justify-content: center;
2087
+ cursor: pointer;
2088
+ padding: 0;
2089
+ transition: all 0.15s ease;
2090
+ box-sizing: border-box;
2091
+
2092
+ &:hover:not(.active) {
2093
+ background: rgba(0, 0, 0, 0.04);
2094
+ }
2095
+
2096
+ &.active {
2097
+ width: 32px;
2098
+ height: 32px;
2099
+ background: #FFFFFF;
2100
+ border-radius: 9px;
2101
+ border: 1px solid #E5E7EB;
2102
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
2103
+ }
1270
2104
  }
1271
2105
 
1272
- .recipe-palette-strip {
2106
+ :deep(.color-span-box),
2107
+ :deep(.el-popover__reference-wrapper) {
2108
+ display: block !important;
2109
+ width: 100% !important;
2110
+ }
2111
+
2112
+ .full-color-pill {
2113
+ width: 100%;
2114
+ height: 34px;
2115
+ background: #f8f9fa;
2116
+ border: 1px solid #edf0f3;
2117
+ border-radius: 8px;
2118
+ padding: 0 10px;
1273
2119
  display: flex;
1274
- height: 10px;
1275
- border-radius: 3px;
1276
- overflow: hidden;
1277
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
2120
+ align-items: center;
2121
+ justify-content: space-between;
2122
+ box-sizing: border-box;
2123
+ transition: all 0.15s ease;
2124
+
2125
+ &:hover {
2126
+ background: #f1f5f9;
2127
+ border-color: #e2e8f0;
2128
+ }
2129
+
2130
+ :deep(.color-span-box) {
2131
+ display: flex !important;
2132
+ align-items: center !important;
2133
+ flex: 1 !important;
2134
+ height: 100% !important;
2135
+ cursor: pointer;
2136
+ }
1278
2137
  }
1279
2138
 
1280
- .palette-strip-block {
2139
+ .color-left-info {
2140
+ display: flex;
2141
+ align-items: center;
2142
+ gap: 8px;
1281
2143
  flex: 1;
1282
2144
  height: 100%;
2145
+ cursor: pointer;
1283
2146
  }
1284
2147
 
1285
- .recipe-card-actions {
2148
+ .color-square-preview {
2149
+ width: 16px;
2150
+ height: 16px;
2151
+ border-radius: 3px;
2152
+ border: 1px solid rgba(0, 0, 0, 0.1);
2153
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
2154
+ }
2155
+
2156
+ .color-hex-text {
2157
+ font-size: 13px;
2158
+ font-weight: 500;
2159
+ font-family: monospace, sans-serif;
2160
+ color: #1e293b;
2161
+ letter-spacing: 0.5px;
2162
+ }
2163
+
2164
+ .color-opacity-input-wrapper {
1286
2165
  display: flex;
1287
- gap: 6px;
1288
2166
  align-items: center;
2167
+ justify-content: flex-end;
2168
+ height: 24px;
2169
+ padding: 0 4px;
2170
+ border-radius: 4px;
2171
+ transition: all 0.15s ease;
2172
+ background: transparent;
2173
+
2174
+ &:hover {
2175
+ background: rgba(0, 0, 0, 0.04);
2176
+ }
2177
+
2178
+ &:focus-within {
2179
+ background: #ffffff;
2180
+ box-shadow: 0 0 0 1px #1E6FFF;
2181
+ }
1289
2182
  }
1290
2183
 
1291
- .recipe-act-btn {
1292
- flex: 1;
1293
- padding: 5px 6px;
1294
- border-radius: 5px;
1295
- border: 1px solid #e2e8f0;
1296
- background: #ffffff;
1297
- color: #334155;
1298
- font-size: 11px;
2184
+ .color-opacity-num-input {
2185
+ width: 26px;
2186
+ border: none;
2187
+ background: transparent;
2188
+ outline: none;
2189
+ font-size: 12px;
1299
2190
  font-weight: 500;
2191
+ color: #475569;
2192
+ text-align: right;
2193
+ padding: 0 1px 0 0;
2194
+ font-family: inherit;
2195
+
2196
+ &::-webkit-outer-spin-button,
2197
+ &::-webkit-inner-spin-button {
2198
+ -webkit-appearance: none;
2199
+ margin: 0;
2200
+ }
2201
+ }
2202
+
2203
+ .percent-unit {
2204
+ font-size: 12px;
2205
+ font-weight: 500;
2206
+ color: #64748b;
2207
+ user-select: none;
2208
+ }
2209
+
2210
+ .pattern-fill-container {
2211
+ display: flex;
2212
+ flex-direction: column;
2213
+ gap: 10px;
2214
+ }
2215
+
2216
+ .pattern-colors-section {
2217
+ display: flex;
2218
+ flex-direction: column;
2219
+ gap: 8px;
2220
+ padding-top: 2px;
2221
+ }
2222
+
2223
+ .pattern-color-row {
2224
+ display: flex;
2225
+ align-items: center;
2226
+ gap: 8px;
2227
+ }
2228
+
2229
+ .pattern-color-label {
2230
+ width: 36px;
2231
+ font-size: 13px;
2232
+ color: #475569;
2233
+ flex-shrink: 0;
2234
+ }
2235
+
2236
+ .pattern-presets-grid {
2237
+ display: grid;
2238
+ grid-template-columns: repeat(4, 1fr);
2239
+ gap: 8px;
2240
+ }
2241
+
2242
+ .pattern-preset-card {
2243
+ height: 38px;
2244
+ border-radius: 6px;
2245
+ border: 1px solid #e2e8f0;
2246
+ background-repeat: repeat;
1300
2247
  cursor: pointer;
1301
- white-space: nowrap;
1302
2248
  transition: all 0.15s ease;
2249
+
2250
+ &:hover {
2251
+ border-color: #94a3b8;
2252
+ }
2253
+
2254
+ &.active {
2255
+ border-color: #1E6FFF;
2256
+ box-shadow: 0 0 0 1px #1E6FFF;
2257
+ }
2258
+ }
2259
+
2260
+ .global-image-card {
2261
+ width: 100%;
2262
+ box-sizing: border-box;
2263
+ border: 1px solid #e5e7eb;
2264
+ border-radius: 8px;
2265
+ padding: 10px 12px;
2266
+ background: #ffffff;
2267
+ display: flex;
2268
+ flex-direction: column;
2269
+ gap: 10px;
2270
+ }
2271
+
2272
+ .image-mode-row {
2273
+ display: flex;
2274
+ align-items: center;
2275
+ gap: 8px;
2276
+
2277
+ .image-mode-select {
2278
+ flex: 1;
2279
+ }
2280
+
2281
+ .image-fit-btn {
2282
+ width: 34px;
2283
+ height: 34px;
2284
+ display: flex;
2285
+ align-items: center;
2286
+ justify-content: center;
2287
+ border: 1px solid #e5e7eb;
2288
+ border-radius: 6px;
2289
+ background: #ffffff;
2290
+ color: #6b7280;
2291
+ cursor: pointer;
2292
+ padding: 0;
2293
+ transition: all 0.15s ease;
2294
+
2295
+ &:hover {
2296
+ background: #f9fafb;
2297
+ border-color: #cbd5e1;
2298
+ color: #111827;
2299
+ }
2300
+ }
2301
+ }
2302
+
2303
+ .checkerboard-preview-box {
2304
+ width: 100%;
2305
+ height: 120px;
2306
+ box-sizing: border-box;
2307
+ border-radius: 6px;
2308
+ display: flex;
2309
+ align-items: center;
2310
+ justify-content: center;
2311
+ position: relative;
2312
+ overflow: hidden;
2313
+ cursor: pointer;
2314
+ background-color: #ffffff;
2315
+ background-image:
2316
+ linear-gradient(45deg, #e5e7eb 25%, transparent 25%),
2317
+ linear-gradient(-45deg, #e5e7eb 25%, transparent 25%),
2318
+ linear-gradient(45deg, transparent 75%, #e5e7eb 75%),
2319
+ linear-gradient(-45deg, transparent 75%, #e5e7eb 75%);
2320
+ background-size: 16px 16px;
2321
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
2322
+
2323
+ .select-img-btn {
2324
+ padding: 7px 16px;
2325
+ background: rgba(255, 255, 255, 0.9);
2326
+ backdrop-filter: blur(4px);
2327
+ border: 1px solid rgba(0, 0, 0, 0.08);
2328
+ border-radius: 4px;
2329
+ font-size: 12px;
2330
+ font-weight: 500;
2331
+ color: #374151;
2332
+ cursor: pointer;
2333
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
2334
+ transition: all 0.15s ease;
2335
+
2336
+ &:hover {
2337
+ background: #ffffff;
2338
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
2339
+ }
2340
+ }
2341
+
2342
+ .bg-img-preview {
2343
+ width: 100%;
2344
+ height: 100%;
2345
+ pointer-events: none;
2346
+ }
2347
+
2348
+ .bg-img-hover-actions {
2349
+ position: absolute;
2350
+ inset: 0;
2351
+ background: rgba(0, 0, 0, 0.45);
2352
+ display: none;
2353
+ align-items: center;
2354
+ justify-content: center;
2355
+ gap: 8px;
2356
+
2357
+ .bg-img-action-btn {
2358
+ padding: 4px 10px;
2359
+ font-size: 11px;
2360
+ background: #ffffff;
2361
+ color: #334155;
2362
+ border: none;
2363
+ border-radius: 4px;
2364
+ cursor: pointer;
2365
+
2366
+ &.btn-del {
2367
+ background: #ef4444;
2368
+ color: #ffffff;
2369
+ }
2370
+ }
2371
+ }
2372
+
2373
+ &:hover .bg-img-hover-actions {
2374
+ display: flex;
2375
+ }
2376
+ }
2377
+
2378
+ /* Theme Tab New UI Styles (Matching Reference Screenshot) */
2379
+ .theme-tab-content {
2380
+ padding: 14px 15px;
2381
+ display: flex;
2382
+ flex-direction: column;
2383
+ gap: 12px;
2384
+ }
2385
+
2386
+ .theme-card-list {
2387
+ display: flex;
2388
+ flex-direction: column;
2389
+ gap: 12px;
2390
+ }
2391
+
2392
+ .theme-recipe-card {
2393
+ width: 100%;
2394
+ box-sizing: border-box;
2395
+ background: #ffffff;
2396
+ border: 1px solid #e5e7eb;
2397
+ border-radius: 8px;
2398
+ padding: 10px 12px 12px 12px;
2399
+ cursor: pointer;
2400
+ transition: all 0.2s ease;
2401
+ display: flex;
2402
+ flex-direction: column;
2403
+ gap: 8px;
2404
+ user-select: none;
2405
+
2406
+ &:hover {
2407
+ border-color: #cbd5e1;
2408
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
2409
+ }
2410
+
2411
+ &.is-active {
2412
+ border: 1.5px solid #2970ff;
2413
+ box-shadow: 0 0 0 1px rgba(41, 112, 255, 0.08);
2414
+ }
2415
+ }
2416
+
2417
+ .theme-card-header {
2418
+ display: flex;
2419
+ align-items: center;
2420
+ justify-content: space-between;
2421
+ line-height: 1.2;
2422
+ }
2423
+
2424
+ .theme-name-text {
2425
+ font-size: 13px;
2426
+ font-weight: 600;
2427
+ color: #1f2937;
2428
+ }
2429
+
2430
+ .theme-more-btn {
1303
2431
  display: flex;
1304
2432
  align-items: center;
1305
2433
  justify-content: center;
2434
+ width: 20px;
2435
+ height: 20px;
2436
+ border: none;
2437
+ background: transparent;
2438
+ color: #6b7280;
2439
+ border-radius: 4px;
2440
+ cursor: pointer;
2441
+ padding: 0;
2442
+ transition: all 0.15s ease;
2443
+
2444
+ &:hover {
2445
+ background: #f3f4f6;
2446
+ color: #111827;
2447
+ }
2448
+ }
2449
+
2450
+ .theme-preview-box {
2451
+ width: 100%;
2452
+ box-sizing: border-box;
2453
+ border-radius: 6px;
2454
+ padding: 9px 10px;
2455
+ display: flex;
2456
+ flex-direction: column;
2457
+ gap: 8px;
2458
+ }
2459
+
2460
+ .theme-font-row {
2461
+ display: flex;
2462
+ align-items: baseline;
2463
+ gap: 8px;
2464
+
2465
+ .font-preview-aa {
2466
+ font-size: 15px;
2467
+ font-weight: 700;
2468
+ font-style: italic;
2469
+ line-height: 1;
2470
+ }
2471
+
2472
+ .font-preview-name {
2473
+ font-size: 12px;
2474
+ line-height: 1;
2475
+ }
2476
+ }
2477
+
2478
+ .theme-palette-strip {
2479
+ display: flex;
2480
+ width: 100%;
2481
+ height: 12px;
2482
+ border-radius: 2px;
2483
+ overflow: hidden;
2484
+
2485
+ .palette-color-block {
2486
+ flex: 1;
2487
+ height: 100%;
2488
+ }
2489
+ }
2490
+
2491
+ :global(.theme-actions-popper.el-popover.el-popper) {
2492
+ padding: 4px 0 !important;
2493
+ border-radius: 8px !important;
2494
+ border: 1px solid #eaecf0 !important;
2495
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04) !important;
2496
+ min-width: 146px !important;
1306
2497
  }
1307
2498
 
1308
- .recipe-act-btn:hover {
1309
- background: #f1f5f9;
1310
- border-color: #cbd5e1;
1311
- color: #0f172a;
2499
+ .theme-menu-list {
2500
+ display: flex;
2501
+ flex-direction: column;
2502
+ user-select: none;
1312
2503
  }
1313
2504
 
1314
- .recipe-act-btn.btn-edit {
1315
- flex: 0 0 auto;
1316
- padding: 5px 8px;
1317
- color: #6366f1;
1318
- border-color: #c7d2fe;
1319
- background: #eef2ff;
2505
+ .theme-menu-item {
2506
+ display: flex;
2507
+ align-items: center;
2508
+ gap: 8px;
2509
+ padding: 7px 14px;
2510
+ font-size: 13px;
2511
+ color: #374151;
2512
+ cursor: pointer;
2513
+ transition: background-color 0.15s ease, color 0.15s ease;
2514
+
2515
+ .menu-item-icon {
2516
+ width: 14px;
2517
+ height: 14px;
2518
+ flex-shrink: 0;
2519
+ }
2520
+
2521
+ &:hover {
2522
+ background-color: #f3f4f6;
2523
+ }
2524
+
2525
+ &.item-danger {
2526
+ color: #ef4444;
2527
+
2528
+ .menu-item-icon {
2529
+ color: #ef4444;
2530
+ }
2531
+
2532
+ &:hover {
2533
+ background-color: #fef2f2;
2534
+ color: #dc2626;
2535
+ }
2536
+ }
2537
+ }
2538
+
2539
+ .theme-menu-divider {
2540
+ height: 1px;
2541
+ background-color: #f3f4f6;
2542
+ margin: 4px 0;
1320
2543
  }
1321
2544
 
1322
- .recipe-act-btn.btn-edit:hover {
1323
- background: #e0e7ff;
1324
- border-color: #a5b4fc;
2545
+ .theme-menu-footer {
2546
+ font-size: 11px;
2547
+ color: #9ca3af;
2548
+ text-align: center;
2549
+ padding: 6px 12px;
1325
2550
  }
1326
2551
 
1327
2552
  :deep(.color-span-box) {
@@ -1339,3 +2564,116 @@ watch(extractTab, () => {
1339
2564
  }
1340
2565
  }
1341
2566
  </style>
2567
+
2568
+ <style lang="scss">
2569
+ .autofit-popover-panel {
2570
+ min-width: 0 !important;
2571
+ padding: 6px !important;
2572
+ border-radius: 10px !important;
2573
+ border: 1px solid #edf0f3 !important;
2574
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1) !important;
2575
+ background: #ffffff !important;
2576
+ z-index: 9999 !important;
2577
+
2578
+ .el-popper__arrow {
2579
+ display: none !important;
2580
+ }
2581
+
2582
+ .autofit-menu-item {
2583
+ height: 34px !important;
2584
+ min-height: 34px !important;
2585
+ line-height: 34px !important;
2586
+ flex-shrink: 0 !important;
2587
+ box-sizing: border-box !important;
2588
+ }
2589
+ }
2590
+
2591
+ .el-popper.sidebar-select-popper {
2592
+ min-width: 0 !important;
2593
+ padding: 0 !important;
2594
+ border-radius: 10px !important;
2595
+ border: 1px solid #edf0f3 !important;
2596
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1) !important;
2597
+ background: #ffffff !important;
2598
+ overflow: hidden !important;
2599
+ z-index: 9999 !important;
2600
+
2601
+ .el-popper__arrow {
2602
+ display: none !important;
2603
+ }
2604
+
2605
+ .el-select-dropdown {
2606
+ padding: 6px !important;
2607
+ margin: 0 !important;
2608
+ border: none !important;
2609
+ box-shadow: none !important;
2610
+ border-radius: 0 !important;
2611
+ background: transparent !important;
2612
+ box-sizing: border-box !important;
2613
+ }
2614
+
2615
+ .el-select-dropdown__wrap {
2616
+ max-height: 274px !important;
2617
+ }
2618
+
2619
+ .el-select-dropdown__list {
2620
+ padding: 0 !important;
2621
+ margin: 0 !important;
2622
+ display: flex !important;
2623
+ flex-direction: column !important;
2624
+ gap: 2px !important;
2625
+ }
2626
+
2627
+ .el-select-dropdown__item {
2628
+ height: 34px !important;
2629
+ min-height: 34px !important;
2630
+ line-height: 34px !important;
2631
+ flex-shrink: 0 !important;
2632
+ box-sizing: border-box !important;
2633
+ padding: 0 10px !important;
2634
+ border-radius: 6px !important;
2635
+ font-size: 13px !important;
2636
+ color: #1e293b !important;
2637
+ display: flex !important;
2638
+ align-items: center !important;
2639
+ justify-content: space-between !important;
2640
+ transition: all 0.15s ease !important;
2641
+ user-select: none !important;
2642
+
2643
+ &:hover,
2644
+ &.hover,
2645
+ &.is-hovering {
2646
+ background-color: #f8fafc !important;
2647
+ color: #1e293b !important;
2648
+ }
2649
+
2650
+ &.is-selected {
2651
+ background-color: #eef2ff !important;
2652
+ color: #2563eb !important;
2653
+ font-weight: 500 !important;
2654
+
2655
+ &::after {
2656
+ content: '';
2657
+ display: inline-block;
2658
+ width: 15px;
2659
+ height: 15px;
2660
+ flex-shrink: 0;
2661
+ margin-left: 8px;
2662
+ background-color: #2563eb;
2663
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") no-repeat center / contain;
2664
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") no-repeat center / contain;
2665
+ }
2666
+ }
2667
+ }
2668
+ }
2669
+
2670
+ .el-select-dropdown.sidebar-select-popper {
2671
+ border: none !important;
2672
+ box-shadow: none !important;
2673
+ border-radius: 0 !important;
2674
+ background: transparent !important;
2675
+ padding: 6px !important;
2676
+ margin: 0 !important;
2677
+ box-sizing: border-box !important;
2678
+ }
2679
+ </style>