feat: 全站拖拽上传 + 图片压缩批量与多选项优化

- 新增 UploadDrop 通用组件:点击选择 + 拖拽上传、图片缩略图回显、
  批量/单文件、删除,替代各页面的 t-upload 文件模式
- 图片压缩重写:批量(最多 20 张)、按质量/限制大小双模式、
  等比缩放、保持原格式、JPG 透明图白底填充、jszip 批量下载
- 修复压缩页历史解析正则 bug(/| jpg/ 恒为 true)与 slider label 类型错误
- 裁剪/二维码/Base64/哈希/OCR 页面统一接入 UploadDrop,文件变化改用 watch 响应
- 更新 agents.md 与 README:UploadDrop 约定、新坑位与依赖说明
This commit is contained in:
夏犀麟 2026-08-21 15:37:06 +08:00
parent c0bf3ad3cf
commit 6e25de5c21
12 changed files with 829 additions and 236 deletions

View File

@ -8,7 +8,8 @@
- 左侧固定导航 + 中间内容区PC Web 与移动端 H5 响应式适配(移动端为抽屉导航) - 左侧固定导航 + 中间内容区PC Web 与移动端 H5 响应式适配(移动端为抽屉导航)
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失 - 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
- 每个工具独立路由、独立本地历史记录localStorage - 每个工具独立路由、独立本地历史记录localStorage
- 通用组件:复制按钮、下载按钮、错误提示、历史记录面板 - 所有文件上传支持点击选择 + 拖拽上传,图片自动缩略图回显;图片压缩支持批量、按质量/限制大小、等比缩放与 ZIP 批量下载
- 通用组件:拖拽上传、复制按钮、下载按钮、错误提示、历史记录面板
- 全部能力由浏览器原生 API、WebAssembly 与 npm 前端库实现,无任何 mock 后端接口、无 CDN 脚本引入 - 全部能力由浏览器原生 API、WebAssembly 与 npm 前端库实现,无任何 mock 后端接口、无 CDN 脚本引入
## 技术栈 ## 技术栈
@ -23,7 +24,7 @@
| 样式 | UnoCSS 原子类 + SCSS使用 TDesign 设计令牌变量) | | 样式 | UnoCSS 原子类 + SCSS使用 TDesign 设计令牌变量) |
| 路由 | Vue Router 4History 模式) | | 路由 | Vue Router 4History 模式) |
工具依赖:`qrcode`、`jsqr`、`cropperjs`、`browser-image-compression`、`tesseract.js`、`cron-parser`、`spark-md5`(全部通过 npm 安装)。 工具依赖:`qrcode`、`jsqr`、`cropperjs`、`browser-image-compression`、`tesseract.js`、`cron-parser`、`spark-md5`、`jszip`(全部通过 npm 安装)。
## 快速开始 ## 快速开始
@ -87,7 +88,7 @@ pnpm preview
│ ├── useHistory.ts # 本地历史记录 │ ├── useHistory.ts # 本地历史记录
│ ├── useClipboard.ts # 剪贴板复制 │ ├── useClipboard.ts # 剪贴板复制
│ └── useDownload.ts # 本地文件下载 │ └── useDownload.ts # 本地文件下载
├── components/common/ # CopyButton / DownloadButton / ErrorAlert / HistoryPanel ├── components/common/ # UploadDrop / CopyButton / DownloadButton / ErrorAlert / HistoryPanel
├── layout/ # MainLayout响应式布局+ SideNav侧边导航 ├── layout/ # MainLayout响应式布局+ SideNav侧边导航
├── utils/index.ts # 通用工具函数 ├── utils/index.ts # 通用工具函数
└── views/ # 首页 + 14 个工具页面(每个工具一个目录) └── views/ # 首页 + 14 个工具页面(每个工具一个目录)

View File

