jvs-ppt-ui 0.0.7 → 0.0.8

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.
@@ -1,1336 +1,1341 @@
1
- <script setup lang="ts">
2
- import { ref, computed, watch } from 'vue'
3
- import StyleBuilderDialog from '../../StyleBuilderDialog.vue'
4
- import LayoutThumbSvg from './LayoutThumbSvg.vue'
5
- import QuickColorPopover from '../../QuickColorPopover.vue'
6
- import GradientEditor from './GradientEditor.vue'
7
- import { useSlides, gradientPresets, normalizeSlideBackground } from '../../../composables/useSlides'
8
- import { SYS_FONTS } from '../../../configs/fonts'
9
-
10
- const props = defineProps<{
11
- designActiveTab: string
12
- slideRatio: string
13
- styleBuilderVisible: boolean
14
- styleBuilderTargetMode: 'all' | 'single'
15
- styleBuilderInitialRecipeId: string
16
- styleBuilderIsNewCustom: boolean
17
- builtinStyleRecipes: any[]
18
- slides: any[]
19
- activeIndex: number
20
- masterGroupedLayouts: any[]
21
- }>()
22
-
23
- const emit = defineEmits<{
24
- (e: 'update:designActiveTab', val: string): void
25
- (e: 'update:slideRatio', val: string): void
26
- (e: 'update:styleBuilderVisible', val: boolean): void
27
- (e: 'openStyleBuilder', mode: 'all' | 'single', recipeId?: string, isNew?: boolean): void
28
- (e: 'applyStyleRecipeToSlides', recipe: any, isAll: boolean): void
29
- (e: 'enterMasterView'): void
30
- (e: 'applyLayoutToCurrentSlide', layoutId: string): void
31
- }>()
32
-
33
- const {
34
- globalFontFamily,
35
- globalFontColor,
36
- globalBgColor,
37
- globalThemeColor,
38
- globalBorderStyle,
39
- globalBorderColor,
40
- globalBorderWidth,
41
- presetThemes,
42
- applyBackgroundToAll,
43
- applyThemeToAll,
44
- applyFontToAll,
45
- applyPresetTheme,
46
- activeSlide,
47
- slides,
48
- activeIndex,
49
- notify,
50
- isMasterEditingView,
51
- activeMasterId,
52
- slideMasters,
53
- getThemeUsageLabel,
54
- addHistorySnapshot,
55
- uploadResource
56
- } = useSlides()
57
-
58
- const currentActiveThemeId = computed(() => {
59
- if (isMasterEditingView.value) {
60
- const master = slideMasters.value.find(m => m.id === activeMasterId.value) || slideMasters.value[0]
61
- return (master as any)?.themeId || 'default-recipe'
62
- }
63
- return props.slides[props.activeIndex]?.themeId || 'default-recipe'
64
- })
65
-
66
- // Quick Color Popover recent colors
67
- const quickRecentColors = ref<string[]>([])
68
- const globalFontColorPopoverVisible = ref(false)
69
- const globalBorderColorPopoverVisible = ref(false)
70
- const bgColorPopoverVisible = ref(false)
71
- const isCustomColorPickerOpen = ref(false)
72
-
73
- // 主题色色盘 Popover
74
- const themePalettePopoverVisible = ref(false)
75
- const globalThemeColorPopoverVisible = ref<boolean[]>(Array(5).fill(false))
76
-
77
- // 格式化 hex 显示(去掉 # 并大写)
78
- const formatHexDisplay = (color: string) => {
79
- if (!color || typeof color !== 'string') return 'FFFFFF'
80
- return color.replace('#', '').toUpperCase()
81
- }
82
-
83
- // 字体显示名称
84
- const fontLabel = (font: string) => SYS_FONTS.find(f => f.value === font || f.label === font)?.label || font
85
-
86
- // 背景类型与颜色
87
- const bgFillType = computed({
88
- get() {
89
- if (activeSlide.value?.bgType) return activeSlide.value.bgType
90
- const bg = activeSlide.value?.background
91
- if (bg) {
92
- return normalizeSlideBackground(bg).bgType || 'color'
93
- }
94
- return 'color'
95
- },
96
- set(val: 'color' | 'gradient' | 'image') {
97
- if (activeSlide.value) {
98
- activeSlide.value.bgType = val
99
- if (val === 'color') {
100
- if (typeof activeSlide.value.background === 'string' && (activeSlide.value.background.startsWith('linear-gradient') || activeSlide.value.background.startsWith('radial-gradient') || activeSlide.value.background.startsWith('url'))) {
101
- activeSlide.value.background = '#ffffff'
102
- }
103
- } else if (val === 'gradient') {
104
- activeSlide.value.background = 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
105
- } else if (val === 'image') {
106
- activeSlide.value.background = ''
107
- }
108
- addHistorySnapshot()
109
- }
110
- }
111
- })
112
-
113
- const activeBgColor = computed({
114
- get() {
115
- const bg = activeSlide.value?.background
116
- if (!bg) return '#ffffff'
117
- if (typeof bg === 'string') {
118
- if (bg.startsWith('#') || bg.startsWith('rgb')) return bg
119
- const colorMatch = bg.match(/(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\))/)
120
- if (colorMatch) return colorMatch[1]
121
- return '#ffffff'
122
- }
123
- if (typeof bg === 'object' && bg !== null) {
124
- if (bg.type === 'color') return typeof bg.value === 'string' ? bg.value : '#ffffff'
125
- if (bg.type === 'gradient') {
126
- const c = bg.value?.colors?.[0]?.color
127
- return typeof c === 'string' ? c : '#ffffff'
128
- }
129
- if (bg.type === 'image') {
130
- const c = bg.value?.color || bg.value?.src || bg.value?.base64
131
- return typeof c === 'string' && c.startsWith('#') ? c : '#ffffff'
132
- }
133
- if (typeof bg.value === 'string') return bg.value
134
- }
135
- return '#ffffff'
136
- },
137
- set(val: string) {
138
- if (activeSlide.value) {
139
- activeSlide.value.background = val
140
- activeSlide.value.bgType = 'color'
141
- addHistorySnapshot()
142
- }
143
- }
144
- })
145
-
146
- const bgImageMode = computed({
147
- get() {
148
- return (activeSlide.value as any)?.bgImageMode || 'stretch'
149
- },
150
- set(val: string) {
151
- if (activeSlide.value) {
152
- (activeSlide.value as any).bgImageMode = val
153
- addHistorySnapshot()
154
- }
155
- }
156
- })
157
-
158
- const bgImageUrl = computed({
159
- get() {
160
- if (!activeSlide.value?.background || typeof activeSlide.value.background !== 'string') return ''
161
- const match = activeSlide.value.background.match(/url\(['"]?(.*?)['"]?\)/)
162
- return match ? match[1] : ''
163
- },
164
- set(url: string) {
165
- if (activeSlide.value) {
166
- if (!url) {
167
- activeSlide.value.background = '#ffffff'
168
- activeSlide.value.bgType = 'color'
169
- } else {
170
- activeSlide.value.background = `url("${url}")`
171
- activeSlide.value.bgType = 'image'
172
- }
173
- addHistorySnapshot()
174
- }
175
- }
176
- })
177
-
178
- const bgFileInputRef = ref<HTMLInputElement | null>(null)
179
- const uploadBgImage = () => {
180
- bgFileInputRef.value?.click()
181
- }
182
- const onBgFileChange = async (e: Event) => {
183
- const input = e.target as HTMLInputElement
184
- if (!input.files || !input.files[0]) return
185
- const file = input.files[0]
186
- try {
187
- const url = await uploadResource(file, { type: 'image', source: 'background', filename: file.name }, () => {
188
- return new Promise<string>((resolve) => {
189
- const reader = new FileReader()
190
- reader.onload = (evt) => resolve(typeof evt.target?.result === 'string' ? evt.target.result : '')
191
- reader.readAsDataURL(file)
192
- })
193
- })
194
- if (url) {
195
- bgImageUrl.value = url
196
- notify('已成功更新背景图片')
197
- }
198
- } catch (err: any) {
199
- console.error(err)
200
- notify('背景图片上传失败: ' + (err?.message || err))
201
- }
202
- input.value = ''
203
- }
204
-
205
- const applyBackground = (css: string) => {
206
- if (activeSlide.value) {
207
- activeSlide.value.background = css
208
- activeSlide.value.bgType = 'gradient'
209
- addHistorySnapshot()
210
- }
211
- }
212
-
213
- const activeGradientCss = computed(() => {
214
- const bg = activeSlide.value?.background
215
- if (!bg) return 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
216
- if (typeof bg === 'string') {
217
- if (bg.includes('gradient')) return bg
218
- return `linear-gradient(135deg, ${bg} 0%, #ffffff 100%)`
219
- }
220
- if (typeof bg === 'object' && bg !== null) {
221
- const norm = normalizeSlideBackground(bg)
222
- if (norm.bgType === 'gradient') return norm.background
223
- }
224
- return 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
225
- })
226
-
227
- const onGradientEditorUpdate = (css: string) => {
228
- if (activeSlide.value) {
229
- activeSlide.value.background = css
230
- activeSlide.value.bgType = 'gradient'
231
- }
232
- }
233
-
234
- const onGradientEditorChange = (css: string) => {
235
- if (activeSlide.value) {
236
- activeSlide.value.background = css
237
- activeSlide.value.bgType = 'gradient'
238
- addHistorySnapshot()
239
- }
240
- }
241
-
242
- // 主题提取器
243
- const themeExtractorVisible = ref(false)
244
- const extractTab = ref<'current' | 'all'>('current')
245
- const saveFont = ref(true)
246
- const saveFontColor = ref(true)
247
- const saveBgColor = ref(true)
248
- const extractedFont = ref('默认字体')
249
- const extractedFontColor = ref('#000000')
250
- const extractedBgColor = ref('#FFFFFF')
251
- const extractedThemeColors = ref<{ color: string; selected: boolean }[]>([])
252
-
253
- function toHexString(color: string): string {
254
- if (!color) return '#000000'
255
- if (color.startsWith('#')) return color
256
- if (color.startsWith('rgb')) {
257
- const rgb = color.match(/\d+/g)
258
- if (rgb && rgb.length >= 3) {
259
- const hex = rgb.slice(0, 3).map(x => parseInt(x).toString(16).padStart(2, '0')).join('')
260
- return `#${hex}`
261
- }
262
- }
263
- return color
264
- }
265
-
266
- function normalizeColor(color: string): string | null {
267
- if (!color || typeof color !== 'string') return null
268
- const hex = toHexString(color).toUpperCase()
269
- return hex.startsWith('#') && (hex.length === 7 || hex.length === 4) ? hex : null
270
- }
271
-
272
- function extractThemeStyles(target: any) {
273
- const slidesToExtract = Array.isArray(target) ? target : [target]
274
- const fontNames: Record<string, number> = {}
275
- const fontColors: Record<string, number> = {}
276
- const backgroundColors: Record<string, number> = {}
277
- const themeColors: Record<string, number> = {}
278
-
279
- slidesToExtract.forEach((slide: any) => {
280
- if (!slide) return
281
- if (slide.background && typeof slide.background === 'string') {
282
- const c = normalizeColor(slide.background)
283
- if (c) backgroundColors[c] = (backgroundColors[c] || 0) + 1000
284
- }
285
- const elements = slide.elements || []
286
- elements.forEach((el: any) => {
287
- const area = (el.width || 100) * (el.height || 100)
288
- if (el.type === 'text') {
289
- const font = el.style?.fontFamily || el.fontFamily
290
- if (font) fontNames[font] = (fontNames[font] || 0) + area
291
- const fc = normalizeColor(el.style?.color || el.color)
292
- if (fc) fontColors[fc] = (fontColors[fc] || 0) + area
293
- }
294
- if (el.type === 'shape' || el.type === 'line') {
295
- const fill = normalizeColor(el.style?.fill || el.fill)
296
- if (fill && fill !== '#FFFFFF' && fill !== '#000000') {
297
- themeColors[fill] = (themeColors[fill] || 0) + area
298
- }
299
- const border = normalizeColor(el.style?.borderColor || el.borderColor)
300
- if (border && border !== '#FFFFFF' && border !== '#000000') {
301
- themeColors[border] = (themeColors[border] || 0) + area * 0.5
302
- }
303
- }
304
- })
305
- })
306
-
307
- return {
308
- fontNames: Object.keys(fontNames).sort((a, b) => fontNames[b] - fontNames[a]),
309
- fontColors: Object.keys(fontColors).sort((a, b) => fontColors[b] - fontColors[a]),
310
- backgroundColors: Object.keys(backgroundColors).sort((a, b) => backgroundColors[b] - backgroundColors[a]),
311
- themeColors: Object.keys(themeColors).sort((a, b) => themeColors[b] - themeColors[a])
312
- }
313
- }
314
-
315
- function performExtraction() {
316
- saveFont.value = true
317
- saveFontColor.value = true
318
- saveBgColor.value = true
319
-
320
- const target = extractTab.value === 'current' ? activeSlide.value : slides.value
321
- if (!target) return
322
-
323
- const styles = extractThemeStyles(target)
324
- extractedBgColor.value = styles.backgroundColors[0] || '#FFFFFF'
325
- extractedFontColor.value = styles.fontColors[0] || '#000000'
326
- extractedFont.value = styles.fontNames[0] || '默认字体'
327
-
328
- const bgHex = extractedBgColor.value.toUpperCase()
329
- const fontHex = extractedFontColor.value.toUpperCase()
330
-
331
- const finalColors = styles.themeColors.filter(c => {
332
- const u = c.toUpperCase()
333
- return u !== bgHex && u !== fontHex && u !== '#FFFFFF' && u !== '#000000'
334
- })
335
- extractedThemeColors.value = finalColors.slice(0, 8).map(color => ({ color, selected: true }))
336
- }
337
-
338
- function openThemeExtractor() {
339
- extractTab.value = 'current'
340
- performExtraction()
341
- themeExtractorVisible.value = true
342
- }
343
-
344
- function saveExtractedTheme() {
345
- if (saveFont.value) globalFontFamily.value = extractedFont.value
346
- if (saveFontColor.value) globalFontColor.value = extractedFontColor.value
347
- if (saveBgColor.value) globalBgColor.value = extractedBgColor.value
348
-
349
- const selectedColors = extractedThemeColors.value.filter(c => c.selected).map(c => c.color)
350
- if (selectedColors.length > 0) {
351
- const finalPalette = [...selectedColors]
352
- while (finalPalette.length < 5) {
353
- finalPalette.push(finalPalette[finalPalette.length - 1] || '#3b82f6')
354
- }
355
- globalThemeColor.value = finalPalette.slice(0, 5)
356
- }
357
-
358
- const targetSlides = extractTab.value === 'current' ? (activeSlide.value ? [activeSlide.value] : []) : slides.value
359
- targetSlides.forEach((s: any) => {
360
- if (saveBgColor.value) {
361
- s.background = extractedBgColor.value
362
- s.bgType = 'color'
363
- }
364
- (s.elements || []).forEach((el: any) => {
365
- if (el.type === 'text') {
366
- if (!el.style) el.style = {}
367
- if (saveFont.value) el.style.fontFamily = extractedFont.value
368
- if (saveFontColor.value) el.style.color = extractedFontColor.value
369
- }
370
- })
371
- })
372
-
373
- themeExtractorVisible.value = false
374
- notify?.('提取的主题已成功保存并应用!')
375
- addHistorySnapshot()
376
- }
377
-
378
- watch(extractTab, () => {
379
- performExtraction()
380
- })
381
- </script>
382
-
383
- <template>
384
- <div class="sidebar-design-panel flex-column" style="display: flex; flex-direction: column;">
385
- <!-- 隐藏的图片背景上传 input -->
386
- <input ref="bgFileInputRef" type="file" accept="image/*" style="display: none;" @change="onBgFileChange" />
387
-
388
- <!-- 1. 全局设置 Tab (Global Settings) -->
389
- <div v-if="designActiveTab === 'global'" style="display: flex; flex-direction: column; gap: 14px; padding: 16px;">
390
- <!-- 画布尺寸 -->
391
- <div class="design-property-row">
392
- <span class="shape-property-label">画布尺寸</span>
393
- <el-select :model-value="slideRatio" @update:model-value="emit('update:slideRatio', $event)" size="small"
394
- style="flex: 1;" class="design-select" placement="bottom-start"
395
- :fallback-placements="['bottom-start', 'bottom', 'top-start']">
396
- <el-option label="宽屏 16:9" value="16:9" />
397
- <el-option label="正宽 4:3" value="4:3" />
398
- <el-option label="宽屏 16:10" value="16:10" />
399
- <el-option label="手机竖屏 9:16 (移动端)" value="9:16" />
400
- <el-option v-if="slideRatio === 'custom'" label="自定义尺寸" value="custom" />
401
- </el-select>
402
- </div>
403
-
404
- <!-- 字体 -->
405
- <div class="design-property-row">
406
- <span class="shape-property-label">字体</span>
407
- <el-select v-model="globalFontFamily" size="small" style="flex: 1;" class="design-select" filterable
408
- placement="bottom-start" :fallback-placements="['bottom-start', 'bottom', 'top-start']">
409
- <el-option label="默认字体" value="默认字体" />
410
- <el-option v-for="font in SYS_FONTS" :key="font.value" :label="font.label" :value="font.value">
411
- <span :style="{ fontFamily: font.value }">{{ font.label }}</span>
412
- </el-option>
413
- </el-select>
414
- </div>
415
-
416
- <!-- 字体颜色 -->
417
- <div class="design-property-row">
418
- <span class="shape-property-label">字体颜色</span>
419
- <QuickColorPopover v-model:visible="globalFontColorPopoverVisible" :color="globalFontColor"
420
- v-model:recentColors="quickRecentColors" @select-color="(color) => globalFontColor = color">
421
- <template #reference>
422
- <div class="color-picker-pill-wrapper" style="flex: 1;">
423
- <div class="color-pill-inner" style="height: 32px;">
424
- <div :style="{ background: globalFontColor }" class="color-square-preview"></div>
425
- <span style="font-size: 11px; color: var(--text-primary); font-family: monospace; font-weight: 500;">
426
- {{ formatHexDisplay(globalFontColor) }}
427
- </span>
428
- </div>
429
- </div>
430
- </template>
431
- </QuickColorPopover>
432
- </div>
433
-
434
- <!-- 背景颜色 -->
435
- <div :class="bgFillType === 'color' ? 'design-property-row-start' : 'design-property-row'">
436
- <span class="shape-property-label" :style="bgFillType === 'color' ? 'margin-top: 6px;' : ''">背景颜色</span>
437
- <div style="flex: 1; display: flex; flex-direction: column; gap: 8px;">
438
- <el-select v-model="bgFillType" size="small" style="width: 100%;" class="design-select"
439
- placement="bottom-start" :fallback-placements="['bottom-start', 'bottom', 'top-start']">
440
- <el-option label="纯色填充" value="color" />
441
- <el-option label="渐变填充" value="gradient" />
442
- <el-option label="图片填充" value="image" />
443
- </el-select>
444
-
445
- <!-- 纯色填充色块选择器 (独占一行,不与下拉框挤在一起) -->
446
- <QuickColorPopover v-if="bgFillType === 'color'" v-model:visible="bgColorPopoverVisible"
447
- :color="activeBgColor" v-model:recentColors="quickRecentColors"
448
- @select-color="(color) => activeBgColor = color">
449
- <template #reference>
450
- <div class="color-picker-pill-wrapper" style="width: 100%;">
451
- <div class="color-pill-inner" style="height: 32px;">
452
- <div :style="{ background: activeBgColor }" class="color-square-preview"></div>
453
- <span style="font-size: 11px; color: var(--text-primary); font-family: monospace; font-weight: 500;">
454
- {{ formatHexDisplay(activeBgColor) }}
455
- </span>
456
- </div>
457
- </div>
458
- </template>
459
- </QuickColorPopover>
460
- </div>
461
- </div>
462
-
463
- <!-- 渐变填充专业设置 (与背景颜色同一级) -->
464
- <template v-if="bgFillType === 'gradient'">
465
- <GradientEditor
466
- :model-value="activeGradientCss"
467
- :recent-colors="quickRecentColors"
468
- :theme-colors="globalThemeColor"
469
- @update:model-value="onGradientEditorUpdate"
470
- @change="onGradientEditorChange"
471
- />
472
- <div class="design-property-row-start" style="margin-top: 2px;">
473
- <span class="shape-property-label" style="margin-top: 4px;">渐变预设</span>
474
- <div class="color-picker-grid" style="flex: 1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;">
475
- <div v-for="preset in gradientPresets" :key="preset.name" class="color-option"
476
- :style="{ background: preset.css, height: '24px', borderRadius: '4px', cursor: 'pointer', border: '1px solid rgba(0,0,0,0.1)' }"
477
- @click="applyBackground(preset.css)" :title="preset.name"></div>
478
- </div>
479
- </div>
480
- </template>
481
-
482
- <!-- 图片填充模式 (与背景颜色同一级) -->
483
- <template v-else-if="bgFillType === 'image'">
484
- <div class="design-property-row">
485
- <span class="shape-property-label">图片排版</span>
486
- <el-select v-model="bgImageMode" size="small" style="flex: 1;" class="design-select">
487
- <el-option label="拉伸铺满" value="stretch" />
488
- <el-option label="平铺" value="tile" />
489
- </el-select>
490
- </div>
491
- <div class="design-property-row-start">
492
- <span class="shape-property-label" style="margin-top: 6px;">背景图片</span>
493
- <div class="bg-image-uploader-box"
494
- 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;"
495
- @click="uploadBgImage">
496
- <template v-if="bgImageUrl">
497
- <img :src="bgImageUrl" style="width: 100%; height: 100%; object-fit: contain; pointer-events: none;" />
498
- <div class="bg-image-hover-overlay"
499
- style="position: absolute; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; gap: 8px;"
500
- @click.stop>
501
- <button class="design-action-btn"
502
- style="padding: 3px 8px; font-size: 11px; background: #fff; color: #333; border: none; border-radius: 4px; cursor: pointer;"
503
- @click="uploadBgImage">更换</button>
504
- <button class="design-action-btn"
505
- style="padding: 3px 8px; font-size: 11px; background: #ef4444; color: #fff; border: none; border-radius: 4px; cursor: pointer;"
506
- @click="bgImageUrl = ''">删除</button>
507
- </div>
508
- </template>
509
- <template v-else>
510
- <div
511
- style="display: flex; flex-direction: column; align-items: center; gap: 4px; color: var(--text-muted);">
512
- <span style="font-size: 11px;">点击上传图片</span>
513
- </div>
514
- </template>
515
- </div>
516
- </div>
517
- </template>
518
-
519
- <!-- 主题色 -->
520
- <div class="design-property-row">
521
- <span class="shape-property-label">主题色</span>
522
- <div style="flex: 1; display: flex; align-items: center;">
523
- <el-popover placement="bottom-end" :width="220" trigger="click" popper-class="theme-palette-popover"
524
- v-model:visible="themePalettePopoverVisible">
525
- <template #reference>
526
- <div class="theme-color-trigger">
527
- <div class="theme-color-preview-list">
528
- <div v-for="(color, cIdx) in globalThemeColor" :key="cIdx" :style="{ background: color }"
529
- class="theme-preview-rect">
530
- </div>
531
- </div>
532
- <span style="font-size: 10px; color: var(--text-muted);">▼</span>
533
- </div>
534
- </template>
535
- <div style="display: flex; flex-direction: column; gap: 10px; padding: 4px 0;">
536
- <div v-for="cIdx in 5" :key="cIdx"
537
- style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
538
- <span style="font-size: 12px; color: var(--text-secondary); width: 64px;">色彩 {{ cIdx }}</span>
539
- <QuickColorPopover v-model:visible="globalThemeColorPopoverVisible[cIdx - 1]"
540
- :color="globalThemeColor[cIdx - 1]" v-model:recentColors="quickRecentColors" placement="left-start"
541
- @select-color="(color) => globalThemeColor[cIdx - 1] = color"
542
- @color-picker-show="isCustomColorPickerOpen = true"
543
- @color-picker-hide="() => { isCustomColorPickerOpen = false }">
544
- <template #reference>
545
- <div class="color-picker-pill-wrapper" style="width: 100%;">
546
- <div class="color-pill-inner" style="height: 28px;">
547
- <div
548
- style="width: 12px; height: 12px; border: 1px solid rgba(0,0,0,0.1); border-radius: 2px; flex-shrink: 0;"
549
- :style="{ background: globalThemeColor[cIdx - 1] }"></div>
550
- <span
551
- style="font-size: 11px; color: var(--text-primary); font-family: monospace; font-weight: 500;">
552
- {{ formatHexDisplay(globalThemeColor[cIdx - 1]) }}
553
- </span>
554
- </div>
555
- </div>
556
- </template>
557
- </QuickColorPopover>
558
- </div>
559
- </div>
560
- </el-popover>
561
- </div>
562
- </div>
563
-
564
- <!-- 边框颜色 -->
565
- <div class="design-property-row">
566
- <span class="shape-property-label">边框颜色</span>
567
- <QuickColorPopover v-model:visible="globalBorderColorPopoverVisible" :color="globalBorderColor"
568
- v-model:recentColors="quickRecentColors" @select-color="(color) => globalBorderColor = color">
569
- <template #reference>
570
- <div class="color-picker-pill-wrapper" style="flex: 1;">
571
- <div class="color-pill-inner" style="height: 32px;">
572
- <div :style="{ background: globalBorderColor }" class="color-square-preview"></div>
573
- <span style="font-size: 11px; color: var(--text-primary); font-family: monospace; font-weight: 500;">
574
- {{ formatHexDisplay(globalBorderColor) }}
575
- </span>
576
- </div>
577
- </div>
578
- </template>
579
- </QuickColorPopover>
580
- </div>
581
-
582
- <!-- 边框样式 -->
583
- <div class="design-property-row">
584
- <span class="shape-property-label">边框样式</span>
585
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: center; flex: 1;">
586
- <el-select v-model="globalBorderStyle" size="small" style="flex: 1;" class="design-select"
587
- placement="bottom-start" :fallback-placements="['bottom-start', 'bottom', 'top-start']">
588
- <el-option value="solid" label="──────" />
589
- <el-option value="dashed" label="------" />
590
- <el-option value="dotted" label="······" />
591
- <el-option value="none" label="无" />
592
- </el-select>
593
- <div class="shape-border-width-wrapper"
594
- style="height: 32px; border: none; border-radius: 4px; padding: 0 8px; display: flex; align-items: center; box-sizing: border-box; background: #F5F6F7;">
595
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"
596
- style="margin-right: 4px; color: var(--text-secondary); flex-shrink: 0;">
597
- <line x1="3" y1="6" x2="21" y2="6" />
598
- <line x1="3" y1="12" x2="21" y2="12" />
599
- <line x1="3" y1="18" x2="21" y2="18" />
600
- </svg>
601
- <input type="number" v-model.number="globalBorderWidth" :min="0" :max="20" class="shape-num-field"
602
- style="width: 100%; border: none; background: transparent; outline: none; text-align: left; font-size: 12px; color: var(--text-primary); -moz-appearance: textfield; appearance: none;" />
603
- </div>
604
- </div>
605
- </div>
606
-
607
- <!-- 4 个操作按钮网格 (2x2) -->
608
- <div class="design-btn-grid">
609
- <el-button type="primary" size="small" class="design-action-btn-primary"
610
- @click="applyBackgroundToAll">背景色应用到全部</el-button>
611
- <el-button type="primary" size="small" class="design-action-btn-primary"
612
- @click="applyThemeToAll">主题色应用到全部</el-button>
613
- <el-button type="primary" size="small" class="design-action-btn-secondary"
614
- @click="applyFontToAll">全局统一字体</el-button>
615
- <el-button type="primary" size="small" class="design-action-btn-secondary"
616
- @click="openThemeExtractor">从幻灯片提取主题</el-button>
617
- </div>
618
- </div>
619
-
620
- <!-- 2. 主题设置 Tab (Theme Settings) -->
621
- <div v-else-if="designActiveTab === 'theme'"
622
- style="display: flex; flex-direction: column; gap: 14px; padding: 16px;">
623
- <StyleBuilderDialog :visible="styleBuilderVisible" @update:visible="emit('update:styleBuilderVisible', $event)"
624
- :target-mode="styleBuilderTargetMode" :initial-recipe-id="styleBuilderInitialRecipeId"
625
- :is-new-custom="styleBuilderIsNewCustom" />
626
-
627
- <!-- 主题样式套件 -->
628
- <div class="design-section" style="padding: 0;">
629
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
630
- <div class="section-title" style="margin: 0; font-weight: bold; color: var(--text-primary);">
631
- 主题样式套件
632
- </div>
633
- <span style="font-size: 11px; color: #6366f1; cursor: pointer; font-weight: 500;"
634
- @click="emit('openStyleBuilder', isMasterEditingView ? 'all' : 'single', 'new-custom', true)">
635
- + 定制主题
636
- </span>
637
- </div>
638
-
639
- <div class="style-recipes-grid" style="display: flex; flex-direction: column; gap: 10px;">
640
- <div v-for="recipe in builtinStyleRecipes" :key="recipe.id" class="recipe-theme-card" :class="{
641
- 'active-slide-theme': currentActiveThemeId === recipe.id
642
- }" :style="{ backgroundColor: recipe.backgroundColor || '#ffffff' }">
643
-
644
- <!-- Card Header -->
645
- <div class="recipe-card-header">
646
- <div style="display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1;">
647
- <span class="recipe-theme-name" :style="{ color: recipe.textStyles?.title?.color || '#0f172a' }">
648
- {{ recipe.name }}
649
- </span>
650
- <span v-if="currentActiveThemeId === recipe.id" class="current-slide-pill">
651
- {{ isMasterEditingView ? '当前母版' : '当前页' }}
652
- </span>
653
- </div>
654
- <div v-if="!isMasterEditingView" class="recipe-usage-badge" :class="getThemeUsageLabel(recipe.id).type">
655
- {{ getThemeUsageLabel(recipe.id).text }}
656
- </div>
657
- </div>
658
-
659
- <!-- Preview Area -->
660
- <div class="recipe-card-preview" @click="emit('applyStyleRecipeToSlides', recipe, false)">
661
- <div class="recipe-font-preview"
662
- :style="{ fontFamily: recipe.styleFonts?.titleFont || 'PingFang SC', color: recipe.textStyles?.title?.color || '#1e293b' }">
663
- <span style="font-size: 16px; font-weight: 700;">Aa</span>
664
- <span style="font-size: 11px; opacity: 0.8; margin-left: 6px;">{{ recipe.styleFonts?.titleFont || '默认字体'
665
- }}</span>
666
- </div>
667
- <!-- 8 Color Palette Strip -->
668
- <div class="recipe-palette-strip">
669
- <div v-for="(color, cIdx) in recipe.styleColors" :key="cIdx" class="palette-strip-block"
670
- :style="{ backgroundColor: color }"></div>
671
- </div>
672
- </div>
673
-
674
- <!-- Quick Actions -->
675
- <div class="recipe-card-actions">
676
- <template v-if="isMasterEditingView">
677
- <button class="recipe-act-btn" @click.stop="emit('applyStyleRecipeToSlides', recipe, false)"
678
- title="仅应用到当前选中的母版及下属版式">
679
- ⚡ 当前母版
680
- </button>
681
- <button class="recipe-act-btn" @click.stop="emit('applyStyleRecipeToSlides', recipe, true)"
682
- title="应用到所有母版">
683
- 🌐 全部母版
684
- </button>
685
- <button class="recipe-act-btn btn-edit" @click.stop="emit('openStyleBuilder', 'all', recipe.id)"
686
- title="进入 Style Builder 编辑该主题">
687
- ✏️ 编辑
688
- </button>
689
- </template>
690
- <template v-else>
691
- <button class="recipe-act-btn" @click.stop="emit('applyStyleRecipeToSlides', recipe, false)"
692
- :title="`仅应用到第 ${activeIndex + 1} 页`">
693
- ⚡ 仅当前页 (P{{ activeIndex + 1 }})
694
- </button>
695
- <button class="recipe-act-btn" @click.stop="emit('applyStyleRecipeToSlides', recipe, true)"
696
- title="应用到全部幻灯片">
697
- 🌐 全部幻灯片
698
- </button>
699
- <button class="recipe-act-btn btn-edit" @click.stop="emit('openStyleBuilder', 'all', recipe.id)"
700
- title="进入 Style Builder 编辑该主题">
701
- ✏️ 编辑
702
- </button>
703
- </template>
704
- </div>
705
- </div>
706
- </div>
707
- </div>
708
-
709
- <!-- 预置主题列表 -->
710
- <div v-if="presetThemes && presetThemes.length > 0" class="design-section" style="padding: 0; margin-top: 10px;">
711
- <div class="section-title" style="margin-bottom: 12px; font-weight: bold; color: var(--text-primary);">预置配色主题
712
- </div>
713
- <div class="preset-themes-grid" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">
714
- <div v-for="preset in presetThemes" :key="preset.name" class="preset-theme-card"
715
- :style="{ background: preset.bgColor }" @click="applyPresetTheme(preset)"
716
- 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;">
717
- <div class="preset-theme-title" :style="{ fontFamily: preset.fontFamily, color: preset.fontColor }"
718
- style="font-size: 13px; font-weight: bold; margin: 0; text-align: left;">
719
- 文字 Aa
720
- </div>
721
- <div class="preset-theme-palette"
722
- style="display: flex; gap: 2px; border-radius: 2px; overflow: hidden; height: 8px;">
723
- <div v-for="color in preset.palette" :key="color" :style="{ background: color, flex: 1, height: '100%' }">
724
- </div>
725
- </div>
726
- <div class="preset-theme-name" :style="{ color: preset.isDark ? '#cbd5e1' : 'var(--text-muted)' }"
727
- style="font-size: 10px; margin-top: auto; opacity: 0.8; text-align: left;">
728
- {{ preset.name }}
729
- </div>
730
- </div>
731
- </div>
732
- </div>
733
- </div>
734
-
735
- <!-- 3. 版式设置 Tab (Layout Settings) -->
736
- <div v-else-if="designActiveTab === 'layout' && !isMasterEditingView"
737
- style="display: flex; flex-direction: column; gap: 14px; padding: 16px;">
738
- <div style="display: flex; justify-content: space-between; align-items: center;">
739
- <span style="font-size: 13px; font-weight: bold; color: var(--text-primary);">布局</span>
740
- <span style="font-size: 12px; color: var(--accent-primary); cursor: pointer;"
741
- @click="emit('enterMasterView')">编辑母版</span>
742
- </div>
743
-
744
- <div v-for="masterGroup in masterGroupedLayouts" :key="masterGroup.id" class="preset-layout-master-group"
745
- style="margin-bottom: 16px;">
746
- <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">
747
- {{ masterGroup.name }}
748
- </div>
749
- <div class="preset-layout-grid"
750
- style="grid-template-columns: repeat(2, 1fr); gap: 12px; max-height: none; overflow: visible;">
751
- <div v-for="layout in masterGroup.layouts" :key="layout.id" class="preset-layout-item"
752
- :class="{ 'is-active-layout': slides[activeIndex]?.layoutId === layout.id }"
753
- @click="emit('applyLayoutToCurrentSlide', layout.id)">
754
- <div class="preset-layout-thumb"
755
- :style="{ width: '100%', aspectRatio: '16/9', height: 'auto', background: layout.background || masterGroup.background || '#ffffff', outline: slides[activeIndex]?.layoutId === layout.id ? '2px solid var(--accent-primary, #3b82f6)' : undefined, outlineOffset: '1px', borderRadius: '4px' }">
756
- <LayoutThumbSvg :elements="layout.elements || []"
757
- :background="layout.background || masterGroup.background || '#ffffff'" />
758
- </div>
759
- <div class="preset-layout-name"
760
- :style="{ fontSize: '11px', color: slides[activeIndex]?.layoutId === layout.id ? 'var(--accent-primary, #3b82f6)' : 'var(--text-secondary)', fontWeight: slides[activeIndex]?.layoutId === layout.id ? '600' : 'normal', marginTop: '4px', textAlign: 'center' }">
761
- {{ layout.name }}
762
- </div>
763
- </div>
764
- </div>
765
- </div>
766
- </div>
767
-
768
- <!-- Theme Extractor Dialog -->
769
- <el-dialog v-model="themeExtractorVisible" width="440px" destroy-on-close align-center :show-close="false"
770
- class="theme-extractor-dialog">
771
- <div class="extractor-container">
772
- <!-- Tabs Header -->
773
- <div class="extractor-tabs">
774
- <div class="extractor-tab" :class="{ active: extractTab === 'current' }" @click="extractTab = 'current'">
775
- 从当前页中提取
776
- </div>
777
- <div class="extractor-tab" :class="{ active: extractTab === 'all' }" @click="extractTab = 'all'">
778
- 从全部幻灯片提取
779
- </div>
780
- </div>
781
-
782
- <!-- Extractor Content -->
783
- <div class="extractor-content">
784
- <!-- Font Row -->
785
- <div class="extractor-row">
786
- <div class="extractor-row-label">字体:</div>
787
- <div class="extractor-row-body">
788
- <div class="extractor-val-box">
789
- {{ fontLabel(extractedFont) }}
790
- </div>
791
- <el-checkbox v-model="saveFont" class="extractor-checkbox">
792
- 选择 应用到主题
793
- </el-checkbox>
794
- </div>
795
- </div>
796
-
797
- <!-- Text Color Row -->
798
- <div class="extractor-row">
799
- <div class="extractor-row-label">文字颜色:</div>
800
- <div class="extractor-row-body">
801
- <div class="extractor-color-box">
802
- <div class="color-preview-bar" :style="{ background: extractedFontColor }"></div>
803
- <span>{{ extractedFontColor.toUpperCase() }}</span>
804
- </div>
805
- <el-checkbox v-model="saveFontColor" class="extractor-checkbox">
806
- 选择 应用到主题
807
- </el-checkbox>
808
- </div>
809
- </div>
810
-
811
- <!-- Background Color Row -->
812
- <div class="extractor-row">
813
- <div class="extractor-row-label">背景颜色:</div>
814
- <div class="extractor-row-body">
815
- <div class="extractor-color-box">
816
- <div class="color-preview-bar" :style="{ background: extractedBgColor }"></div>
817
- <span>{{ extractedBgColor.toUpperCase() }}</span>
818
- </div>
819
- <el-checkbox v-model="saveBgColor" class="extractor-checkbox">
820
- 选择 应用到主题
821
- </el-checkbox>
822
- </div>
823
- </div>
824
-
825
- <!-- Theme Colors Row -->
826
- <div class="extractor-row">
827
- <div class="extractor-row-label"
828
- style="display: flex; align-items: center; justify-content: space-between;">
829
- <span>主题色:<span
830
- style="font-size: 11px; color: var(--text-muted); font-weight: normal;">(点击色块排除不要的颜色)</span></span>
831
- </div>
832
- <div class="extractor-palette-grid">
833
- <div v-for="(c, cIdx) in extractedThemeColors" :key="cIdx" class="extractor-palette-block"
834
- :class="{ excluded: !c.selected }" :style="{ background: c.color }" @click="c.selected = !c.selected"
835
- :title="c.color">
836
- <div v-if="!c.selected" class="block-slash"></div>
837
- </div>
838
- <div v-if="extractedThemeColors.length === 0"
839
- style="font-size: 11px; color: var(--text-muted); padding: 4px 0;">
840
- 未检测到可用配色
841
- </div>
842
- </div>
843
- </div>
844
- </div>
845
-
846
- <!-- Action Button -->
847
- <button class="extractor-save-btn" @click="saveExtractedTheme">
848
- ✓ 将选中配置保存为主题
849
- </button>
850
- </div>
851
- </el-dialog>
852
- </div>
853
- </template>
854
-
855
- <style scoped>
856
- .design-property-row {
857
- display: flex;
858
- align-items: center;
859
- justify-content: space-between;
860
- gap: 8px;
861
- }
862
-
863
- .design-property-row-start {
864
- display: flex;
865
- align-items: flex-start;
866
- justify-content: space-between;
867
- gap: 8px;
868
- }
869
-
870
- .shape-property-label {
871
- font-size: 12px;
872
- color: var(--text-secondary, #64748b);
873
- width: 80px;
874
- flex-shrink: 0;
875
- text-align: left;
876
- }
877
-
878
- .design-select {
879
- flex: 1;
880
- }
881
-
882
- .design-select :deep(.el-select__wrapper) {
883
- background-color: #F5F6F7 !important;
884
- border: none !important;
885
- box-shadow: none !important;
886
- height: 32px !important;
887
- min-height: 32px !important;
888
- line-height: 32px !important;
889
- padding: 0 10px !important;
890
- border-radius: 4px !important;
891
- cursor: pointer;
892
- }
893
-
894
- .design-select :deep(.el-select__wrapper.is-focused) {
895
- box-shadow: 0 0 0 1px var(--accent-primary, #6366f1) inset !important;
896
- }
897
-
898
- .design-select :deep(.el-select__selected-item) {
899
- font-size: 12px !important;
900
- color: var(--text-primary, #1e293b) !important;
901
- font-weight: 500;
902
- }
903
-
904
- .design-select :deep(.el-select__placeholder) {
905
- font-size: 12px !important;
906
- }
907
-
908
- .design-select :deep(.el-select__suffix) {
909
- color: #94a3b8;
910
- }
911
-
912
- /* 4 操作按钮网格样式 */
913
- .design-btn-grid {
914
- display: grid;
915
- grid-template-columns: 1fr 1fr;
916
- gap: 8px;
917
- margin-top: 6px;
918
- }
919
-
920
- .design-action-btn-primary {
921
- margin: 0 !important;
922
- height: 32px !important;
923
- background-color: #2563eb !important;
924
- border-color: #2563eb !important;
925
- color: #ffffff !important;
926
- font-size: 12px !important;
927
- font-weight: 500 !important;
928
- border-radius: 4px !important;
929
- }
930
-
931
- .design-action-btn-primary:hover {
932
- background-color: #1d4ed8 !important;
933
- border-color: #1d4ed8 !important;
934
- }
935
-
936
- .design-action-btn-secondary {
937
- margin: 0 !important;
938
- height: 32px !important;
939
- background-color: #e0ebff !important;
940
- border-color: #e0ebff !important;
941
- color: #2563eb !important;
942
- font-size: 12px !important;
943
- font-weight: 500 !important;
944
- border-radius: 4px !important;
945
- }
946
-
947
- .design-action-btn-secondary:hover {
948
- background-color: #d0e1fd !important;
949
- border-color: #d0e1fd !important;
950
- }
951
-
952
- /* 颜色选择药丸外观 */
953
- .color-picker-pill-wrapper {
954
- background: #f5f6f7;
955
- border-radius: 4px;
956
- padding: 0 10px;
957
- cursor: pointer;
958
- box-sizing: border-box;
959
- display: flex;
960
- align-items: center;
961
- }
962
-
963
- .color-pill-inner {
964
- display: flex;
965
- align-items: center;
966
- gap: 8px;
967
- width: 100%;
968
- }
969
-
970
- .color-square-preview {
971
- width: 14px;
972
- height: 14px;
973
- border: 1px solid rgba(0, 0, 0, 0.15);
974
- border-radius: 2px;
975
- flex-shrink: 0;
976
- }
977
-
978
- /* 主题色 5 色预览条 */
979
- .theme-color-trigger {
980
- flex: 1;
981
- display: flex;
982
- align-items: center;
983
- justify-content: space-between;
984
- padding: 0 12px;
985
- background: #f5f6f7;
986
- border-radius: 4px;
987
- cursor: pointer;
988
- height: 32px;
989
- box-sizing: border-box;
990
- }
991
-
992
- .theme-color-preview-list {
993
- display: flex;
994
- gap: 6px;
995
- align-items: center;
996
- }
997
-
998
- .theme-preview-rect {
999
- width: 16px;
1000
- height: 16px;
1001
- border-radius: 4px;
1002
- border: 1px solid rgba(0, 0, 0, 0.05);
1003
- box-sizing: border-box;
1004
- }
1005
-
1006
- /* 主题提取器弹窗样式 */
1007
- .extractor-container {
1008
- display: flex;
1009
- flex-direction: column;
1010
- gap: 16px;
1011
- }
1012
-
1013
- .extractor-tabs {
1014
- display: flex;
1015
- border-bottom: 1px solid var(--border-color);
1016
- margin-bottom: 6px;
1017
- }
1018
-
1019
- .extractor-tab {
1020
- flex: 1;
1021
- text-align: center;
1022
- font-size: 14px;
1023
- font-weight: 600;
1024
- padding: 10px 0;
1025
- cursor: pointer;
1026
- color: var(--text-secondary);
1027
- border-bottom: 2px solid transparent;
1028
- transition: all 0.2s ease;
1029
- }
1030
-
1031
- .extractor-tab.active {
1032
- color: #2563eb;
1033
- border-bottom-color: #2563eb;
1034
- }
1035
-
1036
- .extractor-content {
1037
- display: flex;
1038
- flex-direction: column;
1039
- gap: 14px;
1040
- }
1041
-
1042
- .extractor-row {
1043
- display: flex;
1044
- flex-direction: column;
1045
- gap: 8px;
1046
- border-bottom: 1px dashed var(--border-color);
1047
- padding-bottom: 14px;
1048
- }
1049
-
1050
- .extractor-row:last-child {
1051
- border-bottom: none;
1052
- padding-bottom: 0;
1053
- }
1054
-
1055
- .extractor-row-label {
1056
- font-size: 13px;
1057
- font-weight: 700;
1058
- color: var(--text-primary);
1059
- }
1060
-
1061
- .extractor-row-body {
1062
- display: flex;
1063
- align-items: center;
1064
- justify-content: space-between;
1065
- gap: 12px;
1066
- }
1067
-
1068
- .extractor-val-box,
1069
- .extractor-color-box {
1070
- border: 1px solid var(--border-color);
1071
- border-radius: 4px;
1072
- height: 32px;
1073
- flex: 1.2;
1074
- display: flex;
1075
- align-items: center;
1076
- padding: 0 10px;
1077
- font-size: 12px;
1078
- color: var(--text-primary);
1079
- background: #ffffff;
1080
- }
1081
-
1082
- .extractor-color-box {
1083
- gap: 8px;
1084
- }
1085
-
1086
- .color-preview-bar {
1087
- width: 48px;
1088
- height: 16px;
1089
- border-radius: 2px;
1090
- border: 1px solid rgba(0, 0, 0, 0.08);
1091
- }
1092
-
1093
- .extractor-palette-grid {
1094
- display: flex;
1095
- flex-wrap: wrap;
1096
- gap: 8px;
1097
- margin-top: 4px;
1098
- }
1099
-
1100
- .extractor-palette-block {
1101
- width: 28px;
1102
- height: 28px;
1103
- border-radius: 4px;
1104
- cursor: pointer;
1105
- border: 1px solid rgba(0, 0, 0, 0.08);
1106
- position: relative;
1107
- transition: all 0.2s ease;
1108
- }
1109
-
1110
- .extractor-palette-block:hover {
1111
- transform: scale(1.1);
1112
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
1113
- }
1114
-
1115
- .extractor-palette-block.excluded {
1116
- opacity: 0.25;
1117
- }
1118
-
1119
- .block-slash {
1120
- position: absolute;
1121
- top: 50%;
1122
- left: 0;
1123
- width: 100%;
1124
- height: 1.5px;
1125
- background: #ef4444;
1126
- transform: translateY(-50%) rotate(45deg);
1127
- }
1128
-
1129
- .extractor-save-btn {
1130
- background: #2563eb;
1131
- border: none;
1132
- border-radius: 4px;
1133
- color: #ffffff;
1134
- font-size: 13px;
1135
- font-weight: 600;
1136
- padding: 10px 0;
1137
- cursor: pointer;
1138
- width: 100%;
1139
- display: flex;
1140
- align-items: center;
1141
- justify-content: center;
1142
- gap: 6px;
1143
- transition: all 0.2s ease;
1144
- }
1145
-
1146
- .extractor-save-btn:hover {
1147
- background: #1d4ed8;
1148
- box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
1149
- }
1150
-
1151
- :deep(.theme-extractor-dialog) {
1152
- border-radius: 8px;
1153
- overflow: hidden;
1154
- }
1155
-
1156
- :deep(.theme-extractor-dialog .el-dialog__header) {
1157
- display: none !important;
1158
- }
1159
-
1160
- :deep(.theme-extractor-dialog .el-dialog__body) {
1161
- padding: 20px 24px 24px 24px !important;
1162
- background: #ffffff;
1163
- }
1164
-
1165
- :deep(.extractor-checkbox) {
1166
- flex: 0.8;
1167
- font-size: 12px;
1168
- }
1169
-
1170
- /* Style Builder Theme Recipe Cards in Sidebar */
1171
- .recipe-theme-card {
1172
- width: 100%;
1173
- box-sizing: border-box;
1174
- border-radius: 10px;
1175
- border: 1.5px solid var(--border-color, #e2e8f0);
1176
- padding: 12px;
1177
- display: flex;
1178
- flex-direction: column;
1179
- gap: 10px;
1180
- transition: all 0.2s ease;
1181
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
1182
- }
1183
-
1184
- .recipe-theme-card:hover {
1185
- border-color: #a5b4fc;
1186
- box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1);
1187
- }
1188
-
1189
- .recipe-theme-card.active-slide-theme {
1190
- border-color: #6366f1;
1191
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.25);
1192
- }
1193
-
1194
- .recipe-card-header {
1195
- display: flex;
1196
- align-items: center;
1197
- justify-content: space-between;
1198
- gap: 8px;
1199
- }
1200
-
1201
- .recipe-theme-name {
1202
- font-size: 13px;
1203
- font-weight: 700;
1204
- line-height: 1.2;
1205
- white-space: nowrap;
1206
- overflow: hidden;
1207
- text-overflow: ellipsis;
1208
- }
1209
-
1210
- .current-slide-pill {
1211
- font-size: 10px;
1212
- background: #6366f1;
1213
- color: #ffffff;
1214
- padding: 1px 6px;
1215
- border-radius: 10px;
1216
- font-weight: 600;
1217
- flex-shrink: 0;
1218
- }
1219
-
1220
- .recipe-usage-badge {
1221
- font-size: 10px;
1222
- padding: 2px 7px;
1223
- border-radius: 10px;
1224
- font-weight: 500;
1225
- flex-shrink: 0;
1226
- white-space: nowrap;
1227
- }
1228
-
1229
- .recipe-usage-badge.all {
1230
- background: #ecfdf5;
1231
- color: #059669;
1232
- border: 1px solid #a7f3d0;
1233
- }
1234
-
1235
- .recipe-usage-badge.partial {
1236
- background: #eff6ff;
1237
- color: #2563eb;
1238
- border: 1px solid #bfdbfe;
1239
- }
1240
-
1241
- .recipe-usage-badge.none {
1242
- background: #f8fafc;
1243
- color: #94a3b8;
1244
- border: 1px solid #e2e8f0;
1245
- }
1246
-
1247
- .recipe-card-preview {
1248
- cursor: pointer;
1249
- padding: 8px 10px;
1250
- background: rgba(0, 0, 0, 0.02);
1251
- border-radius: 6px;
1252
- display: flex;
1253
- flex-direction: column;
1254
- gap: 8px;
1255
- transition: background 0.15s ease;
1256
- }
1257
-
1258
- .recipe-card-preview:hover {
1259
- background: rgba(99, 102, 241, 0.05);
1260
- }
1261
-
1262
- .recipe-font-preview {
1263
- display: flex;
1264
- align-items: baseline;
1265
- }
1266
-
1267
- .recipe-palette-strip {
1268
- display: flex;
1269
- height: 10px;
1270
- border-radius: 3px;
1271
- overflow: hidden;
1272
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
1273
- }
1274
-
1275
- .palette-strip-block {
1276
- flex: 1;
1277
- height: 100%;
1278
- }
1279
-
1280
- .recipe-card-actions {
1281
- display: flex;
1282
- gap: 6px;
1283
- align-items: center;
1284
- }
1285
-
1286
- .recipe-act-btn {
1287
- flex: 1;
1288
- padding: 5px 6px;
1289
- border-radius: 5px;
1290
- border: 1px solid #e2e8f0;
1291
- background: #ffffff;
1292
- color: #334155;
1293
- font-size: 11px;
1294
- font-weight: 500;
1295
- cursor: pointer;
1296
- white-space: nowrap;
1297
- transition: all 0.15s ease;
1298
- display: flex;
1299
- align-items: center;
1300
- justify-content: center;
1301
- }
1302
-
1303
- .recipe-act-btn:hover {
1304
- background: #f1f5f9;
1305
- border-color: #cbd5e1;
1306
- color: #0f172a;
1307
- }
1308
-
1309
- .recipe-act-btn.btn-edit {
1310
- flex: 0 0 auto;
1311
- padding: 5px 8px;
1312
- color: #6366f1;
1313
- border-color: #c7d2fe;
1314
- background: #eef2ff;
1315
- }
1316
-
1317
- .recipe-act-btn.btn-edit:hover {
1318
- background: #e0e7ff;
1319
- border-color: #a5b4fc;
1320
- }
1321
-
1322
- :deep(.color-span-box) {
1323
- width: 100%;
1324
- }
1325
-
1326
- .shape-num-field {
1327
- -moz-appearance: textfield;
1328
- appearance: textfield;
1329
-
1330
- &::-webkit-outer-spin-button,
1331
- &::-webkit-inner-spin-button {
1332
- -webkit-appearance: none;
1333
- margin: 0;
1334
- }
1335
- }
1336
- </style>
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, defineAsyncComponent } from 'vue'
3
+ // 样式构建器大弹层(~100KB 源码)异步化:首次打开才加载
4
+ const StyleBuilderDialog = defineAsyncComponent(() => import('../../StyleBuilderDialog.vue'))
5
+ import LayoutThumbSvg from './LayoutThumbSvg.vue'
6
+ import QuickColorPopover from '../../QuickColorPopover.vue'
7
+ import GradientEditor from './GradientEditor.vue'
8
+ import { useSlides, gradientPresets, normalizeSlideBackground } from '../../../composables/useSlides'
9
+ import { SYS_FONTS } from '../../../configs/fonts'
10
+
11
+ const props = defineProps<{
12
+ designActiveTab: string
13
+ slideRatio: string
14
+ styleBuilderVisible: boolean
15
+ styleBuilderTargetMode: 'all' | 'single'
16
+ styleBuilderInitialRecipeId: string
17
+ styleBuilderIsNewCustom: boolean
18
+ builtinStyleRecipes: any[]
19
+ slides: any[]
20
+ activeIndex: number
21
+ masterGroupedLayouts: any[]
22
+ }>()
23
+
24
+ // 样式构建器挂载闩锁:首次打开后保持挂载,异步 chunk 只加载一次
25
+ const styleBuilderMounted = ref(false)
26
+ watch(() => props.styleBuilderVisible, v => { if (v) styleBuilderMounted.value = true })
27
+
28
+ const emit = defineEmits<{
29
+ (e: 'update:designActiveTab', val: string): void
30
+ (e: 'update:slideRatio', val: string): void
31
+ (e: 'update:styleBuilderVisible', val: boolean): void
32
+ (e: 'openStyleBuilder', mode: 'all' | 'single', recipeId?: string, isNew?: boolean): void
33
+ (e: 'applyStyleRecipeToSlides', recipe: any, isAll: boolean): void
34
+ (e: 'enterMasterView'): void
35
+ (e: 'applyLayoutToCurrentSlide', layoutId: string): void
36
+ }>()
37
+
38
+ const {
39
+ globalFontFamily,
40
+ globalFontColor,
41
+ globalBgColor,
42
+ globalThemeColor,
43
+ globalBorderStyle,
44
+ globalBorderColor,
45
+ globalBorderWidth,
46
+ presetThemes,
47
+ applyBackgroundToAll,
48
+ applyThemeToAll,
49
+ applyFontToAll,
50
+ applyPresetTheme,
51
+ activeSlide,
52
+ slides,
53
+ activeIndex,
54
+ notify,
55
+ isMasterEditingView,
56
+ activeMasterId,
57
+ slideMasters,
58
+ getThemeUsageLabel,
59
+ addHistorySnapshot,
60
+ uploadResource
61
+ } = useSlides()
62
+
63
+ const currentActiveThemeId = computed(() => {
64
+ if (isMasterEditingView.value) {
65
+ const master = slideMasters.value.find(m => m.id === activeMasterId.value) || slideMasters.value[0]
66
+ return (master as any)?.themeId || 'default-recipe'
67
+ }
68
+ return props.slides[props.activeIndex]?.themeId || 'default-recipe'
69
+ })
70
+
71
+ // Quick Color Popover recent colors
72
+ const quickRecentColors = ref<string[]>([])
73
+ const globalFontColorPopoverVisible = ref(false)
74
+ const globalBorderColorPopoverVisible = ref(false)
75
+ const bgColorPopoverVisible = ref(false)
76
+ const isCustomColorPickerOpen = ref(false)
77
+
78
+ // 主题色色盘 Popover
79
+ const themePalettePopoverVisible = ref(false)
80
+ const globalThemeColorPopoverVisible = ref<boolean[]>(Array(5).fill(false))
81
+
82
+ // 格式化 hex 显示(去掉 # 并大写)
83
+ const formatHexDisplay = (color: string) => {
84
+ if (!color || typeof color !== 'string') return 'FFFFFF'
85
+ return color.replace('#', '').toUpperCase()
86
+ }
87
+
88
+ // 字体显示名称
89
+ const fontLabel = (font: string) => SYS_FONTS.find(f => f.value === font || f.label === font)?.label || font
90
+
91
+ // 背景类型与颜色
92
+ const bgFillType = computed({
93
+ get() {
94
+ if (activeSlide.value?.bgType) return activeSlide.value.bgType
95
+ const bg = activeSlide.value?.background
96
+ if (bg) {
97
+ return normalizeSlideBackground(bg).bgType || 'color'
98
+ }
99
+ return 'color'
100
+ },
101
+ set(val: 'color' | 'gradient' | 'image') {
102
+ if (activeSlide.value) {
103
+ activeSlide.value.bgType = val
104
+ 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'))) {
106
+ activeSlide.value.background = '#ffffff'
107
+ }
108
+ } else if (val === 'gradient') {
109
+ activeSlide.value.background = 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
110
+ } else if (val === 'image') {
111
+ activeSlide.value.background = ''
112
+ }
113
+ addHistorySnapshot()
114
+ }
115
+ }
116
+ })
117
+
118
+ const activeBgColor = computed({
119
+ get() {
120
+ const bg = activeSlide.value?.background
121
+ if (!bg) return '#ffffff'
122
+ if (typeof bg === 'string') {
123
+ if (bg.startsWith('#') || bg.startsWith('rgb')) return bg
124
+ const colorMatch = bg.match(/(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\))/)
125
+ if (colorMatch) return colorMatch[1]
126
+ return '#ffffff'
127
+ }
128
+ if (typeof bg === 'object' && bg !== null) {
129
+ if (bg.type === 'color') return typeof bg.value === 'string' ? bg.value : '#ffffff'
130
+ if (bg.type === 'gradient') {
131
+ const c = bg.value?.colors?.[0]?.color
132
+ return typeof c === 'string' ? c : '#ffffff'
133
+ }
134
+ if (bg.type === 'image') {
135
+ const c = bg.value?.color || bg.value?.src || bg.value?.base64
136
+ return typeof c === 'string' && c.startsWith('#') ? c : '#ffffff'
137
+ }
138
+ if (typeof bg.value === 'string') return bg.value
139
+ }
140
+ return '#ffffff'
141
+ },
142
+ set(val: string) {
143
+ if (activeSlide.value) {
144
+ activeSlide.value.background = val
145
+ activeSlide.value.bgType = 'color'
146
+ addHistorySnapshot()
147
+ }
148
+ }
149
+ })
150
+
151
+ const bgImageMode = computed({
152
+ get() {
153
+ return (activeSlide.value as any)?.bgImageMode || 'stretch'
154
+ },
155
+ set(val: string) {
156
+ if (activeSlide.value) {
157
+ (activeSlide.value as any).bgImageMode = val
158
+ addHistorySnapshot()
159
+ }
160
+ }
161
+ })
162
+
163
+ const bgImageUrl = computed({
164
+ 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] : ''
168
+ },
169
+ set(url: string) {
170
+ if (activeSlide.value) {
171
+ if (!url) {
172
+ activeSlide.value.background = '#ffffff'
173
+ activeSlide.value.bgType = 'color'
174
+ } else {
175
+ activeSlide.value.background = `url("${url}")`
176
+ activeSlide.value.bgType = 'image'
177
+ }
178
+ addHistorySnapshot()
179
+ }
180
+ }
181
+ })
182
+
183
+ const bgFileInputRef = ref<HTMLInputElement | null>(null)
184
+ const uploadBgImage = () => {
185
+ bgFileInputRef.value?.click()
186
+ }
187
+ const onBgFileChange = async (e: Event) => {
188
+ const input = e.target as HTMLInputElement
189
+ if (!input.files || !input.files[0]) return
190
+ const file = input.files[0]
191
+ try {
192
+ const url = await uploadResource(file, { type: 'image', source: 'background', filename: file.name }, () => {
193
+ return new Promise<string>((resolve) => {
194
+ const reader = new FileReader()
195
+ reader.onload = (evt) => resolve(typeof evt.target?.result === 'string' ? evt.target.result : '')
196
+ reader.readAsDataURL(file)
197
+ })
198
+ })
199
+ if (url) {
200
+ bgImageUrl.value = url
201
+ notify('已成功更新背景图片')
202
+ }
203
+ } catch (err: any) {
204
+ console.error(err)
205
+ notify('背景图片上传失败: ' + (err?.message || err))
206
+ }
207
+ input.value = ''
208
+ }
209
+
210
+ const applyBackground = (css: string) => {
211
+ if (activeSlide.value) {
212
+ activeSlide.value.background = css
213
+ activeSlide.value.bgType = 'gradient'
214
+ addHistorySnapshot()
215
+ }
216
+ }
217
+
218
+ const activeGradientCss = computed(() => {
219
+ const bg = activeSlide.value?.background
220
+ if (!bg) return 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
221
+ if (typeof bg === 'string') {
222
+ if (bg.includes('gradient')) return bg
223
+ return `linear-gradient(135deg, ${bg} 0%, #ffffff 100%)`
224
+ }
225
+ if (typeof bg === 'object' && bg !== null) {
226
+ const norm = normalizeSlideBackground(bg)
227
+ if (norm.bgType === 'gradient') return norm.background
228
+ }
229
+ return 'linear-gradient(135deg, #f43f5e 0%, #f97316 100%)'
230
+ })
231
+
232
+ const onGradientEditorUpdate = (css: string) => {
233
+ if (activeSlide.value) {
234
+ activeSlide.value.background = css
235
+ activeSlide.value.bgType = 'gradient'
236
+ }
237
+ }
238
+
239
+ const onGradientEditorChange = (css: string) => {
240
+ if (activeSlide.value) {
241
+ activeSlide.value.background = css
242
+ activeSlide.value.bgType = 'gradient'
243
+ addHistorySnapshot()
244
+ }
245
+ }
246
+
247
+ // 主题提取器
248
+ const themeExtractorVisible = ref(false)
249
+ const extractTab = ref<'current' | 'all'>('current')
250
+ const saveFont = ref(true)
251
+ const saveFontColor = ref(true)
252
+ const saveBgColor = ref(true)
253
+ const extractedFont = ref('默认字体')
254
+ const extractedFontColor = ref('#000000')
255
+ const extractedBgColor = ref('#FFFFFF')
256
+ const extractedThemeColors = ref<{ color: string; selected: boolean }[]>([])
257
+
258
+ function toHexString(color: string): string {
259
+ if (!color) return '#000000'
260
+ if (color.startsWith('#')) return color
261
+ if (color.startsWith('rgb')) {
262
+ const rgb = color.match(/\d+/g)
263
+ if (rgb && rgb.length >= 3) {
264
+ const hex = rgb.slice(0, 3).map(x => parseInt(x).toString(16).padStart(2, '0')).join('')
265
+ return `#${hex}`
266
+ }
267
+ }
268
+ return color
269
+ }
270
+
271
+ function normalizeColor(color: string): string | null {
272
+ if (!color || typeof color !== 'string') return null
273
+ const hex = toHexString(color).toUpperCase()
274
+ return hex.startsWith('#') && (hex.length === 7 || hex.length === 4) ? hex : null
275
+ }
276
+
277
+ function extractThemeStyles(target: any) {
278
+ const slidesToExtract = Array.isArray(target) ? target : [target]
279
+ const fontNames: Record<string, number> = {}
280
+ const fontColors: Record<string, number> = {}
281
+ const backgroundColors: Record<string, number> = {}
282
+ const themeColors: Record<string, number> = {}
283
+
284
+ slidesToExtract.forEach((slide: any) => {
285
+ if (!slide) return
286
+ if (slide.background && typeof slide.background === 'string') {
287
+ const c = normalizeColor(slide.background)
288
+ if (c) backgroundColors[c] = (backgroundColors[c] || 0) + 1000
289
+ }
290
+ const elements = slide.elements || []
291
+ elements.forEach((el: any) => {
292
+ const area = (el.width || 100) * (el.height || 100)
293
+ if (el.type === 'text') {
294
+ const font = el.style?.fontFamily || el.fontFamily
295
+ if (font) fontNames[font] = (fontNames[font] || 0) + area
296
+ const fc = normalizeColor(el.style?.color || el.color)
297
+ if (fc) fontColors[fc] = (fontColors[fc] || 0) + area
298
+ }
299
+ if (el.type === 'shape' || el.type === 'line') {
300
+ const fill = normalizeColor(el.style?.fill || el.fill)
301
+ if (fill && fill !== '#FFFFFF' && fill !== '#000000') {
302
+ themeColors[fill] = (themeColors[fill] || 0) + area
303
+ }
304
+ const border = normalizeColor(el.style?.borderColor || el.borderColor)
305
+ if (border && border !== '#FFFFFF' && border !== '#000000') {
306
+ themeColors[border] = (themeColors[border] || 0) + area * 0.5
307
+ }
308
+ }
309
+ })
310
+ })
311
+
312
+ return {
313
+ fontNames: Object.keys(fontNames).sort((a, b) => fontNames[b] - fontNames[a]),
314
+ fontColors: Object.keys(fontColors).sort((a, b) => fontColors[b] - fontColors[a]),
315
+ backgroundColors: Object.keys(backgroundColors).sort((a, b) => backgroundColors[b] - backgroundColors[a]),
316
+ themeColors: Object.keys(themeColors).sort((a, b) => themeColors[b] - themeColors[a])
317
+ }
318
+ }
319
+
320
+ function performExtraction() {
321
+ saveFont.value = true
322
+ saveFontColor.value = true
323
+ saveBgColor.value = true
324
+
325
+ const target = extractTab.value === 'current' ? activeSlide.value : slides.value
326
+ if (!target) return
327
+
328
+ const styles = extractThemeStyles(target)
329
+ extractedBgColor.value = styles.backgroundColors[0] || '#FFFFFF'
330
+ extractedFontColor.value = styles.fontColors[0] || '#000000'
331
+ extractedFont.value = styles.fontNames[0] || '默认字体'
332
+
333
+ const bgHex = extractedBgColor.value.toUpperCase()
334
+ const fontHex = extractedFontColor.value.toUpperCase()
335
+
336
+ const finalColors = styles.themeColors.filter(c => {
337
+ const u = c.toUpperCase()
338
+ return u !== bgHex && u !== fontHex && u !== '#FFFFFF' && u !== '#000000'
339
+ })
340
+ extractedThemeColors.value = finalColors.slice(0, 8).map(color => ({ color, selected: true }))
341
+ }
342
+
343
+ function openThemeExtractor() {
344
+ extractTab.value = 'current'
345
+ performExtraction()
346
+ themeExtractorVisible.value = true
347
+ }
348
+
349
+ function saveExtractedTheme() {
350
+ if (saveFont.value) globalFontFamily.value = extractedFont.value
351
+ if (saveFontColor.value) globalFontColor.value = extractedFontColor.value
352
+ if (saveBgColor.value) globalBgColor.value = extractedBgColor.value
353
+
354
+ const selectedColors = extractedThemeColors.value.filter(c => c.selected).map(c => c.color)
355
+ if (selectedColors.length > 0) {
356
+ const finalPalette = [...selectedColors]
357
+ while (finalPalette.length < 5) {
358
+ finalPalette.push(finalPalette[finalPalette.length - 1] || '#3b82f6')
359
+ }
360
+ globalThemeColor.value = finalPalette.slice(0, 5)
361
+ }
362
+
363
+ const targetSlides = extractTab.value === 'current' ? (activeSlide.value ? [activeSlide.value] : []) : slides.value
364
+ targetSlides.forEach((s: any) => {
365
+ if (saveBgColor.value) {
366
+ s.background = extractedBgColor.value
367
+ s.bgType = 'color'
368
+ }
369
+ (s.elements || []).forEach((el: any) => {
370
+ if (el.type === 'text') {
371
+ if (!el.style) el.style = {}
372
+ if (saveFont.value) el.style.fontFamily = extractedFont.value
373
+ if (saveFontColor.value) el.style.color = extractedFontColor.value
374
+ }
375
+ })
376
+ })
377
+
378
+ themeExtractorVisible.value = false
379
+ notify?.('提取的主题已成功保存并应用!')
380
+ addHistorySnapshot()
381
+ }
382
+
383
+ watch(extractTab, () => {
384
+ performExtraction()
385
+ })
386
+ </script>
387
+
388
+ <template>
389
+ <div class="sidebar-design-panel flex-column" style="display: flex; flex-direction: column;">
390
+ <!-- 隐藏的图片背景上传 input -->
391
+ <input ref="bgFileInputRef" type="file" accept="image/*" style="display: none;" @change="onBgFileChange" />
392
+
393
+ <!-- 1. 全局设置 Tab (Global Settings) -->
394
+ <div v-if="designActiveTab === 'global'" style="display: flex; flex-direction: column; gap: 14px; padding: 16px;">
395
+ <!-- 画布尺寸 -->
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>
407
+ </div>
408
+
409
+ <!-- 字体 -->
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>
419
+ </div>
420
+
421
+ <!-- 字体颜色 -->
422
+ <div class="design-property-row">
423
+ <span class="shape-property-label">字体颜色</span>
424
+ <QuickColorPopover v-model:visible="globalFontColorPopoverVisible" :color="globalFontColor"
425
+ v-model:recentColors="quickRecentColors" @select-color="(color) => globalFontColor = color">
426
+ <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>
433
+ </div>
434
+ </div>
435
+ </template>
436
+ </QuickColorPopover>
437
+ </div>
438
+
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>
465
+ </div>
466
+ </div>
467
+
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>
483
+ </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">
501
+ <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>
512
+ </div>
513
+ </template>
514
+ <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>
519
+ </template>
520
+ </div>
521
+ </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">
535
+ </div>
536
+ </div>
537
+ <span style="font-size: 10px; color: var(--text-muted);">▼</span>
538
+ </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
+
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>
581
+ </div>
582
+ </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
+ </div>
609
+ </div>
610
+ </div>
611
+
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>
622
+ </div>
623
+ </div>
624
+
625
+ <!-- 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>
643
+
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>
663
+
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>
676
+ </div>
677
+ </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>
739
+
740
+ <!-- 3. 版式设置 Tab (Layout Settings) -->
741
+ <div v-else-if="designActiveTab === 'layout' && !isMasterEditingView"
742
+ style="display: flex; flex-direction: column; gap: 14px; padding: 16px;">
743
+ <div style="display: flex; justify-content: space-between; align-items: center;">
744
+ <span style="font-size: 13px; font-weight: bold; color: var(--text-primary);">布局</span>
745
+ <span style="font-size: 12px; color: var(--accent-primary); cursor: pointer;"
746
+ @click="emit('enterMasterView')">编辑母版</span>
747
+ </div>
748
+
749
+ <div v-for="masterGroup in masterGroupedLayouts" :key="masterGroup.id" class="preset-layout-master-group"
750
+ 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">
752
+ {{ masterGroup.name }}
753
+ </div>
754
+ <div class="preset-layout-grid"
755
+ style="grid-template-columns: repeat(2, 1fr); gap: 12px; max-height: none; overflow: visible;">
756
+ <div v-for="layout in masterGroup.layouts" :key="layout.id" class="preset-layout-item"
757
+ :class="{ 'is-active-layout': slides[activeIndex]?.layoutId === layout.id }"
758
+ @click="emit('applyLayoutToCurrentSlide', layout.id)">
759
+ <div class="preset-layout-thumb"
760
+ :style="{ width: '100%', aspectRatio: '16/9', height: 'auto', background: layout.background || masterGroup.background || '#ffffff', outline: slides[activeIndex]?.layoutId === layout.id ? '2px solid var(--accent-primary, #3b82f6)' : undefined, outlineOffset: '1px', borderRadius: '4px' }">
761
+ <LayoutThumbSvg :elements="layout.elements || []"
762
+ :background="layout.background || masterGroup.background || '#ffffff'" />
763
+ </div>
764
+ <div class="preset-layout-name"
765
+ :style="{ fontSize: '11px', color: slides[activeIndex]?.layoutId === layout.id ? 'var(--accent-primary, #3b82f6)' : 'var(--text-secondary)', fontWeight: slides[activeIndex]?.layoutId === layout.id ? '600' : 'normal', marginTop: '4px', textAlign: 'center' }">
766
+ {{ layout.name }}
767
+ </div>
768
+ </div>
769
+ </div>
770
+ </div>
771
+ </div>
772
+
773
+ <!-- Theme Extractor Dialog -->
774
+ <el-dialog v-model="themeExtractorVisible" width="440px" destroy-on-close align-center :show-close="false"
775
+ class="theme-extractor-dialog">
776
+ <div class="extractor-container">
777
+ <!-- Tabs Header -->
778
+ <div class="extractor-tabs">
779
+ <div class="extractor-tab" :class="{ active: extractTab === 'current' }" @click="extractTab = 'current'">
780
+ 从当前页中提取
781
+ </div>
782
+ <div class="extractor-tab" :class="{ active: extractTab === 'all' }" @click="extractTab = 'all'">
783
+ 从全部幻灯片提取
784
+ </div>
785
+ </div>
786
+
787
+ <!-- Extractor Content -->
788
+ <div class="extractor-content">
789
+ <!-- Font Row -->
790
+ <div class="extractor-row">
791
+ <div class="extractor-row-label">字体:</div>
792
+ <div class="extractor-row-body">
793
+ <div class="extractor-val-box">
794
+ {{ fontLabel(extractedFont) }}
795
+ </div>
796
+ <el-checkbox v-model="saveFont" class="extractor-checkbox">
797
+ 选择 应用到主题
798
+ </el-checkbox>
799
+ </div>
800
+ </div>
801
+
802
+ <!-- Text Color Row -->
803
+ <div class="extractor-row">
804
+ <div class="extractor-row-label">文字颜色:</div>
805
+ <div class="extractor-row-body">
806
+ <div class="extractor-color-box">
807
+ <div class="color-preview-bar" :style="{ background: extractedFontColor }"></div>
808
+ <span>{{ extractedFontColor.toUpperCase() }}</span>
809
+ </div>
810
+ <el-checkbox v-model="saveFontColor" class="extractor-checkbox">
811
+ 选择 应用到主题
812
+ </el-checkbox>
813
+ </div>
814
+ </div>
815
+
816
+ <!-- Background Color Row -->
817
+ <div class="extractor-row">
818
+ <div class="extractor-row-label">背景颜色:</div>
819
+ <div class="extractor-row-body">
820
+ <div class="extractor-color-box">
821
+ <div class="color-preview-bar" :style="{ background: extractedBgColor }"></div>
822
+ <span>{{ extractedBgColor.toUpperCase() }}</span>
823
+ </div>
824
+ <el-checkbox v-model="saveBgColor" class="extractor-checkbox">
825
+ 选择 应用到主题
826
+ </el-checkbox>
827
+ </div>
828
+ </div>
829
+
830
+ <!-- Theme Colors Row -->
831
+ <div class="extractor-row">
832
+ <div class="extractor-row-label"
833
+ style="display: flex; align-items: center; justify-content: space-between;">
834
+ <span>主题色:<span
835
+ style="font-size: 11px; color: var(--text-muted); font-weight: normal;">(点击色块排除不要的颜色)</span></span>
836
+ </div>
837
+ <div class="extractor-palette-grid">
838
+ <div v-for="(c, cIdx) in extractedThemeColors" :key="cIdx" class="extractor-palette-block"
839
+ :class="{ excluded: !c.selected }" :style="{ background: c.color }" @click="c.selected = !c.selected"
840
+ :title="c.color">
841
+ <div v-if="!c.selected" class="block-slash"></div>
842
+ </div>
843
+ <div v-if="extractedThemeColors.length === 0"
844
+ style="font-size: 11px; color: var(--text-muted); padding: 4px 0;">
845
+ 未检测到可用配色
846
+ </div>
847
+ </div>
848
+ </div>
849
+ </div>
850
+
851
+ <!-- Action Button -->
852
+ <button class="extractor-save-btn" @click="saveExtractedTheme">
853
+ ✓ 将选中配置保存为主题
854
+ </button>
855
+ </div>
856
+ </el-dialog>
857
+ </div>
858
+ </template>
859
+
860
+ <style scoped>
861
+ .design-property-row {
862
+ display: flex;
863
+ align-items: center;
864
+ justify-content: space-between;
865
+ gap: 8px;
866
+ }
867
+
868
+ .design-property-row-start {
869
+ display: flex;
870
+ align-items: flex-start;
871
+ justify-content: space-between;
872
+ gap: 8px;
873
+ }
874
+
875
+ .shape-property-label {
876
+ font-size: 12px;
877
+ color: var(--text-secondary, #64748b);
878
+ width: 80px;
879
+ flex-shrink: 0;
880
+ text-align: left;
881
+ }
882
+
883
+ .design-select {
884
+ flex: 1;
885
+ }
886
+
887
+ .design-select :deep(.el-select__wrapper) {
888
+ background-color: #F5F6F7 !important;
889
+ border: none !important;
890
+ box-shadow: none !important;
891
+ height: 32px !important;
892
+ min-height: 32px !important;
893
+ line-height: 32px !important;
894
+ padding: 0 10px !important;
895
+ border-radius: 4px !important;
896
+ cursor: pointer;
897
+ }
898
+
899
+ .design-select :deep(.el-select__wrapper.is-focused) {
900
+ box-shadow: 0 0 0 1px var(--accent-primary, #6366f1) inset !important;
901
+ }
902
+
903
+ .design-select :deep(.el-select__selected-item) {
904
+ font-size: 12px !important;
905
+ color: var(--text-primary, #1e293b) !important;
906
+ font-weight: 500;
907
+ }
908
+
909
+ .design-select :deep(.el-select__placeholder) {
910
+ font-size: 12px !important;
911
+ }
912
+
913
+ .design-select :deep(.el-select__suffix) {
914
+ color: #94a3b8;
915
+ }
916
+
917
+ /* 4 操作按钮网格样式 */
918
+ .design-btn-grid {
919
+ display: grid;
920
+ grid-template-columns: 1fr 1fr;
921
+ gap: 8px;
922
+ margin-top: 6px;
923
+ }
924
+
925
+ .design-action-btn-primary {
926
+ margin: 0 !important;
927
+ height: 32px !important;
928
+ background-color: #2563eb !important;
929
+ border-color: #2563eb !important;
930
+ color: #ffffff !important;
931
+ font-size: 12px !important;
932
+ font-weight: 500 !important;
933
+ border-radius: 4px !important;
934
+ }
935
+
936
+ .design-action-btn-primary:hover {
937
+ background-color: #1d4ed8 !important;
938
+ border-color: #1d4ed8 !important;
939
+ }
940
+
941
+ .design-action-btn-secondary {
942
+ margin: 0 !important;
943
+ height: 32px !important;
944
+ background-color: #e0ebff !important;
945
+ border-color: #e0ebff !important;
946
+ color: #2563eb !important;
947
+ font-size: 12px !important;
948
+ font-weight: 500 !important;
949
+ border-radius: 4px !important;
950
+ }
951
+
952
+ .design-action-btn-secondary:hover {
953
+ background-color: #d0e1fd !important;
954
+ border-color: #d0e1fd !important;
955
+ }
956
+
957
+ /* 颜色选择药丸外观 */
958
+ .color-picker-pill-wrapper {
959
+ background: #f5f6f7;
960
+ border-radius: 4px;
961
+ padding: 0 10px;
962
+ cursor: pointer;
963
+ box-sizing: border-box;
964
+ display: flex;
965
+ align-items: center;
966
+ }
967
+
968
+ .color-pill-inner {
969
+ display: flex;
970
+ align-items: center;
971
+ gap: 8px;
972
+ width: 100%;
973
+ }
974
+
975
+ .color-square-preview {
976
+ width: 14px;
977
+ height: 14px;
978
+ border: 1px solid rgba(0, 0, 0, 0.15);
979
+ border-radius: 2px;
980
+ flex-shrink: 0;
981
+ }
982
+
983
+ /* 主题色 5 色预览条 */
984
+ .theme-color-trigger {
985
+ flex: 1;
986
+ display: flex;
987
+ align-items: center;
988
+ justify-content: space-between;
989
+ padding: 0 12px;
990
+ background: #f5f6f7;
991
+ border-radius: 4px;
992
+ cursor: pointer;
993
+ height: 32px;
994
+ box-sizing: border-box;
995
+ }
996
+
997
+ .theme-color-preview-list {
998
+ display: flex;
999
+ gap: 6px;
1000
+ align-items: center;
1001
+ }
1002
+
1003
+ .theme-preview-rect {
1004
+ width: 16px;
1005
+ height: 16px;
1006
+ border-radius: 4px;
1007
+ border: 1px solid rgba(0, 0, 0, 0.05);
1008
+ box-sizing: border-box;
1009
+ }
1010
+
1011
+ /* 主题提取器弹窗样式 */
1012
+ .extractor-container {
1013
+ display: flex;
1014
+ flex-direction: column;
1015
+ gap: 16px;
1016
+ }
1017
+
1018
+ .extractor-tabs {
1019
+ display: flex;
1020
+ border-bottom: 1px solid var(--border-color);
1021
+ margin-bottom: 6px;
1022
+ }
1023
+
1024
+ .extractor-tab {
1025
+ flex: 1;
1026
+ text-align: center;
1027
+ font-size: 14px;
1028
+ font-weight: 600;
1029
+ padding: 10px 0;
1030
+ cursor: pointer;
1031
+ color: var(--text-secondary);
1032
+ border-bottom: 2px solid transparent;
1033
+ transition: all 0.2s ease;
1034
+ }
1035
+
1036
+ .extractor-tab.active {
1037
+ color: #2563eb;
1038
+ border-bottom-color: #2563eb;
1039
+ }
1040
+
1041
+ .extractor-content {
1042
+ display: flex;
1043
+ flex-direction: column;
1044
+ gap: 14px;
1045
+ }
1046
+
1047
+ .extractor-row {
1048
+ display: flex;
1049
+ flex-direction: column;
1050
+ gap: 8px;
1051
+ border-bottom: 1px dashed var(--border-color);
1052
+ padding-bottom: 14px;
1053
+ }
1054
+
1055
+ .extractor-row:last-child {
1056
+ border-bottom: none;
1057
+ padding-bottom: 0;
1058
+ }
1059
+
1060
+ .extractor-row-label {
1061
+ font-size: 13px;
1062
+ font-weight: 700;
1063
+ color: var(--text-primary);
1064
+ }
1065
+
1066
+ .extractor-row-body {
1067
+ display: flex;
1068
+ align-items: center;
1069
+ justify-content: space-between;
1070
+ gap: 12px;
1071
+ }
1072
+
1073
+ .extractor-val-box,
1074
+ .extractor-color-box {
1075
+ border: 1px solid var(--border-color);
1076
+ border-radius: 4px;
1077
+ height: 32px;
1078
+ flex: 1.2;
1079
+ display: flex;
1080
+ align-items: center;
1081
+ padding: 0 10px;
1082
+ font-size: 12px;
1083
+ color: var(--text-primary);
1084
+ background: #ffffff;
1085
+ }
1086
+
1087
+ .extractor-color-box {
1088
+ gap: 8px;
1089
+ }
1090
+
1091
+ .color-preview-bar {
1092
+ width: 48px;
1093
+ height: 16px;
1094
+ border-radius: 2px;
1095
+ border: 1px solid rgba(0, 0, 0, 0.08);
1096
+ }
1097
+
1098
+ .extractor-palette-grid {
1099
+ display: flex;
1100
+ flex-wrap: wrap;
1101
+ gap: 8px;
1102
+ margin-top: 4px;
1103
+ }
1104
+
1105
+ .extractor-palette-block {
1106
+ width: 28px;
1107
+ height: 28px;
1108
+ border-radius: 4px;
1109
+ cursor: pointer;
1110
+ border: 1px solid rgba(0, 0, 0, 0.08);
1111
+ position: relative;
1112
+ transition: all 0.2s ease;
1113
+ }
1114
+
1115
+ .extractor-palette-block:hover {
1116
+ transform: scale(1.1);
1117
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
1118
+ }
1119
+
1120
+ .extractor-palette-block.excluded {
1121
+ opacity: 0.25;
1122
+ }
1123
+
1124
+ .block-slash {
1125
+ position: absolute;
1126
+ top: 50%;
1127
+ left: 0;
1128
+ width: 100%;
1129
+ height: 1.5px;
1130
+ background: #ef4444;
1131
+ transform: translateY(-50%) rotate(45deg);
1132
+ }
1133
+
1134
+ .extractor-save-btn {
1135
+ background: #2563eb;
1136
+ border: none;
1137
+ border-radius: 4px;
1138
+ color: #ffffff;
1139
+ font-size: 13px;
1140
+ font-weight: 600;
1141
+ padding: 10px 0;
1142
+ cursor: pointer;
1143
+ width: 100%;
1144
+ display: flex;
1145
+ align-items: center;
1146
+ justify-content: center;
1147
+ gap: 6px;
1148
+ transition: all 0.2s ease;
1149
+ }
1150
+
1151
+ .extractor-save-btn:hover {
1152
+ background: #1d4ed8;
1153
+ box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25);
1154
+ }
1155
+
1156
+ :deep(.theme-extractor-dialog) {
1157
+ border-radius: 8px;
1158
+ overflow: hidden;
1159
+ }
1160
+
1161
+ :deep(.theme-extractor-dialog .el-dialog__header) {
1162
+ display: none !important;
1163
+ }
1164
+
1165
+ :deep(.theme-extractor-dialog .el-dialog__body) {
1166
+ padding: 20px 24px 24px 24px !important;
1167
+ background: #ffffff;
1168
+ }
1169
+
1170
+ :deep(.extractor-checkbox) {
1171
+ flex: 0.8;
1172
+ font-size: 12px;
1173
+ }
1174
+
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;
1182
+ display: flex;
1183
+ 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
+ }
1188
+
1189
+ .recipe-theme-card:hover {
1190
+ border-color: #a5b4fc;
1191
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1);
1192
+ }
1193
+
1194
+ .recipe-theme-card.active-slide-theme {
1195
+ border-color: #6366f1;
1196
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.25);
1197
+ }
1198
+
1199
+ .recipe-card-header {
1200
+ display: flex;
1201
+ align-items: center;
1202
+ justify-content: space-between;
1203
+ gap: 8px;
1204
+ }
1205
+
1206
+ .recipe-theme-name {
1207
+ font-size: 13px;
1208
+ font-weight: 700;
1209
+ line-height: 1.2;
1210
+ white-space: nowrap;
1211
+ overflow: hidden;
1212
+ text-overflow: ellipsis;
1213
+ }
1214
+
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;
1223
+ }
1224
+
1225
+ .recipe-usage-badge {
1226
+ font-size: 10px;
1227
+ padding: 2px 7px;
1228
+ border-radius: 10px;
1229
+ font-weight: 500;
1230
+ flex-shrink: 0;
1231
+ white-space: nowrap;
1232
+ }
1233
+
1234
+ .recipe-usage-badge.all {
1235
+ background: #ecfdf5;
1236
+ color: #059669;
1237
+ border: 1px solid #a7f3d0;
1238
+ }
1239
+
1240
+ .recipe-usage-badge.partial {
1241
+ background: #eff6ff;
1242
+ color: #2563eb;
1243
+ border: 1px solid #bfdbfe;
1244
+ }
1245
+
1246
+ .recipe-usage-badge.none {
1247
+ background: #f8fafc;
1248
+ color: #94a3b8;
1249
+ border: 1px solid #e2e8f0;
1250
+ }
1251
+
1252
+ .recipe-card-preview {
1253
+ cursor: pointer;
1254
+ padding: 8px 10px;
1255
+ background: rgba(0, 0, 0, 0.02);
1256
+ border-radius: 6px;
1257
+ display: flex;
1258
+ flex-direction: column;
1259
+ gap: 8px;
1260
+ transition: background 0.15s ease;
1261
+ }
1262
+
1263
+ .recipe-card-preview:hover {
1264
+ background: rgba(99, 102, 241, 0.05);
1265
+ }
1266
+
1267
+ .recipe-font-preview {
1268
+ display: flex;
1269
+ align-items: baseline;
1270
+ }
1271
+
1272
+ .recipe-palette-strip {
1273
+ display: flex;
1274
+ height: 10px;
1275
+ border-radius: 3px;
1276
+ overflow: hidden;
1277
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
1278
+ }
1279
+
1280
+ .palette-strip-block {
1281
+ flex: 1;
1282
+ height: 100%;
1283
+ }
1284
+
1285
+ .recipe-card-actions {
1286
+ display: flex;
1287
+ gap: 6px;
1288
+ align-items: center;
1289
+ }
1290
+
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;
1299
+ font-weight: 500;
1300
+ cursor: pointer;
1301
+ white-space: nowrap;
1302
+ transition: all 0.15s ease;
1303
+ display: flex;
1304
+ align-items: center;
1305
+ justify-content: center;
1306
+ }
1307
+
1308
+ .recipe-act-btn:hover {
1309
+ background: #f1f5f9;
1310
+ border-color: #cbd5e1;
1311
+ color: #0f172a;
1312
+ }
1313
+
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;
1320
+ }
1321
+
1322
+ .recipe-act-btn.btn-edit:hover {
1323
+ background: #e0e7ff;
1324
+ border-color: #a5b4fc;
1325
+ }
1326
+
1327
+ :deep(.color-span-box) {
1328
+ width: 100%;
1329
+ }
1330
+
1331
+ .shape-num-field {
1332
+ -moz-appearance: textfield;
1333
+ appearance: textfield;
1334
+
1335
+ &::-webkit-outer-spin-button,
1336
+ &::-webkit-inner-spin-button {
1337
+ -webkit-appearance: none;
1338
+ margin: 0;
1339
+ }
1340
+ }
1341
+ </style>