Files
sutong b7addb5c7a chore: 初始化项目仓库
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:27:39 +08:00

808 lines
37 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { siteConfig } from '~/config/site'
useHead({ title: `封面制作 - ${siteConfig.siteName}` })
// 文字
const leftText = ref('标题')
const rightText = ref('副标题')
const fontSize = ref(80)
const fontWeight = ref(700)
const gap = ref(20)
// 图标
const showIcon = ref(true)
const iconName = ref('mdi:star')
const iconSvg = ref('')
const localIcon = ref<string | null>(null)
const iconSize = ref(64)
const iconColor = ref('#000000')
const useOriginalIconColor = ref(false)
const iconRadius = ref(0)
const iconPosition = ref<'before' | 'middle' | 'after'>('before')
const searchQuery = ref('')
const searchResults = ref<string[]>([])
let searchTimer: ReturnType<typeof setTimeout> | null = null
// 背景
const bgImage = ref<string | null>(null)
const bgColor = ref('#ffffff')
const bgColorOpacity = ref(1)
const bgBlur = ref(0)
const bgOpacity = ref(1)
const isBgDragOver = ref(false)
// 颜色
const textColor = ref('#000000')
const linkColor = ref(true)
// 阴影
const textShadow = reactive({ x: 0, y: 0, blur: 0, color: '#000000', alpha: 0 })
const iconShadow = reactive({ x: 0, y: 0, blur: 0, color: '#000000', alpha: 0 })
// 图标背景
const iconBgEnabled = ref(false)
const iconBgColor = ref('#000000')
const iconBgOpacity = ref(0.2)
const iconBgPadding = ref(10)
const iconBgRadius = ref(20)
const iconBgBlur = ref(0)
// 画布
const canvasWidth = ref(1067)
const canvasHeight = ref(600)
const ratios = reactive([
{ label: '16:9', w: 16, h: 9, checked: true },
{ label: '4:3', w: 4, h: 3, checked: false },
{ label: '1:1', w: 1, h: 1, checked: false },
{ label: '21:9', w: 21, h: 9, checked: false },
])
const linkScale = ref(true)
// 导出
const exportFormat = ref<'png' | 'svg'>('png')
const exportScales = ref([1])
const exportFilename = ref('cover')
const exportTransparentBg = ref(false)
// 背景图拖拽
const bgX = ref(0)
const bgY = ref(0)
const bgScale = ref(1)
const isDragging = ref(false)
let dragStartX = 0
let dragStartY = 0
let dragStartBgX = 0
let dragStartBgY = 0
const svgRef = ref<SVGSVGElement | null>(null)
// 计算
const activeRatios = computed(() => ratios.filter(r => r.checked))
const maxRatio = computed(() => activeRatios.value.reduce((max, r) => Math.max(max, r.w / r.h), 0))
const computedWidth = computed(() => Math.round(canvasHeight.value * maxRatio.value))
// 颜色转换
function hexToRgba(hex: string, alpha: number) {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
// 图标搜索
function onSearchInput(e: Event) {
const val = (e.target as HTMLInputElement).value
searchQuery.value = val
if (searchTimer) clearTimeout(searchTimer)
if (!val.trim()) { searchResults.value = []; return }
searchTimer = setTimeout(async () => {
try {
const res = await fetch(`https://api.iconify.design/search?query=${encodeURIComponent(val)}&limit=20`)
const data = await res.json()
searchResults.value = data.icons || []
} catch { searchResults.value = [] }
}, 500)
}
function selectIcon(name: string) {
iconName.value = name
localIcon.value = null
}
// 加载图标 SVG
watch(iconName, async (name) => {
if (!name || !name.includes(':') || localIcon.value) { iconSvg.value = ''; return }
try {
const res = await fetch(`https://api.iconify.design/${name.split(':')[0]}/${name.split(':')[1]}.svg`)
if (!res.ok) throw Error()
let svg = await res.text()
svg = svg
.replace(/\s(width|height)="[^"]*"/g, '')
.replace(/<svg\b([^>]*)>/, '<svg$1 width="100%" height="100%" preserveAspectRatio="xMidYMid meet">')
.replace(/currentColor/gi, iconColor.value)
iconSvg.value = svg
} catch { iconSvg.value = '' }
}, { immediate: true })
// 颜色同步
watch(linkColor, (v) => { if (v) iconColor.value = textColor.value })
watch(textColor, (v) => { if (linkColor.value) iconColor.value = v })
// 本地图标上传
function onLocalIconUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
localIcon.value = ev.target?.result as string
iconName.value = '本地图片'
iconSvg.value = ''
}
reader.readAsDataURL(file)
}
// 背景图上传
function onBgUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file || !file.type.startsWith('image/')) return
const reader = new FileReader()
reader.onload = (ev) => {
bgImage.value = ev.target?.result as string
bgX.value = 0; bgY.value = 0; bgScale.value = 1
}
reader.readAsDataURL(file)
}
function onBgDragOver(e: DragEvent) { e.preventDefault(); isBgDragOver.value = true }
function onBgDragLeave() { isBgDragOver.value = false }
function onBgDrop(e: DragEvent) {
e.preventDefault(); isBgDragOver.value = false
const file = e.dataTransfer?.files?.[0]
if (file) onBgUpload({ target: { files: [file] } } as any)
}
// 画布拖拽
function onPointerDown(e: PointerEvent) {
if (!bgImage.value) return
e.preventDefault()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
isDragging.value = true
dragStartX = e.clientX; dragStartY = e.clientY
dragStartBgX = bgX.value; dragStartBgY = bgY.value
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value) return
bgX.value = dragStartBgX + (e.clientX - dragStartX) / bgScale.value
bgY.value = dragStartBgY + (e.clientY - dragStartY) / bgScale.value
}
function onPointerUp() { isDragging.value = false }
function onWheel(e: WheelEvent) {
if (!bgImage.value) return
e.preventDefault()
const factor = 1.1
bgScale.value = e.deltaY < 0
? Math.min(bgScale.value * factor, 10)
: Math.max(bgScale.value / factor, 0.1)
}
// 比例联动
function updateWidth() {
if (linkScale.value) canvasWidth.value = computedWidth.value
}
watch(() => ratios.map(r => r.checked), updateWidth, { deep: true })
watch(maxRatio, updateWidth)
// 导出
async function onExport() {
const svgEl = svgRef.value
if (!svgEl) return
// 隐藏辅助线
const guides = svgEl.querySelectorAll('.ratio-guide')
guides.forEach(g => (g as SVGElement).style.display = 'none')
const border = svgEl.querySelector('.canvas-border') as SVGElement | null
if (border) border.style.display = 'none'
const clone = svgEl.cloneNode(true) as SVGSVGElement
// 恢复
guides.forEach(g => (g as SVGElement).style.display = '')
if (border) border.style.display = ''
const exportRatios = activeRatios.value.length > 0 ? activeRatios.value : [ratios[0]]
for (const ratio of exportRatios) {
const w = Math.round(canvasHeight.value * (ratio.w / ratio.h))
const offsetX = (computedWidth.value - w) / 2
const c = clone.cloneNode(true) as SVGSVGElement
c.setAttribute('width', w.toString())
c.setAttribute('height', canvasHeight.value.toString())
c.setAttribute('viewBox', `${offsetX} 0 ${w} ${canvasHeight.value}`)
const svgStr = new XMLSerializer().serializeToString(c)
const fname = exportRatios.length > 1
? `${exportFilename.value}-${ratio.label.replace(':', '-')}`
: exportFilename.value
if (exportFormat.value === 'svg') {
downloadBlob(new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' }), `${fname}.svg`)
} else {
const img = new Image()
img.src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgStr)))}`
await new Promise<void>(r => { img.onload = () => r() })
const scales = exportScales.value.length > 0 ? exportScales.value : [1]
for (const s of scales) {
const canvas = document.createElement('canvas')
canvas.width = w * s; canvas.height = canvasHeight.value * s
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const suffix = scales.length > 1 ? `@${s}x` : ''
downloadDataURL(canvas.toDataURL('image/png'), `${fname}${suffix}.png`)
}
}
}
}
function downloadBlob(blob: Blob, name: string) {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = name
document.body.appendChild(a); a.click()
document.body.removeChild(a)
}
function downloadDataURL(url: string, name: string) {
const a = document.createElement('a')
a.href = url; a.download = name
document.body.appendChild(a); a.click()
document.body.removeChild(a)
}
// 移动端 tab
const activeTab = ref<'content' | 'style' | 'export'>('content')
</script>
<template>
<div class="container mx-auto max-w-[1920px] px-4 py-8">
<div class="mb-6">
<h1 class="text-3xl font-bold mb-2">封面制作</h1>
<p class="text-muted-foreground">在线生成精美的封面图片</p>
</div>
<div class="flex flex-col lg:flex-row gap-6 w-full">
<!-- 画布 -->
<div class="flex-1 lg:max-w-[55%]">
<div class="lg:sticky lg:top-20">
<div
class="w-full overflow-hidden flex justify-center rounded-xl select-none touch-none"
style="background-color: var(--color-bg-card); padding: 1rem;"
role="button" tabindex="0"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@pointerleave="onPointerUp"
@wheel="onWheel"
>
<svg
ref="svgRef"
xmlns="http://www.w3.org/2000/svg"
:width="computedWidth"
:height="canvasHeight"
:viewBox="`0 0 ${computedWidth} ${canvasHeight}`"
style="max-width: 100%; height: auto;"
:style="{ cursor: bgImage ? (isDragging ? 'grabbing' : 'grab') : 'default' }"
>
<!-- 棋盘格 -->
<defs>
<pattern id="checkerboard" width="20" height="20" patternUnits="userSpaceOnUse">
<rect width="10" height="10" fill="#e0e0e0"/>
<rect x="10" y="0" width="10" height="10" fill="#ffffff"/>
<rect x="0" y="10" width="10" height="10" fill="#ffffff"/>
<rect x="10" y="10" width="10" height="10" fill="#e0e0e0"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#checkerboard)"/>
<!-- 背景色 -->
<rect
width="100%" height="100%"
:fill="hexToRgba(bgColor, bgColorOpacity)"
/>
<!-- 背景图 -->
<image
v-if="bgImage"
:href="bgImage"
:x="bgX" :y="bgY"
:width="computedWidth" :height="canvasHeight"
preserveAspectRatio="xMidYMid meet"
:transform="`scale(${bgScale})`"
:style="`transform-origin: 50% 50%; filter: blur(${bgBlur}px); opacity: ${bgOpacity};`"
/>
<!-- 内容区 -->
<foreignObject x="0" y="0" width="100%" height="100%" style="pointer-events: none;">
<div
xmlns="http://www.w3.org/1999/xhtml"
:style="`
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
gap: ${gap}px;
font-family: sans-serif;
font-weight: ${fontWeight};
`"
>
<!-- 图标 -->
<div
v-if="showIcon && (iconSvg || localIcon)"
:style="`
order: ${iconPosition === 'before' ? 0 : iconPosition === 'middle' ? 1 : 2};
width: ${iconSize + iconBgPadding * 2}px;
height: ${iconSize + iconBgPadding * 2}px;
display: flex; align-items: center; justify-content: center;
background-color: ${iconBgEnabled ? hexToRgba(iconBgColor, iconBgOpacity) : 'transparent'};
backdrop-filter: ${iconBgEnabled && iconBgBlur > 0 ? `blur(${iconBgBlur}px)` : 'none'};
border-radius: ${iconBgEnabled ? iconBgRadius + '%' : '0'};
`"
>
<img
v-if="localIcon"
:src="localIcon"
alt="Icon"
:style="`
max-width: ${iconSize}px; max-height: ${iconSize}px;
flex-shrink: 0; border-radius: ${iconRadius}%;
overflow: hidden;
filter: drop-shadow(${iconShadow.x}px ${iconShadow.y}px ${iconShadow.blur}px ${hexToRgba(iconShadow.color, iconShadow.alpha)});
`"
/>
<div
v-else
v-html="iconSvg"
:style="`
max-width: ${iconSize}px; max-height: ${iconSize}px;
flex-shrink: 0;
color: ${useOriginalIconColor ? 'inherit' : iconColor};
filter: drop-shadow(${iconShadow.x}px ${iconShadow.y}px ${iconShadow.blur}px ${hexToRgba(iconShadow.color, iconShadow.alpha)});
display: flex; align-items: center; justify-content: center;
border-radius: ${iconRadius}%; overflow: hidden;
`"
/>
</div>
<!-- 左侧文字 -->
<span
:style="`
order: ${iconPosition === 'before' ? 1 : 0};
font-size: ${fontSize}px; color: ${textColor};
text-shadow: ${textShadow.x}px ${textShadow.y}px ${textShadow.blur}px ${hexToRgba(textShadow.color, textShadow.alpha)};
line-height: 1; white-space: nowrap;
`"
>{{ leftText }}</span>
<!-- 右侧文字 -->
<span
:style="`
order: ${iconPosition === 'after' ? 1 : 2};
font-size: ${fontSize}px; color: ${textColor};
text-shadow: ${textShadow.x}px ${textShadow.y}px ${textShadow.blur}px ${hexToRgba(textShadow.color, textShadow.alpha)};
line-height: 1; white-space: nowrap;
`"
>{{ rightText }}</span>
</div>
</foreignObject>
<!-- 比例参考线 -->
<g
v-for="r in activeRatios" :key="r.label"
class="ratio-guide"
>
<rect
v-if="canvasHeight * (r.w / r.h) < computedWidth"
:x="(computedWidth - canvasHeight * (r.w / r.h)) / 2"
y="0"
:width="canvasHeight * (r.w / r.h)"
:height="canvasHeight"
fill="none" stroke="rgba(255,0,0,0.5)" stroke-width="2" stroke-dasharray="10 5"
/>
<text
v-if="canvasHeight * (r.w / r.h) < computedWidth"
:x="(computedWidth - canvasHeight * (r.w / r.h)) / 2 + 10"
y="30"
fill="rgba(255,0,0,0.5)" font-size="20"
>{{ r.label }}</text>
</g>
<!-- 画布边框 -->
<rect x="0" y="0" width="100%" height="100%" fill="none" stroke="rgba(255,0,0,0.8)" stroke-width="2" class="canvas-border"/>
</svg>
</div>
</div>
</div>
<!-- 控制面板 -->
<div class="w-full lg:flex-1">
<!-- 移动端 tab -->
<div class="lg:hidden mb-4">
<div class="flex gap-1 border rounded-lg p-1">
<button
v-for="tab in (['content', 'style', 'export'] as const)"
:key="tab"
class="flex-1 px-3 py-1.5 rounded text-sm font-medium transition-colors"
:class="activeTab === tab ? '' : 'text-muted-foreground'"
:style="activeTab === tab ? 'background: var(--color-primary); color: white;' : ''"
@click="activeTab = tab"
>
{{ { content: '内容', style: '样式', export: '导出' }[tab] }}
</button>
</div>
</div>
<!-- 桌面端三列 -->
<div class="hidden lg:grid lg:grid-cols-3 gap-6">
<!-- 内容列 -->
<div class="space-y-6">
<h2 class="text-lg font-semibold mb-4">内容</h2>
<!-- 文本设置 -->
<div class="space-y-4">
<h3 class="font-medium">文本设置</h3>
<div>
<label class="text-sm text-muted-foreground">左侧文字</label>
<input v-model="leftText" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" />
</div>
<div>
<label class="text-sm text-muted-foreground">右侧文字</label>
<input v-model="rightText" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" />
</div>
<div>
<label class="text-sm text-muted-foreground">字体粗细: {{ fontWeight }}</label>
<input type="range" v-model.number="fontWeight" min="100" max="900" step="100" class="w-full mt-1" />
</div>
</div>
<!-- 图标设置 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-medium">图标设置</h3>
<label class="flex items-center gap-2 cursor-pointer text-sm">
<input type="checkbox" v-model="showIcon" />
<span>显示图标</span>
</label>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<input type="file" accept="image/*" class="hidden" id="icon-upload" @change="onLocalIconUpload" />
<label for="icon-upload" class="flex items-center justify-center h-10 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary text-sm" style="border-color: var(--color-border);">
{{ localIcon ? '更换图片' : '上传图标' }}
</label>
</div>
<div>
<input
:value="searchQuery"
@input="onSearchInput"
placeholder="搜索图标库..."
class="w-full h-10 rounded-lg border bg-transparent px-3 text-sm"
style="border-color: var(--color-border);"
/>
</div>
</div>
<div v-if="searchResults.length > 0" class="max-h-48 overflow-y-auto border rounded-lg p-2" style="border-color: var(--color-border);">
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fit, minmax(48px, 1fr));">
<button
v-for="icon in searchResults" :key="icon"
class="aspect-square flex items-center justify-center p-1.5 rounded-md border hover:bg-accent transition-colors"
:class="icon === iconName ? 'border-primary' : ''"
:style="{ borderColor: icon === iconName ? 'var(--color-primary)' : 'var(--color-border)' }"
:title="icon"
@click="selectIcon(icon)"
>
<img :src="`https://api.iconify.design/${icon.split(':')[0]}/${icon.split(':')[1]}.svg`" class="w-full h-full" />
</button>
</div>
</div>
<p class="text-xs text-muted-foreground">
当前: {{ iconName }}
<a href="https://icon-sets.iconify.design/" target="_blank" rel="noopener noreferrer" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;" class="hover:opacity-70">浏览图标库</a>
</p>
</div>
<!-- 背景图片 -->
<div class="space-y-4">
<h3 class="font-medium">背景图片</h3>
<div>
<input type="file" accept="image/*" class="hidden" id="bg-upload" @change="onBgUpload" />
<label
for="bg-upload"
class="flex items-center justify-center w-full h-24 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary transition-colors"
:class="isBgDragOver ? 'border-primary' : ''"
:style="{ borderColor: isBgDragOver ? 'var(--color-primary)' : 'var(--color-border)' }"
@dragover="onBgDragOver"
@dragleave="onBgDragLeave"
@drop="onBgDrop"
>
<div class="flex flex-col items-center gap-1 text-muted-foreground">
<span class="text-xs">{{ isBgDragOver ? '松开上传' : bgImage ? '点击或拖拽更换' : '点击或拖拽上传' }}</span>
</div>
</label>
</div>
<div v-if="bgImage" class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">模糊: {{ bgBlur }}px</span>
<button class="text-red-500 text-sm" @click="bgImage = null">删除</button>
</div>
<input type="range" v-model.number="bgBlur" min="0" max="20" class="w-full" />
<span class="text-sm text-muted-foreground">不透明度: {{ Math.round(bgOpacity * 100) }}%</span>
<input type="range" v-model.number="bgOpacity" min="0" max="1" step="0.01" class="w-full" />
<p class="text-xs text-muted-foreground">提示: 拖拽移动位置滚轮缩放大小</p>
</div>
</div>
</div>
<!-- 样式列 -->
<div class="space-y-6">
<h2 class="text-lg font-semibold mb-4">样式</h2>
<!-- 尺寸设置 -->
<div class="space-y-4">
<h3 class="font-medium">尺寸设置</h3>
<div>
<label class="text-sm text-muted-foreground">字体大小: {{ fontSize }}px</label>
<input type="range" v-model.number="fontSize" min="20" max="700" class="w-full mt-1" />
</div>
<div>
<label class="text-sm text-muted-foreground">图标大小: {{ iconSize }}px</label>
<input type="range" v-model.number="iconSize" min="20" max="700" class="w-full mt-1" />
</div>
<div>
<label class="text-sm text-muted-foreground">图标圆角: {{ iconRadius }}%</label>
<input type="range" v-model.number="iconRadius" min="0" max="50" class="w-full mt-1" />
</div>
<div>
<label class="text-sm text-muted-foreground">图标位置</label>
<div class="flex gap-1 border rounded-lg p-1 mt-1" style="border-color: var(--color-border);">
<button
v-for="pos in ([{ v: 'before', l: '前面' }, { v: 'middle', l: '中间' }, { v: 'after', l: '后面' }] as const)"
:key="pos.v"
class="flex-1 px-2 py-1 rounded text-sm font-medium transition-colors"
:style="iconPosition === pos.v ? 'background: var(--color-primary); color: white;' : ''"
@click="iconPosition = pos.v"
>{{ pos.l }}</button>
</div>
</div>
<div>
<label class="text-sm text-muted-foreground">间距: {{ gap }}px</label>
<input type="range" v-model.number="gap" min="0" max="200" class="w-full mt-1" />
</div>
</div>
<!-- 颜色设置 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-medium">颜色设置</h3>
<label class="flex items-center gap-2 cursor-pointer text-sm">
<input type="checkbox" v-model="linkColor" />
<span>颜色同步</span>
</label>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">文字颜色</span>
<div class="flex items-center gap-2">
<input v-model="textColor" class="w-24 h-8 text-xs rounded border bg-transparent px-2" style="border-color: var(--color-border);" />
<input type="color" v-model="textColor" class="w-8 h-8 rounded cursor-pointer" />
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">图标颜色</span>
<div class="flex items-center gap-2">
<input v-model="iconColor" :disabled="useOriginalIconColor" class="w-24 h-8 text-xs rounded border bg-transparent px-2" style="border-color: var(--color-border);" />
<input type="color" v-model="iconColor" :disabled="useOriginalIconColor" class="w-8 h-8 rounded cursor-pointer" />
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">背景颜色</span>
<div class="flex items-center gap-2">
<input v-model="bgColor" class="w-24 h-8 text-xs rounded border bg-transparent px-2" style="border-color: var(--color-border);" />
<input type="color" v-model="bgColor" class="w-8 h-8 rounded cursor-pointer" />
</div>
</div>
<div>
<label class="text-sm text-muted-foreground">背景不透明度: {{ Math.round(bgColorOpacity * 100) }}%</label>
<input type="range" v-model.number="bgColorOpacity" min="0" max="1" step="0.01" class="w-full mt-1" />
</div>
</div>
<!-- 图标背景 -->
<div class="space-y-4 pt-4" style="border-top: 1px solid var(--color-border);">
<h3 class="font-medium">图标背景</h3>
<label class="flex items-center justify-between cursor-pointer text-sm">
<span>启用图标背景</span>
<input type="checkbox" v-model="iconBgEnabled" />
</label>
<div v-if="iconBgEnabled" class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">背景颜色</span>
<input type="color" v-model="iconBgColor" class="w-6 h-6 rounded cursor-pointer" />
</div>
<div>
<label class="text-sm text-muted-foreground">不透明度: {{ Math.round(iconBgOpacity * 100) }}%</label>
<input type="range" v-model.number="iconBgOpacity" min="0" max="1" step="0.01" class="w-full mt-1" />
</div>
<div>
<label class="text-sm text-muted-foreground">内边距: {{ iconBgPadding }}px</label>
<input type="range" v-model.number="iconBgPadding" min="0" max="100" class="w-full mt-1" />
</div>
<div>
<label class="text-sm text-muted-foreground">圆角: {{ iconBgRadius }}%</label>
<input type="range" v-model.number="iconBgRadius" min="0" max="50" class="w-full mt-1" />
</div>
</div>
</div>
<!-- 阴影设置 -->
<div class="space-y-4">
<h3 class="font-medium">阴影设置</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">颜色</span>
<input type="color" v-model="textShadow.color" class="w-8 h-8 rounded cursor-pointer" />
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="text-xs text-muted-foreground">模糊</label>
<input type="number" v-model.number="textShadow.blur" class="w-full h-8 rounded border bg-transparent px-2 text-sm mt-1" style="border-color: var(--color-border);" />
</div>
<div>
<label class="text-xs text-muted-foreground">水平</label>
<input type="number" v-model.number="textShadow.x" class="w-full h-8 rounded border bg-transparent px-2 text-sm mt-1" style="border-color: var(--color-border);" />
</div>
<div>
<label class="text-xs text-muted-foreground">垂直</label>
<input type="number" v-model.number="textShadow.y" class="w-full h-8 rounded border bg-transparent px-2 text-sm mt-1" style="border-color: var(--color-border);" />
</div>
</div>
<div>
<label class="text-sm text-muted-foreground">不透明度: {{ Math.round(textShadow.alpha * 100) }}%</label>
<input type="range" v-model.number="textShadow.alpha" min="0" max="1" step="0.01" class="w-full mt-1" />
</div>
</div>
</div>
</div>
<!-- 导出列 -->
<div class="space-y-6">
<h2 class="text-lg font-semibold mb-4">导出</h2>
<!-- 画板比例 -->
<div class="space-y-2">
<h3 class="font-medium">画板比例</h3>
<div class="grid grid-cols-2 gap-2">
<label
v-for="r in ratios" :key="r.label"
class="flex items-center gap-2 p-2 border rounded-lg cursor-pointer hover:bg-accent"
:style="{ borderColor: 'var(--color-border)' }"
>
<input type="checkbox" v-model="r.checked" />
<span class="font-mono text-sm">{{ r.label }}</span>
</label>
</div>
</div>
<!-- 导出设置 -->
<div class="space-y-4">
<h3 class="font-medium">导出设置</h3>
<div>
<label class="text-sm text-muted-foreground">文件名</label>
<input v-model="exportFilename" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" />
</div>
<div>
<label class="text-sm text-muted-foreground">格式</label>
<div class="flex gap-1 border rounded-lg p-1 mt-1" style="border-color: var(--color-border);">
<button
class="flex-1 px-3 py-1 rounded text-sm font-bold transition-colors"
:style="exportFormat === 'png' ? 'background: var(--color-primary); color: white;' : ''"
@click="exportFormat = 'png'"
>PNG</button>
<button
class="flex-1 px-3 py-1 rounded text-sm font-bold transition-colors"
:style="exportFormat === 'svg' ? 'background: var(--color-primary); color: white;' : ''"
@click="exportFormat = 'svg'"
>SVG</button>
</div>
</div>
<div v-if="exportFormat === 'png'">
<h4 class="text-sm text-muted-foreground mb-2">缩放倍率</h4>
<div class="grid grid-cols-2 gap-2">
<label
v-for="s in [1, 2, 3, 4]" :key="s"
class="flex items-center gap-2 p-2 border rounded-lg cursor-pointer hover:bg-accent"
:style="{ borderColor: 'var(--color-border)' }"
>
<input
type="checkbox"
:checked="exportScales.includes(s)"
@change="exportScales = exportScales.includes(s) ? exportScales.filter(x => x !== s) : [...exportScales, s].sort()"
/>
<span class="font-mono text-sm">{{ s }}x</span>
</label>
</div>
<p class="text-xs text-muted-foreground mt-2">{{ computedWidth }}x{{ canvasHeight }} px</p>
</div>
<button
class="btn-pill btn-primary w-full justify-center"
:disabled="activeRatios.length === 0"
@click="onExport"
>
导出图片
</button>
</div>
</div>
</div>
<!-- 移动端内容 -->
<div class="lg:hidden">
<div v-if="activeTab === 'content'" class="space-y-6">
<!-- 同上"内容列"的全部内容 -->
<div class="space-y-4">
<h3 class="font-medium">文本设置</h3>
<div><label class="text-sm text-muted-foreground">左侧文字</label><input v-model="leftText" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" /></div>
<div><label class="text-sm text-muted-foreground">右侧文字</label><input v-model="rightText" class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm mt-1" style="border-color: var(--color-border);" /></div>
<div><label class="text-sm text-muted-foreground">字体粗细: {{ fontWeight }}</label><input type="range" v-model.number="fontWeight" min="100" max="900" step="100" class="w-full mt-1" /></div>
</div>
<div class="space-y-4">
<h3 class="font-medium">图标设置</h3>
<input type="file" accept="image/*" class="hidden" id="icon-upload-mobile" @change="onLocalIconUpload" />
<label for="icon-upload-mobile" class="flex items-center justify-center h-10 border-2 border-dashed rounded-lg cursor-pointer text-sm" style="border-color: var(--color-border);">{{ localIcon ? '更换图片' : '上传图标' }}</label>
<input :value="searchQuery" @input="onSearchInput" placeholder="搜索图标库..." class="w-full h-9 rounded-lg border bg-transparent px-3 text-sm" style="border-color: var(--color-border);" />
<p class="text-xs text-muted-foreground">
当前: {{ iconName }}
<a href="https://icon-sets.iconify.design/" target="_blank" rel="noopener noreferrer" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;" class="hover:opacity-70">浏览图标库</a>
</p>
</div>
<div class="space-y-4">
<h3 class="font-medium">背景图片</h3>
<input type="file" accept="image/*" class="hidden" id="bg-upload-mobile" @change="onBgUpload" />
<label for="bg-upload-mobile" class="flex items-center justify-center w-full h-24 border-2 border-dashed rounded-lg cursor-pointer text-sm" style="border-color: var(--color-border);">{{ bgImage ? '更换背景' : '上传背景' }}</label>
</div>
</div>
<div v-else-if="activeTab === 'style'" class="space-y-6">
<div class="space-y-4">
<h3 class="font-medium">尺寸设置</h3>
<div><label class="text-sm text-muted-foreground">字体大小: {{ fontSize }}px</label><input type="range" v-model.number="fontSize" min="20" max="700" class="w-full mt-1" /></div>
<div><label class="text-sm text-muted-foreground">图标大小: {{ iconSize }}px</label><input type="range" v-model.number="iconSize" min="20" max="700" class="w-full mt-1" /></div>
<div><label class="text-sm text-muted-foreground">间距: {{ gap }}px</label><input type="range" v-model.number="gap" min="0" max="200" class="w-full mt-1" /></div>
</div>
<div class="space-y-4">
<h3 class="font-medium">颜色设置</h3>
<div class="flex items-center justify-between"><span class="text-sm text-muted-foreground">文字颜色</span><input type="color" v-model="textColor" class="w-8 h-8 rounded cursor-pointer" /></div>
<div class="flex items-center justify-between"><span class="text-sm text-muted-foreground">背景颜色</span><input type="color" v-model="bgColor" class="w-8 h-8 rounded cursor-pointer" /></div>
</div>
</div>
<div v-else class="space-y-6">
<div class="space-y-2">
<h3 class="font-medium">画板比例</h3>
<div class="grid grid-cols-2 gap-2">
<label v-for="r in ratios" :key="r.label" class="flex items-center gap-2 p-2 border rounded-lg cursor-pointer" style="border-color: var(--color-border);">
<input type="checkbox" v-model="r.checked" /><span class="font-mono text-sm">{{ r.label }}</span>
</label>
</div>
</div>
<button class="btn-pill btn-primary w-full justify-center" @click="onExport">导出图片</button>
</div>
</div>
</div>
</div>
</div>
</template>