feat: 图片转 ICO 支持逐尺寸单独下载与多尺寸打包 ZIP

This commit is contained in:
夏犀麟 2026-09-01 10:21:28 +08:00
parent e65da3ac8f
commit 00013ee112
3 changed files with 46 additions and 2 deletions

View File

@ -1,6 +1,8 @@
# tool.xpcool.com 变更记录 # tool.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要 > 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要
2026-09-01 | CHG | 图片转 ICO 结果区增强:逐尺寸单独 ICO 下载buildIco 新增 singles 字段,复用条目数据零额外编码)+ 多尺寸一键打包 ZIP 下载jszip另修复 pnpm wrapper 中 node 版本路径22.22.2 → 22.22.2-2
2026-08-31 | CHG | 新增「图片转 ICO」工具src/utils/ico.ts纯前端自实现 PNG/BMP DIB/ICO 编码 + 中位切分量化 + 自适应滤波 + 体积上限自动降级src/views/image-to-ico/ImageToIcoView.vue尺寸组合/填充/编码方式/量化/体积限制/预览下载/历史),路由注册于 src/router/tools.tsPillow 独立解码验证通过 2026-08-31 | CHG | 新增「图片转 ICO」工具src/utils/ico.ts纯前端自实现 PNG/BMP DIB/ICO 编码 + 中位切分量化 + 自适应滤波 + 体积上限自动降级src/views/image-to-ico/ImageToIcoView.vue尺寸组合/填充/编码方式/量化/体积限制/预览下载/历史),路由注册于 src/router/tools.tsPillow 独立解码验证通过
2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md中央工作空间迁移至 ../workbuddy.xpcool.com相对路径索引 2026-08-26 | CFG | CHANGELOG 纳入 git 管理(.gitignore 放行 .workbuddy/memory/CHANGELOG.md中央工作空间迁移至 ../workbuddy.xpcool.com相对路径索引

View File

@ -55,6 +55,8 @@ export interface IcoBuildResult {
quantized: boolean quantized: boolean
/** 各尺寸的预览图DataURL供页面回显 */ /** 各尺寸的预览图DataURL供页面回显 */
previews: Array<{ size: number; url: string }> previews: Array<{ size: number; url: string }>
/** 每个尺寸单独封装的 ICO 文件(与合成包内条目数据一致,用于逐尺寸下载 / 打包 ZIP */
singles: Array<{ size: number; blob: Blob }>
} }
/** ICO 内部单条目:尺寸 + 编码后的图像数据 */ /** ICO 内部单条目:尺寸 + 编码后的图像数据 */
@ -727,12 +729,15 @@ export async function buildIco(source: HTMLImageElement, params: IcoBuildParams)
const blob = encodeIco(encoded.images) const blob = encodeIco(encoded.images)
const previews = active.map((size) => ({ size, url: imageDataToDataUrl(rendered.get(size) as ImageData) })) const previews = active.map((size) => ({ size, url: imageDataToDataUrl(rendered.get(size) as ImageData) }))
// 单尺寸 ICO 直接复用合成包里已编码的条目数据,只多包一层 6+16 字节的头部,几乎零开销
const singles = encoded.images.map((img) => ({ size: img.size, blob: encodeIco([img]) }))
return { return {
blob, blob,
entries: encoded.meta, entries: encoded.meta,
droppedSizes: sizes.filter((s) => !active.includes(s)), droppedSizes: sizes.filter((s) => !active.includes(s)),
quantized: quantize, quantized: quantize,
previews, previews,
singles,
} }
} }

View File

@ -111,12 +111,16 @@
</div> </div>
<div class="result-actions"> <div class="result-actions">
<t-button theme="primary" @click="downloadIco">下载 ICO</t-button> <t-button theme="primary" @click="downloadIco">下载 ICO</t-button>
<!-- 多个尺寸时支持把每个尺寸的单独 ICO 打包成 ZIP 一键下载 -->
<t-button v-if="result.singles.length > 1" variant="outline" :loading="zipping" @click="downloadAllZip">
打包下载 ZIP
</t-button>
</div> </div>
</div> </div>
<t-divider /> <t-divider />
<p class="grid-title">各尺寸预览点击右下角可单独下载 PNG</p> <p class="grid-title">各尺寸预览点击右下角可单独下载 PNG / ICO</p>
<div class="preview-grid"> <div class="preview-grid">
<div v-for="p in result.previews" :key="p.size" class="preview-cell"> <div v-for="p in result.previews" :key="p.size" class="preview-cell">
<div class="preview-box checker"> <div class="preview-box checker">
@ -124,7 +128,10 @@
</div> </div>
<div class="preview-meta"> <div class="preview-meta">
<span class="preview-size">{{ p.size }}×{{ p.size }}</span> <span class="preview-size">{{ p.size }}×{{ p.size }}</span>
<div class="preview-btns">
<t-button size="small" variant="text" theme="primary" @click="downloadPng(p)">PNG</t-button> <t-button size="small" variant="text" theme="primary" @click="downloadPng(p)">PNG</t-button>
<t-button size="small" variant="text" theme="primary" @click="downloadSingleIco(p.size)">ICO</t-button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -136,6 +143,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import JSZip from 'jszip'
import UploadDrop from '@/components/common/UploadDrop.vue' import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue' import ErrorAlert from '@/components/common/ErrorAlert.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue' import HistoryPanel from '@/components/common/HistoryPanel.vue'
@ -266,6 +274,31 @@ function downloadPng(p: { size: number; url: string }) {
downloadBlob(dataUrlToBlob(p.url), `${base}_${p.size}x${p.size}.png`) downloadBlob(dataUrlToBlob(p.url), `${base}_${p.size}x${p.size}.png`)
} }
/** 单独下载某个尺寸的 ICO 文件 */
function downloadSingleIco(size: number) {
const single = result.value?.singles.find((s) => s.size === size)
if (!single) return
const base = (sourceInfo.value?.name ?? 'icon').replace(/\.[^.]+$/, '')
downloadBlob(single.blob, `${base}_${size}x${size}.ico`)
}
/** 一键把所有单尺寸 ICO 打包成 ZIP 下载 */
const zipping = ref(false)
async function downloadAllZip() {
const singles = result.value?.singles
if (!singles?.length) return
zipping.value = true
try {
const base = (sourceInfo.value?.name ?? 'icon').replace(/\.[^.]+$/, '')
const zip = new JSZip()
for (const s of singles) zip.file(`${base}_${s.size}x${s.size}.ico`, s.blob)
const blob = await zip.generateAsync({ type: 'blob' })
downloadBlob(blob, `${base}_ico.zip`)
} finally {
zipping.value = false
}
}
/** 点击历史:解析摘要串恢复转换参数 */ /** 点击历史:解析摘要串恢复转换参数 */
function onHistorySelect(text: string) { function onHistorySelect(text: string) {
const sMatch = /sizes=([\d,]+)/.exec(text) const sMatch = /sizes=([\d,]+)/.exec(text)
@ -457,6 +490,10 @@ onBeforeUnmount(() => {
justify-content: space-between; justify-content: space-between;
margin-top: 6px; margin-top: 6px;
} }
.preview-btns {
display: flex;
align-items: center;
}
.preview-size { .preview-size {
font-size: 12px; font-size: 12px;
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);