1
0
forked from zguiy/utils
Files
utils/src/components/tools/ImageToIco.vue
2025-06-28 22:38:49 +08:00

838 lines
26 KiB
Vue
Raw 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.

<template>
<div class="space-y-6">
<!-- 工具栏 -->
<div class="card p-4">
<div class="flex flex-wrap gap-2">
<button
@click="convertToIco"
:disabled="!selectedImage"
class="btn-primary"
>
<FontAwesomeIcon :icon="['fas', 'magic']" class="mr-2" />
转换为ICO
</button>
<button
@click="downloadIco"
:disabled="!icoData"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'download']" class="mr-2" />
下载ICO
</button>
<button
@click="clearAll"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
清除
</button>
<button
@click="loadSample"
class="btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'image']" class="mr-2" />
示例图片
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 图片上传区域 -->
<div class="space-y-4">
<!-- 上传区域 -->
<div class="card p-6">
<h3 class="text-lg font-semibold text-primary mb-4">选择图片</h3>
<!-- 拖拽上传区域 -->
<div
@drop="handleDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@click="selectFile"
:class="[
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
isDragOver
? 'border-primary bg-primary bg-opacity-10'
: 'border-border hover:border-primary hover:bg-block-hover'
]"
>
<input
ref="fileInput"
type="file"
accept="image/*"
@change="handleFileSelect"
class="hidden"
>
<div class="space-y-3">
<FontAwesomeIcon
:icon="['fas', 'cloud-upload-alt']"
class="text-4xl text-secondary"
/>
<div>
<div class="text-primary font-medium">
点击选择或拖拽图片到此处
</div>
<div class="text-sm text-secondary mt-1">
支持 JPGPNGGIFBMPWebP 格式
</div>
</div>
</div>
</div>
<!-- 支持的格式说明 -->
<div class="mt-4 text-sm text-secondary">
<div class="font-medium mb-2">支持的输入格式:</div>
<div class="grid grid-cols-2 gap-2">
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>JPEG (.jpg, .jpeg)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>PNG (.png)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>GIF (.gif)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>BMP (.bmp)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>WebP (.webp)</span>
</div>
<div class="flex items-center space-x-2">
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success text-xs" />
<span>SVG (.svg)</span>
</div>
</div>
</div>
</div>
<!-- 图片信息 -->
<div v-if="selectedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">图片信息</h3>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">文件名:</span>
<span class="text-primary font-medium">{{ imageInfo.name }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">格式:</span>
<span class="text-primary font-medium">{{ imageInfo.type }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">大小:</span>
<span class="text-primary font-medium">{{ formatFileSize(imageInfo.size) }}</span>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-secondary">宽度:</span>
<span class="text-primary font-medium">{{ imageInfo.width }}px</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">高度:</span>
<span class="text-primary font-medium">{{ imageInfo.height }}px</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">宽高比:</span>
<span class="text-primary font-medium">{{ imageInfo.aspectRatio }}</span>
</div>
</div>
</div>
</div>
<!-- 转换设置 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换设置</h3>
<div class="space-y-4">
<!-- ICO尺寸设置 -->
<div>
<label class="block text-sm text-secondary mb-2">ICO尺寸 (像素)</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="size in icoSizes"
:key="size"
@click="selectIcoSize(size)"
:class="[
'p-2 text-sm rounded border transition-colors',
selectedSizes.includes(size)
? 'border-primary bg-primary text-white'
: 'border-border hover:border-primary text-secondary'
]"
>
{{ size }}×{{ size }}
</button>
</div>
<div class="text-xs text-tertiary mt-1">
可选择多个尺寸生成多尺寸ICO文件
</div>
</div>
<!-- 自定义尺寸 -->
<div>
<label class="block text-sm text-secondary mb-2">自定义尺寸</label>
<div class="flex items-center space-x-2">
<input
v-model.number="customSize"
type="number"
min="16"
max="256"
class="input-field w-20 text-sm"
placeholder="32"
>
<span class="text-secondary text-sm">像素</span>
<button
@click="addCustomSize"
:disabled="!isValidCustomSize"
class="btn-sm btn-secondary"
>
<FontAwesomeIcon :icon="['fas', 'plus']" />
</button>
</div>
</div>
<!-- 图片质量设置 -->
<div>
<label class="block text-sm text-secondary mb-2">
图片质量: {{ quality }}%
</label>
<input
v-model.number="quality"
type="range"
min="10"
max="100"
step="5"
class="w-full"
>
<div class="flex justify-between text-xs text-tertiary mt-1">
<span>较小文件</span>
<span>较高质量</span>
</div>
</div>
<!-- 背景颜色设置 -->
<div>
<label class="block text-sm text-secondary mb-2">背景颜色 (透明图片)</label>
<div class="flex items-center space-x-2">
<input
v-model="backgroundColor"
type="color"
class="w-12 h-8 border border-border rounded cursor-pointer"
>
<span class="text-sm text-secondary">{{ backgroundColor }}</span>
<label class="flex items-center space-x-2">
<input
v-model="preserveTransparency"
type="checkbox"
class="form-checkbox"
>
<span class="text-sm text-secondary">保持透明度</span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 预览和结果区域 -->
<div class="space-y-4">
<!-- 原图预览 -->
<div v-if="selectedImage" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">原图预览</h3>
<div class="flex justify-center">
<div class="max-w-full max-h-64 overflow-hidden border border-border rounded-lg">
<img
:src="imagePreview"
:alt="imageInfo.name"
class="max-w-full max-h-64 object-contain"
>
</div>
</div>
</div>
<!-- ICO预览 -->
<div v-if="icoPreview" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">ICO预览</h3>
<div class="space-y-4">
<!-- 多尺寸预览 -->
<div class="grid grid-cols-4 gap-4">
<div
v-for="size in selectedSizes"
:key="size"
class="text-center"
>
<div class="border border-border rounded p-2 bg-checkerboard">
<img
:src="icoPreview"
:alt="`ICO ${size}x${size}`"
:style="{ width: size + 'px', height: size + 'px' }"
class="mx-auto object-contain"
>
</div>
<div class="text-xs text-secondary mt-1">{{ size }}×{{ size }}</div>
</div>
</div>
<!-- ICO文件信息 -->
<div v-if="icoInfo" class="bg-block rounded-lg p-3 text-sm">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-secondary">文件大小:</span>
<span class="text-primary font-medium">{{ formatFileSize(icoInfo.size) }}</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">包含尺寸:</span>
<span class="text-primary font-medium">{{ icoInfo.iconCount }}</span>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-secondary">格式:</span>
<span class="text-primary font-medium">ICO</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">颜色深度:</span>
<span class="text-primary font-medium">32</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 转换历史 -->
<div v-if="conversionHistory.length > 0" class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">转换历史</h3>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div
v-for="(record, index) in conversionHistory"
:key="index"
class="flex items-center justify-between p-2 bg-block rounded text-sm"
>
<div class="flex-1">
<div class="font-medium text-primary">{{ record.filename }}</div>
<div class="text-xs text-tertiary">
{{ record.sizes.join(', ') }} | {{ record.time }}
</div>
</div>
<button
@click="downloadHistoryFile(record)"
class="text-secondary hover:text-primary transition-colors"
title="重新下载"
>
<FontAwesomeIcon :icon="['fas', 'download']" />
</button>
</div>
</div>
</div>
<!-- 使用说明 -->
<div class="card p-4">
<h3 class="text-lg font-semibold text-primary mb-3">使用说明</h3>
<div class="space-y-3 text-sm text-secondary">
<div>
<div class="font-medium">ICO格式特点:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>Windows图标标准格式</li>
<li>支持多尺寸存储在一个文件中</li>
<li>常用尺寸: 16×16, 32×32, 48×48, 256×256</li>
<li>支持透明背景</li>
</ul>
</div>
<div>
<div class="font-medium">转换建议:</div>
<ul class="list-disc list-inside text-tertiary ml-2 mt-1">
<li>使用正方形图片效果最佳</li>
<li>PNG格式可保持透明度</li>
<li>选择多个尺寸以适应不同显示场景</li>
<li>16×16和32×32是Windows系统最常用尺寸</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 状态消息 -->
<div v-if="statusMessage" class="card p-4">
<div :class="[
'flex items-center space-x-2',
statusType === 'success' ? 'text-success' : 'text-error'
]">
<FontAwesomeIcon
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
/>
<span>{{ statusMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { useLanguage } from '@/composables/useLanguage'
const { t } = useLanguage()
// 响应式状态
const selectedImage = ref<File | null>(null)
const imagePreview = ref('')
const icoData = ref<Blob | null>(null)
const icoPreview = ref('')
const isDragOver = ref(false)
const quality = ref(90)
const backgroundColor = ref('#ffffff')
const preserveTransparency = ref(true)
const customSize = ref<number | null>(null)
const statusMessage = ref('')
const statusType = ref<'success' | 'error'>('success')
// DOM引用
const fileInput = ref<HTMLInputElement>()
// 图片信息
const imageInfo = reactive({
name: '',
type: '',
size: 0,
width: 0,
height: 0,
aspectRatio: ''
})
// ICO信息
const icoInfo = reactive({
size: 0,
iconCount: 0
})
// ICO尺寸选项
const icoSizes = [16, 24, 32, 48, 64, 96, 128, 256]
const selectedSizes = ref<number[]>([16, 32, 48])
// 转换历史
const conversionHistory = ref<Array<{
filename: string
sizes: string[]
time: string
data: Blob
}>>([])
// 计算属性
const isValidCustomSize = computed(() => {
return customSize.value &&
customSize.value >= 16 &&
customSize.value <= 256 &&
!selectedSizes.value.includes(customSize.value)
})
// 文件选择
const selectFile = () => {
fileInput.value?.click()
}
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
handleImageFile(file)
}
}
// 拖拽处理
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = true
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
const files = event.dataTransfer?.files
if (files && files.length > 0) {
handleImageFile(files[0])
}
}
// 处理图片文件
const handleImageFile = async (file: File) => {
// 验证文件类型
if (!file.type.startsWith('image/')) {
showStatus('请选择有效的图片文件', 'error')
return
}
selectedImage.value = file
// 更新图片信息
imageInfo.name = file.name
imageInfo.type = file.type
imageInfo.size = file.size
// 创建预览
const reader = new FileReader()
reader.onload = (e) => {
imagePreview.value = e.target?.result as string
}
reader.readAsDataURL(file)
// 获取图片尺寸
const img = new Image()
img.onload = () => {
imageInfo.width = img.width
imageInfo.height = img.height
imageInfo.aspectRatio = `${(img.width / img.height).toFixed(2)}:1`
// 如果图片不是正方形,给出提示
if (img.width !== img.height) {
showStatus('建议使用正方形图片以获得最佳ICO效果', 'error')
}
}
img.src = URL.createObjectURL(file)
// 清除之前的ICO数据
icoData.value = null
icoPreview.value = ''
}
// ICO尺寸选择
const selectIcoSize = (size: number) => {
const index = selectedSizes.value.indexOf(size)
if (index >= 0) {
selectedSizes.value.splice(index, 1)
} else {
selectedSizes.value.push(size)
}
selectedSizes.value.sort((a, b) => a - b)
}
// 添加自定义尺寸
const addCustomSize = () => {
if (customSize.value && isValidCustomSize.value) {
selectedSizes.value.push(customSize.value)
selectedSizes.value.sort((a, b) => a - b)
customSize.value = null
}
}
// 转换为ICO
const convertToIco = async () => {
if (!selectedImage.value) {
showStatus('请先选择图片', 'error')
return
}
if (selectedSizes.value.length === 0) {
showStatus('请至少选择一个ICO尺寸', 'error')
return
}
try {
showStatus('正在转换中...', 'success')
// 创建canvas来处理图片
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建canvas上下文')
}
// 加载原图
const img = new Image()
img.src = imagePreview.value
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
// 生成多尺寸图标数据
const iconData: Array<{ size: number; data: Uint8Array }> = []
for (const size of selectedSizes.value) {
canvas.width = size
canvas.height = size
// 设置背景颜色(如果不保持透明度)
if (!preserveTransparency.value) {
ctx.fillStyle = backgroundColor.value
ctx.fillRect(0, 0, size, size)
}
// 绘制图片
ctx.drawImage(img, 0, 0, size, size)
// 获取图片数据
const imageData = ctx.getImageData(0, 0, size, size)
iconData.push({
size,
data: new Uint8Array(imageData.data)
})
}
// 生成ICO文件数据简化实现
const icoBlob = await createIcoBlob(iconData)
icoData.value = icoBlob
// 创建预览
icoPreview.value = URL.createObjectURL(icoBlob)
// 更新ICO信息
icoInfo.size = icoBlob.size
icoInfo.iconCount = selectedSizes.value.length
// 添加到历史记录
conversionHistory.value.unshift({
filename: selectedImage.value.name.replace(/\.[^/.]+$/, '.ico'),
sizes: selectedSizes.value.map(s => `${s}×${s}`),
time: new Date().toLocaleTimeString(),
data: icoBlob
})
// 保持历史记录不超过10条
if (conversionHistory.value.length > 10) {
conversionHistory.value = conversionHistory.value.slice(0, 10)
}
showStatus('转换成功!', 'success')
} catch (error) {
showStatus('转换失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error')
icoData.value = null
icoPreview.value = ''
}
}
// 创建ICO文件数据 (简化实现)
const createIcoBlob = async (iconData: Array<{ size: number; data: Uint8Array }>): Promise<Blob> => {
// 这是一个简化的ICO文件格式实现
// 实际应用中建议使用专门的ICO库
const iconCount = iconData.length
const headerSize = 6 + iconCount * 16 // ICO文件头 + 图标目录项
// 计算每个图标的PNG数据
const pngData: Uint8Array[] = []
for (const icon of iconData) {
// 将RGBA数据转换为PNG (这里简化处理实际需要PNG编码)
const canvas = document.createElement('canvas')
canvas.width = icon.size
canvas.height = icon.size
const ctx = canvas.getContext('2d')!
const imageData = new ImageData(
new Uint8ClampedArray(icon.data),
icon.size,
icon.size
)
ctx.putImageData(imageData, 0, 0)
// 获取PNG数据
const dataUrl = canvas.toDataURL('image/png', quality.value / 100)
const base64 = dataUrl.split(',')[1]
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
pngData.push(bytes)
}
// 计算总文件大小
let totalSize = headerSize
for (const png of pngData) {
totalSize += png.length
}
// 构建ICO文件
const icoFile = new Uint8Array(totalSize)
let offset = 0
// ICO文件头
icoFile[0] = 0 // 保留字段
icoFile[1] = 0
icoFile[2] = 1 // 类型: ICO
icoFile[3] = 0
icoFile[4] = iconCount & 0xFF // 图标数量
icoFile[5] = (iconCount >> 8) & 0xFF
offset = 6
// 图标目录项
let dataOffset = headerSize
for (let i = 0; i < iconCount; i++) {
const size = iconData[i].size
const pngSize = pngData[i].length
icoFile[offset] = size === 256 ? 0 : size // 宽度
icoFile[offset + 1] = size === 256 ? 0 : size // 高度
icoFile[offset + 2] = 0 // 颜色数
icoFile[offset + 3] = 0 // 保留
icoFile[offset + 4] = 1 // 颜色平面数
icoFile[offset + 5] = 0
icoFile[offset + 6] = 32 // 位深度
icoFile[offset + 7] = 0
// PNG数据大小
icoFile[offset + 8] = pngSize & 0xFF
icoFile[offset + 9] = (pngSize >> 8) & 0xFF
icoFile[offset + 10] = (pngSize >> 16) & 0xFF
icoFile[offset + 11] = (pngSize >> 24) & 0xFF
// PNG数据偏移
icoFile[offset + 12] = dataOffset & 0xFF
icoFile[offset + 13] = (dataOffset >> 8) & 0xFF
icoFile[offset + 14] = (dataOffset >> 16) & 0xFF
icoFile[offset + 15] = (dataOffset >> 24) & 0xFF
offset += 16
dataOffset += pngSize
}
// 写入PNG数据
for (const png of pngData) {
icoFile.set(png, offset)
offset += png.length
}
return new Blob([icoFile], { type: 'image/x-icon' })
}
// 下载ICO
const downloadIco = () => {
if (!icoData.value || !selectedImage.value) return
const filename = selectedImage.value.name.replace(/\.[^/.]+$/, '.ico')
const url = URL.createObjectURL(icoData.value)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
showStatus('ICO文件下载完成', 'success')
}
// 从历史记录下载
const downloadHistoryFile = (record: any) => {
const url = URL.createObjectURL(record.data)
const link = document.createElement('a')
link.href = url
link.download = record.filename
link.click()
URL.revokeObjectURL(url)
}
// 加载示例图片
const loadSample = () => {
// 创建一个简单的示例图片
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const ctx = canvas.getContext('2d')!
// 绘制渐变背景
const gradient = ctx.createLinearGradient(0, 0, 128, 128)
gradient.addColorStop(0, '#3b82f6')
gradient.addColorStop(1, '#1d4ed8')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 128, 128)
// 绘制图标形状
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 60px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('ICO', 64, 64)
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], 'sample.png', { type: 'image/png' })
handleImageFile(file)
}
})
}
// 清除所有
const clearAll = () => {
selectedImage.value = null
imagePreview.value = ''
icoData.value = null
icoPreview.value = ''
selectedSizes.value = [16, 32, 48]
customSize.value = null
statusMessage.value = ''
// 重置图片信息
Object.assign(imageInfo, {
name: '',
type: '',
size: 0,
width: 0,
height: 0,
aspectRatio: ''
})
// 重置ICO信息
Object.assign(icoInfo, {
size: 0,
iconCount: 0
})
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 显示状态消息
const showStatus = (message: string, type: 'success' | 'error') => {
statusMessage.value = message
statusType.value = type
setTimeout(() => {
statusMessage.value = ''
}, 3000)
}
</script>
<style scoped>
.bg-checkerboard {
background-image:
linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
}
</style>