@ -80,6 +80,7 @@ export interface ToolItem {
| `DownloadButton` | 下载文件 | `filename`、`getBlob: () => Blob \| null` | | `DownloadButton` | 下载文件 | `filename`、`getBlob: () => Blob \| null` |
| `ErrorAlert` | 错误提示 | `message`(为空不渲染),`@close` | | `ErrorAlert` | 错误提示 | `message`(为空不渲染),`@close` |
| `HistoryPanel` | 历史面板 | `items`、`@select`、`@remove`、`@clear` | | `HistoryPanel` | 历史面板 | `items`、`@select`、`@remove`、`@clear` |
| `UploadDrop` | 拖拽上传(全站唯一上传组件) | `files`v-model、`accept`、`multiple`、`max`、`tip` |
新建工具页必须复用这些组件,保持全站交互一致。 新建工具页必须复用这些组件,保持全站交互一致。
@ -87,7 +88,7 @@ export interface ToolItem {
| 工具 | 目录 | 核心依赖 / 实现 | 备注 | | 工具 | 目录 | 核心依赖 / 实现 | 备注 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 图片压缩 | `views/image-compress` | `browser-image-compression` | `alwaysKeepResolution: true` + `initialQuality` 实现纯质量压缩WebP 编码部分 Safari 不支持,已做 try/catch 降级提示 | | 图片压缩 | `views/image-compress` | `browser-image-compression` + `jszip` | 支持批量(最多 20 张);「按质量 / 限制大小」双模式(大小模式传 `maxSizeMB` + 迭代);`maxWidthOrHeight` 等比缩放;输出格式可选保持原格式/JPG/PNG/WebP转 JPG 时透明图先垫白底canvas 重绘)避免变黑;批量下载用 jszip 打包WebP 编码部分 Safari 不支持,已做 try/catch 逐张降级提示 |
| 图片裁剪 | `views/image-cropper` | `cropperjs`(含 `cropperjs/dist/cropper.css` | 实例在 `onBeforeUnmount` 必须 `destroy()`;换图时先 destroy 再重建 | | 图片裁剪 | `views/image-cropper` | `cropperjs`(含 `cropperjs/dist/cropper.css` | 实例在 `onBeforeUnmount` 必须 `destroy()`;换图时先 destroy 再重建 |
| 二维码 | `views/qrcode` | `qrcode`(生成)+ `jsqr`(解析) | 解析需先把图片绘制到 canvas 取 `ImageData` | | 二维码 | `views/qrcode` | `qrcode`(生成)+ `jsqr`(解析) | 解析需先把图片绘制到 canvas 取 `ImageData` |
| JSON 格式化 | `views/json-format` | 原生 `JSON.parse/stringify` | 缩进支持 2/4 空格/Tab | | JSON 格式化 | `views/json-format` | 原生 `JSON.parse/stringify` | 缩进支持 2/4 空格/Tab |
@ -120,7 +121,7 @@ import TesseractCore from 'tesseract.js-core/tesseract-core.wasm.js?url'
- Vue 3 组合式 API `<script setup lang="ts">`;页面逻辑保持“状态 → 校验 → 处理 → 历史”结构。 - Vue 3 组合式 API `<script setup lang="ts">`;页面逻辑保持“状态 → 校验 → 处理 → 历史”结构。
- TypeScript strict 模式(`vue-tsc` 参与构建),**不要**用 `any` 掩盖类型错误;`pnpm build` 必须通过。 - TypeScript strict 模式(`vue-tsc` 参与构建),**不要**用 `any` 掩盖类型错误;`pnpm build` 必须通过。
- 注释用中文,说明“为什么”而非复述代码;每个工具页顶部有用途说明。 - 注释用中文,说明“为什么”而非复述代码;每个工具页顶部有用途说明。
- 上传使用 TDesign `t-upload``:auto-upload="false"`,从 `UploadFile[].raw``File`)。 - 上传一律使用公共组件 `UploadDrop``v-model:files` 绑定 `File[]`,直接拿 `File` 对象,不再用 `t-upload``UploadFile[].raw`)。涉及图片的页面已自动获得缩略图回显;文件变化用 `watch(files, ...)` 响应(含拖拽删除/清空场景)。
- 所有对象 URL`URL.createObjectURL`)在用完后 `revokeObjectURL`,组件卸载时清理。 - 所有对象 URL`URL.createObjectURL`)在用完后 `revokeObjectURL`,组件卸载时清理。
- 样式:布局类用 UnoCSS 原子类或 `unocss.config.ts` 中已定义的快捷类(`page-wrap`、`form-row`、`code-block`);颜色类一律用 TDesign 令牌变量。 - 样式:布局类用 UnoCSS 原子类或 `unocss.config.ts` 中已定义的快捷类(`page-wrap`、`form-row`、`code-block`);颜色类一律用 TDesign 令牌变量。
- 移动端适配:栅格用 `grid-template-columns: repeat(auto-fill, ...)` + `@media (max-width: 767px)` 降为单列,参考 `HomeView.vue` - 移动端适配:栅格用 `grid-template-columns: repeat(auto-fill, ...)` + `@media (max-width: 767px)` 降为单列,参考 `HomeView.vue`
@ -137,6 +138,10 @@ import TesseractCore from 'tesseract.js-core/tesseract-core.wasm.js?url'
5. **cron-parser API**v4 使用 `parseExpression(expr)` 返回可迭代表达式,异常即非法表达式;不支持秒的表达式是标准 5 段。 5. **cron-parser API**v4 使用 `parseExpression(expr)` 返回可迭代表达式,异常即非法表达式;不支持秒的表达式是标准 5 段。
6. **移动端复制**:非 HTTPS 环境 `navigator.clipboard` 不可用,`useClipboard` 已内置 `execCommand` 回退,不要重写复制逻辑。 6. **移动端复制**:非 HTTPS 环境 `navigator.clipboard` 不可用,`useClipboard` 已内置 `execCommand` 回退,不要重写复制逻辑。
7. **JSON.parse 的输入**Vue `v-model` 绑定 textarea 的是字符串;解析前先 `trim()` 判空,错误信息取 `e.message` 展示给用户。 7. **JSON.parse 的输入**Vue `v-model` 绑定 textarea 的是字符串;解析前先 `trim()` 判空,错误信息取 `e.message` 展示给用户。
8. **t-slider 的 label**:类型是 `string | boolean | TNode`**不能传函数 formatter**(历史 bug百分比文案用外部 `<span>` 显示。
9. **PNG 无损**canvas 的 PNG 编码忽略 quality 参数,`browser-image-compression` 对 PNG 只能靠迭代降质量(无效);「限制大小」模式对 PNG 基本不生效UI 已提示改用 JPG/WebP。
10. **透明图转 JPG 变黑**:浏览器 canvas 把透明区编码为 JPG 时填黑色;必须先画白底再压缩(`flattenToWhite` 顺序不能反,先压缩透明区已被填黑不可恢复)。
11. **历史文本解析**:图片压缩/裁剪的历史按结构化文本保存(如 `q=0.8 | type=jpeg | max=1920`),用正则 `q=([\d.]+)` 等解析;不要用 `\| jpg` 这类含空分支的正则(`/| jpg/` 恒为 true是历史 bug
## 8. 构建、验证与提交 ## 8. 构建、验证与提交

View File

@ -16,6 +16,7 @@
"cron-parser": "^4.9.0", "cron-parser": "^4.9.0",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"jszip": "^3.10.1",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"spark-md5": "^3.0.2", "spark-md5": "^3.0.2",

85
pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ importers:
jsqr: jsqr:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
jszip:
specifier: ^3.10.1
version: 3.10.1
pinia: pinia:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.4(typescript@5.7.3)(vue@3.5.41(typescript@5.7.3)) version: 3.0.4(typescript@5.7.3)(vue@3.5.41(typescript@5.7.3))
@ -947,6 +950,9 @@ packages:
resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==} resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cron-parser@4.9.0: cron-parser@4.9.0:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -1067,12 +1073,18 @@ packages:
idb-keyval@6.3.0: idb-keyval@6.3.0:
resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==} resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immutable@5.1.9: immutable@5.1.9:
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
importx@0.5.2: importx@0.5.2:
resolution: {integrity: sha512-YEwlK86Ml5WiTxN/ECUYC5U7jd1CisAVw7ya4i9ZppBoHfFkT2+hChhr3PE2fYxUKLkNyivxEQpa5Ruil1LJBQ==} resolution: {integrity: sha512-YEwlK86Ml5WiTxN/ECUYC5U7jd1CisAVw7ya4i9ZppBoHfFkT2+hChhr3PE2fYxUKLkNyivxEQpa5Ruil1LJBQ==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
is-binary-path@2.1.0: is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1099,6 +1111,9 @@ packages:
is-url@1.2.4: is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
jiti@2.7.0: jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
@ -1106,9 +1121,15 @@ packages:
jsqr@1.4.0: jsqr@1.4.0:
resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
kolorist@1.8.0: kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
load-tsconfig@0.2.5: load-tsconfig@0.2.5:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@ -1200,6 +1221,9 @@ packages:
package-manager-detector@1.8.0: package-manager-detector@1.8.0:
resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@ -1250,6 +1274,9 @@ packages:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
qrcode@1.5.4: qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -1258,6 +1285,9 @@ packages:
quansync@0.2.11: quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@3.6.0: readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
@ -1284,6 +1314,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
sass@1.103.1: sass@1.103.1:
resolution: {integrity: sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==} resolution: {integrity: sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@ -1292,6 +1325,9 @@ packages:
set-blocking@2.0.0: set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
sirv@3.0.2: sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -1314,6 +1350,9 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strip-ansi@6.0.1: strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1397,6 +1436,9 @@ packages:
vite: vite:
optional: true optional: true
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uzip@0.20201231.0: uzip@0.20201231.0:
resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==} resolution: {integrity: sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==}
@ -2229,6 +2271,8 @@ snapshots:
copy-anything@4.1.0: {} copy-anything@4.1.0: {}
core-util-is@1.0.3: {}
cron-parser@4.9.0: cron-parser@4.9.0:
dependencies: dependencies:
luxon: 3.7.2 luxon: 3.7.2
@ -2363,6 +2407,8 @@ snapshots:
idb-keyval@6.3.0: {} idb-keyval@6.3.0: {}
immediate@3.0.6: {}
immutable@5.1.9: {} immutable@5.1.9: {}
importx@0.5.2: importx@0.5.2:
@ -2376,6 +2422,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
inherits@2.0.4: {}
is-binary-path@2.1.0: is-binary-path@2.1.0:
dependencies: dependencies:
binary-extensions: 2.3.0 binary-extensions: 2.3.0
@ -2394,12 +2442,25 @@ snapshots:
is-url@1.2.4: {} is-url@1.2.4: {}
isarray@1.0.0: {}
jiti@2.7.0: {} jiti@2.7.0: {}
jsqr@1.4.0: {} jsqr@1.4.0: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
kolorist@1.8.0: {} kolorist@1.8.0: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
load-tsconfig@0.2.5: {} load-tsconfig@0.2.5: {}
local-pkg@1.2.1: local-pkg@1.2.1:
@ -2474,6 +2535,8 @@ snapshots:
package-manager-detector@1.8.0: {} package-manager-detector@1.8.0: {}
pako@1.0.11: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
@ -2517,6 +2580,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
process-nextick-args@2.0.1: {}
qrcode@1.5.4: qrcode@1.5.4:
dependencies: dependencies:
dijkstrajs: 1.0.3 dijkstrajs: 1.0.3
@ -2525,6 +2590,16 @@ snapshots:
quansync@0.2.11: {} quansync@0.2.11: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readdirp@3.6.0: readdirp@3.6.0:
dependencies: dependencies:
picomatch: 2.3.2 picomatch: 2.3.2
@ -2571,6 +2646,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.62.5 '@rollup/rollup-win32-x64-msvc': 4.62.5
fsevents: 2.3.3 fsevents: 2.3.3
safe-buffer@5.1.2: {}
sass@1.103.1: sass@1.103.1:
dependencies: dependencies:
chokidar: 5.0.0 chokidar: 5.0.0
@ -2581,6 +2658,8 @@ snapshots:
set-blocking@2.0.0: {} set-blocking@2.0.0: {}
setimmediate@1.0.5: {}
sirv@3.0.2: sirv@3.0.2:
dependencies: dependencies:
'@polka/url': 1.0.0-next.29 '@polka/url': 1.0.0-next.29
@ -2601,6 +2680,10 @@ snapshots:
is-fullwidth-code-point: 3.0.0 is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1 strip-ansi: 6.0.1
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
strip-ansi@6.0.1: strip-ansi@6.0.1:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@ -2717,6 +2800,8 @@ snapshots:
- supports-color - supports-color
- vue - vue
util-deprecate@1.0.2: {}
uzip@0.20201231.0: {} uzip@0.20201231.0: {}
validator@13.15.35: {} validator@13.15.35: {}

View File

@ -0,0 +1,282 @@
<template>
<div
class="drop-zone"
:class="{ dragging, empty: !props.files.length }"
@click="openPicker"
@dragover.prevent
@dragenter.prevent="onDragEnter"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<input
ref="inputRef"
type="file"
class="hidden-input"
:accept="props.accept"
:multiple="props.multiple"
@change="onPick"
/>
<!-- 拖拽悬浮提示层 -->
<div class="drop-overlay">
<UploadIcon />
<span>松开鼠标上传文件</span>
</div>
<!-- 空状态 -->
<template v-if="!props.files.length">
<UploadIcon class="empty-icon" />
<p class="tip">{{ props.tip }}</p>
<p class="sub">点击选择文件或将文件拖拽到此处</p>
</template>
<!-- 已选文件列表图片自动生成缩略图回显 -->
<div v-else class="file-list" @click.stop>
<div v-for="(f, i) in props.files" :key="`${f.name}-${f.size}-${i}`" class="file-item">
<img v-if="previews[i]" :src="previews[i]" alt="" class="thumb" />
<span v-else class="thumb thumb-icon"><FileIcon /></span>
<span class="meta">
<span class="name" :title="f.name">{{ f.name }}</span>
<span class="size">{{ formatBytes(f.size) }}</span>
</span>
<t-button variant="text" size="small" theme="danger" class="remove-btn" @click.stop="removeAt(i)">
<template #icon><DeleteIcon /></template>
</t-button>
</div>
<p v-if="props.multiple && props.files.length < props.max" class="add-more">点击可继续添加文件</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { DeleteIcon, FileIcon, UploadIcon } from 'tdesign-icons-vue-next'
import { formatBytes } from '@/utils'
/**
* 通用拖拽上传组件替代 t-upload 文件模式
* - 支持点击选择与拖拽上传两种方式
* - 图片文件自动生成缩略图回显可单条删除
* - 通过 v-model:files 与父组件同步 File[]
* 全站所有文件上传统一使用本组件保证交互一致
*/
const props = withDefaults(
defineProps<{
/** 已选文件列表v-model:files */
files: File[]
/** 接受的文件类型,如 "image/*"、".json",空表示任意文件 */
accept?: string
/** 是否支持多选/批量 */
multiple?: boolean
/** 最多文件数量 */
max?: number
/** 空状态提示文案 */
tip?: string
}>(),
{ accept: '', multiple: false, max: 1, tip: '上传文件' },
)
const emit = defineEmits<{
'update:files': [files: File[]]
}>()
const inputRef = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
/** 图片缩略图的对象 URL与 files 一一对应,非图片为空串) */
const previews = ref<string[]>([])
// dragleave
let dragDepth = 0
/** 文件变化时重建预览 URL并释放旧 URL */
watch(
() => props.files,
(files) => {
previews.value.forEach((u) => u && URL.revokeObjectURL(u))
previews.value = files.map((f) => (f.type.startsWith('image/') ? URL.createObjectURL(f) : ''))
},
{ immediate: true },
)
onBeforeUnmount(() => {
previews.value.forEach((u) => u && URL.revokeObjectURL(u))
})
function openPicker() {
inputRef.value?.click()
}
/** 校验文件是否符合 accept 约束 */
function acceptFile(f: File): boolean {
if (!props.accept) return true
return props.accept
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
.some((p) => {
if (p.startsWith('.')) return f.name.toLowerCase().endsWith(p)
if (p.endsWith('/*')) return f.type.toLowerCase().startsWith(p.slice(0, -1))
return f.type.toLowerCase() === p
})
}
/** 合并新文件:去重、按 multiple/max 截断,并同步给父组件 */
function mergeFiles(incoming: File[]) {
const valid = incoming.filter(acceptFile)
if (!props.multiple) {
emit('update:files', valid.slice(0, 1))
return
}
const list = [...props.files]
for (const f of valid) {
if (list.length >= props.max) break
if (!list.some((e) => e.name === f.name && e.size === f.size)) list.push(f)
}
emit('update:files', list)
}
function onPick(e: Event) {
const input = e.target as HTMLInputElement
mergeFiles(Array.from(input.files ?? []))
// value change
input.value = ''
}
function onDragEnter() {
dragDepth++
dragging.value = true
}
function onDragLeave() {
dragDepth--
if (dragDepth <= 0) {
dragDepth = 0
dragging.value = false
}
}
function onDrop(e: DragEvent) {
dragDepth = 0
dragging.value = false
mergeFiles(Array.from(e.dataTransfer?.files ?? []))
}
function removeAt(i: number) {
const list = [...props.files]
list.splice(i, 1)
emit('update:files', list)
}
</script>
<style scoped>
.drop-zone {
position: relative;
border: 2px dashed var(--td-border-level-2-color);
border-radius: 8px;
padding: 24px 16px;
text-align: center;
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
}
.drop-zone:hover,
.drop-zone.dragging {
border-color: var(--td-brand-color);
background: var(--td-brand-color-light);
}
.hidden-input {
display: none;
}
.empty-icon {
font-size: 40px;
color: var(--td-brand-color);
}
.tip {
margin: 8px 0 4px;
font-size: 14px;
color: var(--td-text-color-primary);
}
.sub {
margin: 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.drop-overlay {
display: none;
}
.drop-zone.dragging .drop-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
color: var(--td-brand-color);
background: var(--td-brand-color-light);
pointer-events: none;
}
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
text-align: left;
}
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--td-border-level-1-color);
border-radius: 6px;
background: var(--td-bg-color-container);
}
.thumb {
width: 48px;
height: 48px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--td-border-level-1-color);
}
.thumb-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-secondarycontainer);
}
.meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.name {
font-size: 13px;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.remove-btn {
flex-shrink: 0;
}
.add-more {
margin: 2px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
text-align: center;
}
</style>

View File

@ -33,16 +33,7 @@
<t-tab-panel value="image" label="图片互转"> <t-tab-panel value="image" label="图片互转">
<!-- 图片 -> Base64 --> <!-- 图片 -> Base64 -->
<t-card class="tool-section" title="图片 → Base64"> <t-card class="tool-section" title="图片 → Base64">
<t-upload <UploadDrop v-model:files="files" accept="image/*" tip="上传图片,自动生成 Base64 字符串" />
v-model="fileList"
theme="file"
:auto-upload="false"
:show-upload-progress="false"
:max="1"
accept="image/*"
placeholder="点击上传或拖拽图片到此处"
@change="onFileChange"
/>
</t-card> </t-card>
<ErrorAlert :message="imgError" @close="imgError = ''" /> <ErrorAlert :message="imgError" @close="imgError = ''" />
@ -88,8 +79,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import type { UploadFile } from 'tdesign-vue-next' import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue' import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue' import CopyButton from '@/components/common/CopyButton.vue'
import DownloadButton from '@/components/common/DownloadButton.vue' import DownloadButton from '@/components/common/DownloadButton.vue'
@ -148,7 +139,7 @@ function clearText() {
} }
/* ---------- 图片互转 ---------- */ /* ---------- 图片互转 ---------- */
const fileList = ref<UploadFile[]>([]) const files = ref<File[]>([])
const imgBase64 = ref('') const imgBase64 = ref('')
const imgError = ref('') const imgError = ref('')
@ -162,12 +153,16 @@ const imgFilename = computed(() => {
return `decoded_image.${ext}` return `decoded_image.${ext}`
}) })
async function onFileChange(list: UploadFile[]) { /** 文件变化时(含拖拽删除/清空)自动转 Base64 */
fileList.value = list watch(files, (list) => {
const raw = list[0]
imgError.value = '' imgError.value = ''
imgBase64.value = '' imgBase64.value = ''
const raw = list[0]?.raw
if (!(raw instanceof File)) return if (!(raw instanceof File)) return
void convertToBase64(raw)
})
async function convertToBase64(raw: File) {
try { try {
imgBase64.value = await fileToDataUrl(raw) imgBase64.value = await fileToDataUrl(raw)
} catch { } catch {

View File

@ -23,15 +23,7 @@
<!-- 文件哈希 --> <!-- 文件哈希 -->
<t-tab-panel value="file" label="文件哈希"> <t-tab-panel value="file" label="文件哈希">
<t-card class="tool-section" title="上传文件"> <t-card class="tool-section" title="上传文件">
<t-upload <UploadDrop v-model:files="files" tip="上传任意文件,自动计算哈希" />
v-model="fileList"
theme="file"
:auto-upload="false"
:show-upload-progress="false"
:max="1"
placeholder="点击上传或拖拽任意文件到此处"
@change="onFileChange"
/>
<p v-if="file" class="file-meta"> <p v-if="file" class="file-meta">
{{ file.name }} · {{ formatBytes(file.size) }} {{ file.name }} · {{ formatBytes(file.size) }}
</p> </p>
@ -64,9 +56,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import type { UploadFile } from 'tdesign-vue-next'
import SparkMD5 from 'spark-md5' import SparkMD5 from 'spark-md5'
import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue' import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue' import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue' import HistoryPanel from '@/components/common/HistoryPanel.vue'
@ -79,7 +71,7 @@ const uppercase = ref(false)
const error = ref('') const error = ref('')
const hashResults = ref<{ alg: string; value: string }[]>([]) const hashResults = ref<{ alg: string; value: string }[]>([])
const fileList = ref<UploadFile[]>([]) const files = ref<File[]>([])
const file = ref<File | null>(null) const file = ref<File | null>(null)
const history = useHistory('hash') const history = useHistory('hash')
@ -129,16 +121,20 @@ async function computeText() {
} }
} }
async function onFileChange(list: UploadFile[]) { /** 文件变化时(含拖拽删除/清空)自动计算哈希 */
fileList.value = list watch(files, (list) => {
const raw = list[0]
error.value = '' error.value = ''
hashResults.value = [] hashResults.value = []
const raw = list[0]?.raw
if (!(raw instanceof File)) { if (!(raw instanceof File)) {
file.value = null file.value = null
return return
} }
file.value = raw file.value = raw
void computeFileHash(raw)
})
async function computeFileHash(raw: File) {
try { try {
// 4MB // 4MB
const chunkSize = 4 * 1024 * 1024 const chunkSize = 4 * 1024 * 1024

View File

@ -1,60 +1,95 @@
<template> <template>
<div class="page-wrap"> <div class="page-wrap">
<h2 class="page-title">图片压缩</h2> <h2 class="page-title">图片压缩</h2>
<p class="page-desc">上传图片调节压缩质量与输出格式全部在浏览器本地完成图片不会上传服务器</p> <p class="page-desc">批量上传图片按质量或目标大小压缩全部在浏览器本地完成图片不会上传服务器</p>
<t-card class="tool-section" title="上传图片"> <t-card class="tool-section" title="上传图片">
<t-upload <UploadDrop v-model:files="files" accept="image/*" :multiple="true" :max="20" tip="支持 JPG / PNG / WebP 等格式,最多 20 张" />
v-model="fileList"
theme="file"
:auto-upload="false"
:show-upload-progress="false"
:max="1"
accept="image/*"
placeholder="点击上传或拖拽图片到此处"
@change="onFileChange"
/>
</t-card> </t-card>
<t-card v-if="file" class="tool-section" title="压缩设置"> <t-card v-if="files.length" class="tool-section" title="压缩设置">
<div class="form-row"> <div class="form-row">
<span class="form-label">压缩质量</span> <span class="form-label">压缩方式</span>
<t-slider <t-radio-group v-model="mode" variant="default-filled">
v-model="quality" <t-radio-button value="quality">按质量</t-radio-button>
class="form-slider" <t-radio-button value="size">限制大小</t-radio-button>
:min="0.1" </t-radio-group>
:max="1"
:step="0.05"
:label="(v: number) => `${Math.round(v * 100)}%`"
/>
</div> </div>
<div v-if="mode === 'quality'" class="form-row">
<span class="form-label">压缩质量</span>
<t-slider v-model="quality" class="form-slider" :min="0.1" :max="1" :step="0.05" />
<span class="form-value">{{ Math.round(quality * 100) }}%</span>
</div>
<div v-else class="form-row">
<span class="form-label">目标大小</span>
<t-input-number v-model="targetKb" :min="1" :max="20480" :step="50" theme="normal" />
<span class="form-suffix">KB / 尽力逼近未必精确</span>
</div>
<div class="form-row">
<span class="form-label">缩放尺寸</span>
<t-select v-model="maxDim" :options="dimOptions" class="form-select" />
<span class="form-suffix">仅当图片超过该尺寸时等比缩小</span>
</div>
<div class="form-row"> <div class="form-row">
<span class="form-label">输出格式</span> <span class="form-label">输出格式</span>
<t-select v-model="outputType" :options="typeOptions" class="form-select" /> <t-select v-model="outputType" :options="typeOptions" class="form-select" />
</div> </div>
<div v-if="outputType === 'image/jpeg'" class="form-row">
<span class="form-label">透明背景</span>
<t-switch v-model="whiteBg" />
<span class="form-suffix">PNG/WebP 透明区域填白色避免转 JPG 后变黑</span>
</div>
<t-alert v-if="outputType === 'image/png'" theme="warning" class="form-tip">
PNG 为无损格式压缩质量不生效也无法精确控制大小想显著减小体积请选择 JPG WebP
</t-alert>
<t-alert v-else-if="outputType === 'image/webp'" theme="info" class="form-tip">
部分旧版 Safari 不支持 WebP 编码压缩失败时可换回 JPG / PNG
</t-alert>
<div class="form-actions"> <div class="form-actions">
<t-button theme="primary" :loading="busy" @click="compress">开始压缩</t-button> <t-button theme="primary" :loading="busy" @click="compressAll">
{{ busy ? progressText : '开始压缩' }}
</t-button>
<t-button variant="outline" @click="clearAll">清空</t-button> <t-button variant="outline" @click="clearAll">清空</t-button>
</div> </div>
</t-card> </t-card>
<ErrorAlert :message="error" @close="error = ''" /> <ErrorAlert :message="error" @close="error = ''" />
<t-card v-if="outUrl" class="tool-section" title="压缩结果"> <t-card v-if="results.length" class="tool-section" title="压缩结果">
<div class="result-compare"> <p class="summary">
<div class="compare-item"> {{ results.length }} 原始 {{ formatBytes(totalSource) }} 压缩后 {{ formatBytes(totalOut) }}
<p class="compare-title">原图{{ formatBytes(file?.size ?? 0) }}</p> 总节省 <span class="saved">{{ totalSaved }}%</span>
<img :src="previewUrl" alt="原图预览" class="compare-img" /> </p>
</div> <div class="result-list">
<div class="compare-item"> <div v-for="(r, i) in results" :key="`${r.name}-${i}`" class="result-item">
<p class="compare-title"> <img :src="r.outUrl || r.sourceUrl" alt="" class="result-thumb" />
压缩后{{ formatBytes(outFile?.size ?? 0) }}节省 {{ savings }}% <div class="result-info">
</p> <p class="result-name" :title="r.name">{{ r.name }}</p>
<img :src="outUrl" alt="压缩后预览" class="compare-img" /> <p class="result-size">
{{ formatBytes(r.sourceSize) }} {{ formatBytes(r.outSize) }}
<span v-if="!r.error" class="saved">节省 {{ r.savedPct }}%</span>
</p>
<p v-if="r.error" class="result-error">{{ r.error }}</p>
</div>
<div class="result-actions">
<t-button v-if="r.outFile" size="small" theme="primary" variant="outline" @click="downloadOne(r)">
下载
</t-button>
</div>
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<DownloadButton :filename="outName" :get-blob="getOutBlob" label="下载压缩图" /> <t-button theme="primary" variant="outline" :loading="zipping" :disabled="!okCount" @click="downloadAll">
全部下载ZIP
</t-button>
<t-button variant="outline" @click="clearResults">清空结果</t-button>
</div> </div>
</t-card> </t-card>
@ -68,116 +103,270 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import type { UploadFile } from 'tdesign-vue-next'
import imageCompression from 'browser-image-compression' import imageCompression from 'browser-image-compression'
import JSZip from 'jszip'
import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue' import ErrorAlert from '@/components/common/ErrorAlert.vue'
import DownloadButton from '@/components/common/DownloadButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue' import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory' import { useHistory } from '@/composables/useHistory'
import { formatBytes } from '@/utils' import { downloadBlob } from '@/composables/useDownload'
import { formatBytes, genFilename } from '@/utils'
/** 输出格式选项MIME -> 展示名) */ /** 压缩方式quality=按质量size=限制目标大小KB */
type Mode = 'quality' | 'size'
/** 单张图片的压缩结果 */
interface CompressResult {
name: string
sourceSize: number
sourceUrl: string
outUrl: string
outSize: number
savedPct: number
outFile: File | null
outName: string
error: string
}
/** 缩放尺寸选项0 表示保持原始分辨率 */
const dimOptions = [
{ label: '原始尺寸(不缩放)', value: 0 },
{ label: '最大 4096px', value: 4096 },
{ label: '最大 2560px', value: 2560 },
{ label: '最大 1920px', value: 1920 },
{ label: '最大 1280px', value: 1280 },
{ label: '最大 800px', value: 800 },
]
/** 输出格式选项keep 保持原格式GIF/BMP 等无法编码时自动转 PNG */
const typeOptions = [ const typeOptions = [
{ label: '保持原格式', value: 'keep' },
{ label: 'JPG', value: 'image/jpeg' }, { label: 'JPG', value: 'image/jpeg' },
{ label: 'PNG', value: 'image/png' }, { label: 'PNG', value: 'image/png' },
{ label: 'WebP', value: 'image/webp' }, { label: 'WebP', value: 'image/webp' },
] ]
const fileList = ref<UploadFile[]>([]) const files = ref<File[]>([])
const file = ref<File | null>(null) const mode = ref<Mode>('quality')
const quality = ref(0.8) const quality = ref(0.8)
const outputType = ref('image/jpeg') const targetKb = ref(200)
const maxDim = ref(0)
const outputType = ref('keep')
const whiteBg = ref(true)
const busy = ref(false) const busy = ref(false)
const zipping = ref(false)
const error = ref('') const error = ref('')
const doneCount = ref(0)
const results = ref<CompressResult[]>([])
const previewUrl = ref('') /** 历史记录:保存「张数 | 压缩方式 | 格式 | 尺寸」摘要,点击可恢复压缩参数 */
const outUrl = ref('')
const outFile = ref<File | null>(null)
/** 历史记录:保存「文件名 | 质量 | 格式」,点击可恢复压缩参数 */
const history = useHistory('image-compress') const history = useHistory('image-compress')
const outName = computed(() => { const progressText = computed(() => `压缩中(${doneCount.value}/${files.value.length}`)
const base = (file.value?.name ?? 'image').replace(/\.[^.]+$/, '')
const ext = outputType.value.split('/')[1] const okCount = computed(() => results.value.filter((r) => r.outFile).length)
return `${base}_compressed.${ext}` const totalSource = computed(() => results.value.reduce((s, r) => s + r.sourceSize, 0))
const totalOut = computed(() => results.value.reduce((s, r) => s + r.outSize, 0))
const totalSaved = computed(() => {
if (!totalSource.value) return 0
return Math.max(0, Math.round((1 - totalOut.value / totalSource.value) * 100))
}) })
const savings = computed(() => { /** 解析单张图片的目标 MIMEkeep 时沿用原类型,不可编码的类型统一转 PNG */
if (!file.value || !outFile.value) return 0 function resolveMime(file: File): string {
return Math.max(0, Math.round((1 - outFile.value.size / file.value.size) * 100)) if (outputType.value !== 'keep') return outputType.value
}) const t = file.type.toLowerCase()
if (['image/jpeg', 'image/png', 'image/webp'].includes(t)) return t
return 'image/png'
}
function onFileChange(list: UploadFile[]) { function extOf(mime: string): string {
error.value = '' return mime === 'image/jpeg' ? 'jpg' : mime.split('/')[1]
resetOutput() }
fileList.value = list
const raw = list[0]?.raw /** 判断源图是否可能带透明通道(只有 PNG/WebP 会) */
if (raw instanceof File) { function mayHaveAlpha(file: File): boolean {
file.value = raw return ['image/png', 'image/webp'].includes(file.type.toLowerCase())
previewUrl.value = URL.createObjectURL(raw) }
} else {
file.value = null /**
* 把透明图片铺到白色画布上重新导出为 PNG
* 浏览器把透明 PNG 编码为 JPG 时会填黑底先垫白底可避免透明变黑
*/
async function flattenToWhite(file: File): Promise<File> {
const img = await loadImage(file)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('当前浏览器不支持 Canvas')
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(img, 0, 0)
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
if (!blob) throw new Error('PNG 编码失败')
return new File([blob], file.name.replace(/\.[^.]+$/, '') + '_bg.png', { type: 'image/png' })
}
/** 通过 Image + 对象 URL 加载图片(兼容性好于 createImageBitmap */
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = () => {
URL.revokeObjectURL(url)
resolve(img)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('图片加载失败'))
}
img.src = url
})
}
/** 压缩单张图片并写入结果列表 */
async function compressOne(file: File) {
const mime = resolveMime(file)
const result: CompressResult = {
name: file.name,
sourceSize: file.size,
sourceUrl: URL.createObjectURL(file),
outUrl: '',
outSize: 0,
savedPct: 0,
outFile: null,
outName: '',
error: '',
}
results.value.push(result)
try {
// JPG
let source: File = file
if (whiteBg.value && mime === 'image/jpeg' && mayHaveAlpha(file)) {
source = await flattenToWhite(file)
}
const out = await imageCompression(source, {
// 0.9
initialQuality: mode.value === 'quality' ? quality.value : 0.9,
maxSizeMB: mode.value === 'size' ? targetKb.value / 1024 : Number.POSITIVE_INFINITY,
maxIteration: 10,
// 0 0
...(maxDim.value > 0 ? { maxWidthOrHeight: maxDim.value } : {}),
useWebWorker: true,
fileType: mime,
alwaysKeepResolution: true,
})
result.outFile = out
result.outSize = out.size
result.outUrl = URL.createObjectURL(out)
result.outName = `${file.name.replace(/\.[^.]+$/, '')}_compressed.${extOf(mime)}`
result.savedPct = Math.max(0, Math.round((1 - out.size / file.size) * 100))
} catch {
// Safari WebP
result.error = '压缩失败:图片可能损坏,或浏览器不支持该格式编码(如 Safari 的 WebP'
} }
} }
/** 压缩仅按质量单次压缩保持原始分辨率browser-image-compression */ /** 批量压缩:每 4 张并行,避免同时开太多 WebWorker 卡死页面 */
async function compress() { async function compressAll() {
if (!file.value) { if (!files.value.length) {
error.value = '请先上传图片' error.value = '请先上传图片'
return return
} }
busy.value = true busy.value = true
error.value = '' error.value = ''
doneCount.value = 0
clearResults()
try { try {
const result = await imageCompression(file.value, { for (let i = 0; i < files.value.length; i += 4) {
initialQuality: quality.value, const chunk = files.value.slice(i, i + 4)
maxSizeMB: Infinity, // await Promise.all(chunk.map((f) => compressOne(f)))
useWebWorker: true, doneCount.value = Math.min(i + 4, files.value.length)
fileType: outputType.value, }
alwaysKeepResolution: true, //
}) const typeTxt = outputType.value === 'keep' ? 'keep' : outputType.value.split('/')[1]
outFile.value = result let text = `${files.value.length} 张图片 | ${mode.value === 'quality' ? `q=${quality.value}` : `size=${targetKb.value}KB`} | type=${typeTxt} | max=${maxDim.value || '原始'}`
outUrl.value = URL.createObjectURL(result) if (whiteBg.value && outputType.value === 'image/jpeg') text += ' | white'
// history.add(text)
const ext = outputType.value.split('/')[1]
history.add(`${file.value.name} | 质量${Math.round(quality.value * 100)}% | ${ext}`)
} catch {
// Safari WebP
error.value = '压缩失败:当前浏览器可能不支持所选输出格式,请换一种格式重试'
} finally { } finally {
busy.value = false busy.value = false
} }
} }
function getOutBlob(): Blob | null { function downloadOne(r: CompressResult) {
return outFile.value if (r.outFile) downloadBlob(r.outFile, r.outName)
} }
/** 点击历史:恢复质量与输出格式参数 */ /** 全部结果打包为 ZIP 下载jszip纯本地生成 */
async function downloadAll() {
const ok = results.value.filter((r) => r.outFile)
if (!ok.length) return
zipping.value = true
try {
const zip = new JSZip()
const used = new Set<string>()
for (const r of ok) {
let name = r.outName
let n = 1
while (used.has(name)) {
name = r.outName.replace(/(\.[^.]+)$/, `_${n}$1`)
n++
}
used.add(name)
zip.file(name, r.outFile as File)
}
const blob = await zip.generateAsync({ type: 'blob' })
downloadBlob(blob, genFilename('compressed', 'zip'))
} catch {
error.value = '打包失败,请重试或逐张下载'
} finally {
zipping.value = false
}
}
/** 点击历史:按正则解析结构化文本,恢复压缩参数 */
function onHistorySelect(text: string) { function onHistorySelect(text: string) {
const qMatch = /质量(\d+)%/.exec(text) const qMatch = /q=([\d.]+)/.exec(text)
if (qMatch) quality.value = Number(qMatch[1]) / 100 if (qMatch) {
if (/| jpg/.test(text)) outputType.value = 'image/jpeg' mode.value = 'quality'
else if (/| png/.test(text)) outputType.value = 'image/png' quality.value = Number(qMatch[1])
else if (/| webp/.test(text)) outputType.value = 'image/webp' }
const sMatch = /size=(\d+)/.exec(text)
if (sMatch) {
mode.value = 'size'
targetKb.value = Number(sMatch[1])
}
const tMatch = /type=(\w+)/.exec(text)
if (tMatch) outputType.value = tMatch[1] === 'keep' ? 'keep' : `image/${tMatch[1]}`
const mMatch = /max=(\d+|原始)/.exec(text)
if (mMatch) maxDim.value = mMatch[1] === '原始' ? 0 : Number(mMatch[1])
whiteBg.value = /white/.test(text)
} }
function resetOutput() { function revokeResultUrls() {
outUrl.value && URL.revokeObjectURL(outUrl.value) results.value.forEach((r) => {
outUrl.value = '' r.sourceUrl && URL.revokeObjectURL(r.sourceUrl)
outFile.value = null r.outUrl && URL.revokeObjectURL(r.outUrl)
})
}
function clearResults() {
revokeResultUrls()
results.value = []
} }
function clearAll() { function clearAll() {
fileList.value = [] clearResults()
file.value = null files.value = []
error.value = '' error.value = ''
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
resetOutput()
} }
onBeforeUnmount(() => {
revokeResultUrls()
})
</script> </script>
<style scoped> <style scoped>
@ -189,40 +378,95 @@ function clearAll() {
} }
.form-slider { .form-slider {
flex: 1; flex: 1;
max-width: 360px; max-width: 300px;
}
.form-value {
width: 48px;
font-size: 13px;
color: var(--td-brand-color);
font-weight: 600;
} }
.form-select { .form-select {
width: 200px; width: 200px;
} }
.form-suffix {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
.form-tip {
margin-top: 12px;
}
.form-actions { .form-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-top: 16px; margin-top: 16px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.result-compare { .summary {
display: grid; margin: 0 0 12px;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 767px) {
.result-compare {
grid-template-columns: 1fr;
}
}
.compare-title {
margin: 0 0 8px;
font-size: 13px; font-size: 13px;
color: var(--td-text-color-secondary); color: var(--td-text-color-secondary);
} }
.compare-img { .saved {
max-width: 100%; color: var(--td-success-color);
max-height: 300px; font-weight: 600;
border-radius: 8px; }
.result-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--td-border-level-1-color); border: 1px solid var(--td-border-level-1-color);
background-image: linear-gradient(45deg, #f0f0f0 25%, transparent 25%, transparent 75%, #f0f0f0 75%), border-radius: 8px;
linear-gradient(45deg, #f0f0f0 25%, transparent 25%, transparent 75%, #f0f0f0 75%); background: var(--td-bg-color-secondarycontainer);
background-size: 16px 16px; }
background-position: 0 0, 8px 8px; .result-thumb {
width: 56px;
height: 56px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--td-border-level-1-color);
}
.result-info {
flex: 1;
min-width: 0;
}
.result-name {
margin: 0 0 4px;
font-size: 13px;
font-weight: 500;
color: var(--td-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-size {
margin: 0;
font-size: 12px;
color: var(--td-text-color-secondary);
}
.result-error {
margin: 4px 0 0;
font-size: 12px;
color: var(--td-error-color);
}
.result-actions {
flex-shrink: 0;
}
@media (max-width: 767px) {
.result-item {
flex-wrap: wrap;
}
.result-actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
} }
</style> </style>

View File

@ -4,16 +4,7 @@
<p class="page-desc">上传图片选择裁剪比例拖动选框调整区域裁剪结果本地生成并下载</p> <p class="page-desc">上传图片选择裁剪比例拖动选框调整区域裁剪结果本地生成并下载</p>
<t-card class="tool-section" title="上传图片"> <t-card class="tool-section" title="上传图片">
<t-upload <UploadDrop v-model:files="files" accept="image/*" tip="上传一张图片开始裁剪" />
v-model="fileList"
theme="file"
:auto-upload="false"
:show-upload-progress="false"
:max="1"
accept="image/*"
placeholder="点击上传或拖拽图片到此处"
@change="onFileChange"
/>
</t-card> </t-card>
<t-card v-if="imageUrl" class="tool-section" title="裁剪设置"> <t-card v-if="imageUrl" class="tool-section" title="裁剪设置">
@ -51,10 +42,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import type { UploadFile } from 'tdesign-vue-next'
import Cropper from 'cropperjs' import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css' import 'cropperjs/dist/cropper.css'
import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue' import ErrorAlert from '@/components/common/ErrorAlert.vue'
import DownloadButton from '@/components/common/DownloadButton.vue' import DownloadButton from '@/components/common/DownloadButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue' import HistoryPanel from '@/components/common/HistoryPanel.vue'
@ -75,7 +66,7 @@ const typeOptions = [
{ label: 'PNG', value: 'image/png' }, { label: 'PNG', value: 'image/png' },
] ]
const fileList = ref<UploadFile[]>([]) const files = ref<File[]>([])
const imageUrl = ref('') const imageUrl = ref('')
const ratio = ref('free') const ratio = ref('free')
const outType = ref('image/jpeg') const outType = ref('image/jpeg')
@ -95,32 +86,33 @@ function ratioValue(v: string): number {
return w / h return w / h
} }
function onFileChange(list: UploadFile[]) { /** 文件列表变化时(含拖拽删除/清空):重建图片 URL 与 Cropper 实例 */
error.value = '' watch(
outUrl.value = '' files,
fileList.value = list (list) => {
const raw = list[0]?.raw error.value = ''
if (!(raw instanceof File)) { outUrl.value = ''
imageUrl.value && URL.revokeObjectURL(imageUrl.value)
imageUrl.value = ''
return
}
imageUrl.value && URL.revokeObjectURL(imageUrl.value)
imageUrl.value = URL.createObjectURL(raw)
// Cropper
nextTick(() => {
cropper?.destroy() cropper?.destroy()
cropper = null cropper = null
if (imgRef.value) { imageUrl.value && URL.revokeObjectURL(imageUrl.value)
cropper = new Cropper(imgRef.value, { imageUrl.value = ''
aspectRatio: ratioValue(ratio.value), const raw = list[0]
viewMode: 1, if (!(raw instanceof File)) return
autoCropArea: 1, imageUrl.value = URL.createObjectURL(raw)
background: false, // Cropper
}) nextTick(() => {
} if (imgRef.value) {
}) cropper = new Cropper(imgRef.value, {
} aspectRatio: ratioValue(ratio.value),
viewMode: 1,
autoCropArea: 1,
background: false,
})
}
})
},
{ deep: true },
)
function onRatioChange(v: string) { function onRatioChange(v: string) {
ratio.value = v ratio.value = v
@ -136,7 +128,7 @@ function doCrop() {
error.value = '' error.value = ''
const canvas = cropper.getCroppedCanvas({ maxWidth: 2400, maxHeight: 2400 }) const canvas = cropper.getCroppedCanvas({ maxWidth: 2400, maxHeight: 2400 })
outUrl.value = canvas.toDataURL(outType.value, 0.92) outUrl.value = canvas.toDataURL(outType.value, 0.92)
const name = fileList.value[0]?.name ?? 'image' const name = files.value[0]?.name ?? 'image'
history.add(`${name} | ${ratio.value === 'free' ? '自由' : ratio.value} | ${outType.value.split('/')[1]}`) history.add(`${name} | ${ratio.value === 'free' ? '自由' : ratio.value} | ${outType.value.split('/')[1]}`)
} }
@ -149,18 +141,18 @@ function getOutBlob(): Blob | null {
} }
} }
/** 点击历史:恢复比例与格式参数 */ /** 点击历史:恢复比例与格式参数(历史文本末尾为扩展名,用行尾匹配避免误判) */
function onHistorySelect(text: string) { function onHistorySelect(text: string) {
const rMatch = /(自由|\d+:\d+)/.exec(text) const rMatch = /(自由|\d+:\d+)/.exec(text)
if (rMatch) ratio.value = rMatch[1] === '自由' ? 'free' : rMatch[1] if (rMatch) ratio.value = rMatch[1] === '自由' ? 'free' : rMatch[1]
if (/| jpg/.test(text)) outType.value = 'image/jpeg' if (/jpeg$|jpg$/.test(text)) outType.value = 'image/jpeg'
else if (/| png/.test(text)) outType.value = 'image/png' else if (/png$/.test(text)) outType.value = 'image/png'
} }
function clearAll() { function clearAll() {
cropper?.destroy() cropper?.destroy()
cropper = null cropper = null
fileList.value = [] files.value = []
imageUrl.value && URL.revokeObjectURL(imageUrl.value) imageUrl.value && URL.revokeObjectURL(imageUrl.value)
imageUrl.value = '' imageUrl.value = ''
outUrl.value = '' outUrl.value = ''

View File

@ -6,16 +6,7 @@
</p> </p>
<t-card class="tool-section" title="上传图片"> <t-card class="tool-section" title="上传图片">
<t-upload <UploadDrop v-model:files="files" accept="image/*" tip="支持中文 / 英文识别" />
v-model="fileList"
theme="file"
:auto-upload="false"
:show-upload-progress="false"
:max="1"
accept="image/*"
placeholder="点击上传或拖拽图片到此处(支持中文/英文识别)"
@change="onFileChange"
/>
<img v-if="previewUrl" :src="previewUrl" alt="待识别图片" class="preview-img" /> <img v-if="previewUrl" :src="previewUrl" alt="待识别图片" class="preview-img" />
<div class="form-actions"> <div class="form-actions">
<t-button theme="primary" :loading="busy" :disabled="!file" @click="recognize"> <t-button theme="primary" :loading="busy" :disabled="!file" @click="recognize">
@ -54,13 +45,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue' import { onBeforeUnmount, ref, watch } from 'vue'
import type { UploadFile } from 'tdesign-vue-next'
import { DownloadIcon } from 'tdesign-icons-vue-next' import { DownloadIcon } from 'tdesign-icons-vue-next'
import { createWorker } from 'tesseract.js' import { createWorker } from 'tesseract.js'
// worker wasm npm CDN // worker wasm npm CDN
import TesseractWorker from 'tesseract.js/dist/worker.min.js?url' import TesseractWorker from 'tesseract.js/dist/worker.min.js?url'
import TesseractCore from 'tesseract.js-core/tesseract-core.wasm.js?url' import TesseractCore from 'tesseract.js-core/tesseract-core.wasm.js?url'
import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue' import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue' import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue' import HistoryPanel from '@/components/common/HistoryPanel.vue'
@ -78,7 +69,7 @@ const LANG_PATH = 'https://tessdata.projectnaptha.com/4.0.0'
/** Worker 实例类型(避免依赖内部类型导出,保证类型兼容) */ /** Worker 实例类型(避免依赖内部类型导出,保证类型兼容) */
type WorkerInstance = Awaited<ReturnType<typeof createWorker>> type WorkerInstance = Awaited<ReturnType<typeof createWorker>>
const fileList = ref<UploadFile[]>([]) const files = ref<File[]>([])
const file = ref<File | null>(null) const file = ref<File | null>(null)
const previewUrl = ref('') const previewUrl = ref('')
const busy = ref(false) const busy = ref(false)
@ -110,19 +101,20 @@ async function getWorker(): Promise<WorkerInstance> {
return worker return worker
} }
function onFileChange(list: UploadFile[]) { /** 文件变化时(含拖拽删除/清空):更新待识别图片与预览 */
fileList.value = list watch(files, (list) => {
error.value = '' error.value = ''
resultText.value = '' resultText.value = ''
const raw = list[0]?.raw previewUrl.value && URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
const raw = list[0]
if (raw instanceof File) { if (raw instanceof File) {
file.value = raw file.value = raw
previewUrl.value && URL.revokeObjectURL(previewUrl.value)
previewUrl.value = URL.createObjectURL(raw) previewUrl.value = URL.createObjectURL(raw)
} else { } else {
file.value = null file.value = null
} }
} })
async function recognize() { async function recognize() {
if (!file.value) { if (!file.value) {
@ -155,7 +147,7 @@ function downloadResult() {
} }
function clearAll() { function clearAll() {
fileList.value = [] files.value = []
file.value = null file.value = null
error.value = '' error.value = ''
resultText.value = '' resultText.value = ''

View File

@ -54,16 +54,7 @@
<!-- ==================== 解析 ==================== --> <!-- ==================== 解析 ==================== -->
<t-tab-panel value="parse" label="解析二维码"> <t-tab-panel value="parse" label="解析二维码">
<t-card class="tool-section" title="上传二维码图片"> <t-card class="tool-section" title="上传二维码图片">
<t-upload <UploadDrop v-model:files="parseFiles" accept="image/*" tip="上传一张二维码图片自动解析" />
v-model="parseFileList"
theme="file"
:auto-upload="false"
:show-upload-progress="false"
:max="1"
accept="image/*"
placeholder="点击上传或拖拽二维码图片到此处"
@change="onParseFileChange"
/>
</t-card> </t-card>
<ErrorAlert :message="parseError" @close="parseError = ''" /> <ErrorAlert :message="parseError" @close="parseError = ''" />
@ -90,10 +81,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, watch } from 'vue'
import type { UploadFile } from 'tdesign-vue-next'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import jsQR from 'jsqr' import jsQR from 'jsqr'
import UploadDrop from '@/components/common/UploadDrop.vue'
import ErrorAlert from '@/components/common/ErrorAlert.vue' import ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue' import CopyButton from '@/components/common/CopyButton.vue'
import DownloadButton from '@/components/common/DownloadButton.vue' import DownloadButton from '@/components/common/DownloadButton.vue'
@ -165,19 +156,28 @@ function clearGen() {
} }
/* ---------- 解析 ---------- */ /* ---------- 解析 ---------- */
const parseFileList = ref<UploadFile[]>([]) const parseFiles = ref<File[]>([])
const parseError = ref('') const parseError = ref('')
const parseResult = ref('') const parseResult = ref('')
const parsed = ref(false) const parsed = ref(false)
const parseHistory = useHistory('qrcode-parse') const parseHistory = useHistory('qrcode-parse')
async function onParseFileChange(list: UploadFile[]) { /** 文件变化时(含拖拽删除/清空)自动解析 */
parseFileList.value = list watch(parseFiles, (list) => {
const raw = list[0]
if (!(raw instanceof File)) {
parseError.value = ''
parseResult.value = ''
parsed.value = false
return
}
void parseImage(raw)
})
async function parseImage(raw: File) {
parseError.value = '' parseError.value = ''
parseResult.value = '' parseResult.value = ''
parsed.value = false parsed.value = false
const raw = list[0]?.raw
if (!(raw instanceof File)) return
try { try {
// canvas jsqr // canvas jsqr

View File

@ -9,7 +9,7 @@ export default defineConfig({
transformers: [transformerDirectives()], transformers: [transformerDirectives()],
shortcuts: { shortcuts: {
// 工具页面通用容器 // 工具页面通用容器
'page-wrap': 'mx-auto w-full max-w-5xl px-4 py-6', 'page-wrap': 'mx-auto w-full max-w-5xl',
// 表单行:标签 + 控件 的通用栅格(响应式) // 表单行:标签 + 控件 的通用栅格(响应式)
'form-row': 'flex flex-col gap-2 md:flex-row md:items-center md:gap-4', 'form-row': 'flex flex-col gap-2 md:flex-row md:items-center md:gap-4',
// 结果代码块 // 结果代码块