diff --git a/plugins/batch-image-tools/.gitignore b/plugins/batch-image-tools/.gitignore new file mode 100644 index 000000000..746be9c12 --- /dev/null +++ b/plugins/batch-image-tools/.gitignore @@ -0,0 +1,12 @@ +# Dependencies +node_modules/ + +# Build / pack outputs +dist/ +*.zip + +# Logs & caches +*.log +.DS_Store +Thumbs.db +.vite/ diff --git a/plugins/batch-image-tools/CHANGELOG.md b/plugins/batch-image-tools/CHANGELOG.md new file mode 100644 index 000000000..4c8cd3530 --- /dev/null +++ b/plugins/batch-image-tools/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## [1.1.0] - 2026-08-25 + +### Added + +- 批量重命名(序号模板 / 查找替换,原地改名,冲突跳过) +- 任务结果提示框与错误提示可关闭 +- 顶部「重置」:清空队列、结果、错误并恢复默认参数 + +## [1.0.0] - 2026-08-25 + +### Added + +- 批量图片压缩(质量调节 / 目标体积) +- 格式转换(JPEG、PNG、WebP、AVIF、TIFF、GIF、BMP、ICO) +- 更改尺寸(像素 / 百分比) +- 旋转、翻转 +- 固定比例裁剪 / **拖拽裁剪** +- 文字 / 图片水印 +- 圆角、边框、边距 +- 合并长图 / PDF / 动画 GIF +- 支持拖入文件、文件夹、粘贴图片触发 +- 本地打包脚本 `npm run pack`(生成 zip 导入 ZTools) +- 固定高度布局,底部操作栏常驻 diff --git a/plugins/batch-image-tools/README.md b/plugins/batch-image-tools/README.md new file mode 100644 index 000000000..47829e168 --- /dev/null +++ b/plugins/batch-image-tools/README.md @@ -0,0 +1,169 @@ +# 批量图片处理 + +ZTools 插件:批量压缩、格式转换、缩放、裁剪、水印、合并等图片处理工具。 + +![主界面](static/images/overview.png) + +## 功能预览 + +### 图片压缩 + +质量滑块 / 目标体积(KB),支持保持原格式或指定输出。 + +![图片压缩](static/images/feature-compress.png) + +### 格式转换 + +JPEG / PNG / WebP / AVIF / TIFF / GIF / BMP / ICO。 + +![格式转换](static/images/feature-convert.png) + +### 更改尺寸 + +像素或百分比缩放,可锁定宽高比、禁止放大。 + +![更改尺寸](static/images/feature-resize.png) + +### 旋转翻转 + +任意角度旋转,水平 / 垂直翻转。 + +![旋转翻转](static/images/feature-rotate.png) + +### 裁剪图片 + +拖拽裁剪框调整区域,或使用快捷比例 / 比例居中裁剪。 + +![裁剪图片](static/images/feature-crop.png) + +### 添加水印 + +文字或图片水印,可调透明度与位置。 + +![添加水印](static/images/feature-watermark.png) + +### 圆角边框 + +圆角、描边、边距与透明度。 + +![圆角边框](static/images/feature-style.png) + +### 合并图片 + +纵向 / 横向长图、PDF、动画 GIF。 + +![合并图片](static/images/feature-merge.png) + +### 批量重命名 + +序号模板(前缀 + 起始序号 + 位数)或查找替换;原地改名,目标名冲突则跳过。 + +## 功能列表 + +- 批量选择图片或文件夹(支持递归子目录) +- 拖入图片文件 / 文件夹到 ZTools 触发 +- 粘贴图片到 ZTools 触发 +- 图片压缩(质量 10–100 / 目标体积) +- 格式转换(保持原格式或指定输出) +- 更改尺寸(像素 / 百分比) +- 旋转、水平 / 垂直翻转 +- **拖拽裁剪**(自由调整区域,或快捷比例锁定) +- 比例居中裁剪 +- 文字 / 图片水印 +- 圆角、边框、边距 +- 合并长图 / PDF / 动画 GIF +- **批量重命名**(序号模板 / 查找替换) +- 结果提示与错误提示可关闭;顶部可一键重置 +- 保存方式:同目录另存、覆盖原图、指定输出目录 + +## 触发指令 + +- 文本:`批量图片` / `图片处理` / `图片压缩` +- 拖入图片文件 +- 拖入文件夹 +- 粘贴图片 + +## 本地安装(推荐自测) + +```bash +npm run setup +npm run pack +``` + +生成 `batch-image-tools.zip` 后: + +1. 打开 ZTools → **设置 → 已安装插件** +2. 右上角 **更多 → 导入本地插件** +3. 选择 `batch-image-tools.zip`(不支持选择文件夹) +4. 搜索「批量图片」打开插件 + +改代码后重复 `npm run pack` 并重新导入即可覆盖。 + +## 开发 + +```bash +npm run setup +npm run dev +``` + +开发服务器默认:`http://127.0.0.1:5173/index.html` + +已导入本地插件时,`plugin.json` 中的 `development.main` 会走 Vite 热更新(UI)。 +**preload / sharp 相关改动**需重新 `npm run pack` 导入,或同步到插件目录后重启插件。 + +浏览器直接打开 Vite 地址仅可预览 UI,完整图片处理请在 ZTools 中运行。 + +## 构建 + +```bash +npm run build +``` + +产物在 `dist/`。也可用: + +```bash +npm run pack # build + 打 zip +``` + +## 发布到插件中心 + +参考 [ZTools 第一个插件文档](https://ztoolscenter.github.io/ZTools-doc/first-plugin.html): + +```bash +# 工作区需干净(勿提交 dist/、*.zip) +git add . +git commit -m "your changes" +git push + +ztools publish +``` + +发布前请更新 `CHANGELOG.md` 与 `public/plugin.json` 中的 `version`。 + +## 项目结构 + +``` +batch-image-tools/ +├── static/images/ # README 功能截图 +│ ├── overview.png +│ ├── feature-compress.png +│ ├── feature-convert.png +│ ├── feature-resize.png +│ ├── feature-rotate.png +│ ├── feature-crop.png +│ ├── feature-watermark.png +│ ├── feature-style.png +│ └── feature-merge.png +├── public/ # 插件静态资源 +│ ├── plugin.json +│ ├── logo.png +│ └── preload/ +├── scripts/ +├── src/BatchImage/ +├── CHANGELOG.md +└── package.json +``` + +## License + +MIT diff --git a/plugins/batch-image-tools/docs/superpowers/plans/2026-08-25-rename-reset-dismiss.md b/plugins/batch-image-tools/docs/superpowers/plans/2026-08-25-rename-reset-dismiss.md new file mode 100644 index 000000000..6704baafa --- /dev/null +++ b/plugins/batch-image-tools/docs/superpowers/plans/2026-08-25-rename-reset-dismiss.md @@ -0,0 +1,15 @@ +# Rename / Reset / Dismiss Implementation Plan + +> For agent: implement steps below; design: `docs/superpowers/specs/2026-08-25-rename-reset-dismiss-design.md` + +**Goal:** Batch rename tool, dismissible result/error banners, header reset. + +**Architecture:** `rename.js` isolated from sharp; hooks own `reset()`; `BatchImage` orchestrates. + +## Tasks + +1. Types + tools registry + RenamePanel + ToolPanel +2. preload `renameOne` + services + env/dev-mock +3. useBatchProcess / useImageQueue reset + path updates +4. ResultBanner / ErrorBanner + header reset UI +5. CHANGELOG 1.1.0 + README + verify build diff --git a/plugins/batch-image-tools/docs/superpowers/specs/2026-08-25-rename-reset-dismiss-design.md b/plugins/batch-image-tools/docs/superpowers/specs/2026-08-25-rename-reset-dismiss-design.md new file mode 100644 index 000000000..9a40338ce --- /dev/null +++ b/plugins/batch-image-tools/docs/superpowers/specs/2026-08-25-rename-reset-dismiss-design.md @@ -0,0 +1,35 @@ +# 批量重命名 / 结果可关闭 / 全局重置 — 设计说明 + +**日期:** 2026-08-25 +**状态:** 已批准 + +## 目标 + +1. 新增工具「批量重命名」:序号模板 + 查找替换;原地改名;冲突跳过。 +2. 任务完成提示框可关闭;错误条可关闭。 +3. 顶部右上角「重置」:清空队列、结果、错误、进度,并恢复各工具默认参数与默认工具。 + +## 架构原则 + +- **高内聚**:重命名逻辑独立 `processors/rename.js`,不进入 sharp pipeline。 +- **低耦合**:`useImageQueue.reset` / `useBatchProcess.reset` 各自重置;`BatchImage` 只编排;`ResultBanner` 纯展示 + `onDismiss`。 + +## 重命名 + +- `ToolId: 'rename'`,`batchMode: 'single'` +- `RenameOptions`:`renameMode: 'sequence' | 'replace'`;sequence 用 `prefix` / `startIndex` / `padLength` / `separator`;replace 用 `findText` / `replaceText` +- 保留扩展名;非法字符替换为 `_` +- 目标路径已存在 → `success: false, error: '目标文件名已存在,已跳过'` +- 成功后由队列 `applyPathUpdates` 同步更新列表中的 path/name +- UI 不展示 OutputSettings + +## UI + +- `ResultBanner`:关闭 → `setResults([])` +- 错误条关闭 → `setError('')` +- Header 右侧:统计 +「重置」按钮 + +## 非目标 + +- 不支持另存副本 / 覆盖目录输出 +- 不自动解决文件名冲突 diff --git a/plugins/batch-image-tools/index.html b/plugins/batch-image-tools/index.html new file mode 100644 index 000000000..84b7eb685 --- /dev/null +++ b/plugins/batch-image-tools/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/plugins/batch-image-tools/package-lock.json b/plugins/batch-image-tools/package-lock.json new file mode 100644 index 000000000..4582c4122 --- /dev/null +++ b/plugins/batch-image-tools/package-lock.json @@ -0,0 +1,1837 @@ +{ + "name": "batch-image-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "batch-image-tools", + "version": "1.0.0", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "typescript": "^5.3.0", + "vite": "^6.0.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://mirrors.cloud.tencent.com/npm/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@ztools-center/ztools-api-types": { + "version": "1.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/@ztools-center/ztools-api-types/-/ztools-api-types-1.0.3.tgz", + "integrity": "sha512-dv1eOAIasAupqKaQL/gESk1i2+RebdM/1gvZhrvH2D/bo3enCUsAGJ8nrHnlCLBSOGB81eC/SU0IH8BNsUlmvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.19", + "resolved": "https://mirrors.cloud.tencent.com/npm/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://mirrors.cloud.tencent.com/npm/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.414", + "resolved": "https://mirrors.cloud.tencent.com/npm/electron-to-chromium/-/electron-to-chromium-1.5.414.tgz", + "integrity": "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://mirrors.cloud.tencent.com/npm/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://mirrors.cloud.tencent.com/npm/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://mirrors.cloud.tencent.com/npm/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://mirrors.cloud.tencent.com/npm/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://mirrors.cloud.tencent.com/npm/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/plugins/batch-image-tools/package.json b/plugins/batch-image-tools/package.json new file mode 100644 index 000000000..70c930234 --- /dev/null +++ b/plugins/batch-image-tools/package.json @@ -0,0 +1,25 @@ +{ + "name": "batch-image-tools", + "version": "1.1.0", + "description": "批量压缩、格式转换、缩放图片", + "type": "module", + "scripts": { + "dev": "vite", + "setup": "npm install --ignore-scripts && node scripts/setup-preload.mjs", + "build": "node scripts/clean-build.mjs && tsc && vite build && node scripts/build-preload.mjs", + "pack": "npm run build && node scripts/pack-plugin.mjs", + "clean:legacy": "node scripts/remove-legacy-dirs.mjs" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "typescript": "^5.3.0", + "vite": "^6.0.11" + } +} diff --git a/plugins/batch-image-tools/public/logo.png b/plugins/batch-image-tools/public/logo.png new file mode 100644 index 000000000..f195f8779 Binary files /dev/null and b/plugins/batch-image-tools/public/logo.png differ diff --git a/plugins/batch-image-tools/public/plugin.json b/plugins/batch-image-tools/public/plugin.json new file mode 100644 index 000000000..7c60421aa --- /dev/null +++ b/plugins/batch-image-tools/public/plugin.json @@ -0,0 +1,44 @@ +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "batch-image-tools", + "title": "批量图片处理", + "description": "批量压缩、格式转换、缩放图片,支持拖入文件、文件夹或粘贴图片", + "author": "xgs", + "version": "1.1.0", + "main": "index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://127.0.0.1:5173/index.html" + }, + "features": [ + { + "code": "batch", + "explain": "批量压缩、转换格式、缩放图片", + "icon": "logo.png", + "cmds": [ + "批量图片", + "图片处理", + "图片压缩", + { + "type": "files", + "label": "批量图片处理", + "fileType": "file", + "extensions": ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tiff", "tif", "avif"], + "minLength": 1 + }, + { + "type": "files", + "label": "批量图片处理(文件夹)", + "fileType": "directory", + "minLength": 1, + "maxLength": 1 + }, + { + "type": "img", + "label": "图片处理" + } + ] + } + ] +} diff --git a/plugins/batch-image-tools/public/preload/.gitignore b/plugins/batch-image-tools/public/preload/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/plugins/batch-image-tools/public/preload/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/plugins/batch-image-tools/public/preload/lib/collect.js b/plugins/batch-image-tools/public/preload/lib/collect.js new file mode 100644 index 000000000..96ad5e64e --- /dev/null +++ b/plugins/batch-image-tools/public/preload/lib/collect.js @@ -0,0 +1,38 @@ +const fs = require('node:fs') +const path = require('node:path') +const { isImageFile } = require('./paths') + +function collectFromDir(dir, recursive, result, seen) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory() && recursive) { + collectFromDir(full, recursive, result, seen) + } else if (entry.isFile() && isImageFile(full)) { + if (!seen.has(full)) { + seen.add(full) + result.push(full) + } + } + } +} + +function collectImagesFromPaths(paths, recursive) { + const result = [] + const seen = new Set() + for (const p of paths) { + if (!p || !fs.existsSync(p)) continue + const stat = fs.statSync(p) + if (stat.isDirectory()) { + collectFromDir(p, recursive, result, seen) + } else if (stat.isFile() && isImageFile(p)) { + if (!seen.has(p)) { + seen.add(p) + result.push(p) + } + } + } + return result +} + +module.exports = { collectImagesFromPaths } diff --git a/plugins/batch-image-tools/public/preload/lib/constants.js b/plugins/batch-image-tools/public/preload/lib/constants.js new file mode 100644 index 000000000..a57efdb4a --- /dev/null +++ b/plugins/batch-image-tools/public/preload/lib/constants.js @@ -0,0 +1,17 @@ +const IMAGE_EXTENSIONS = new Set([ + 'jpg', + 'jpeg', + 'png', + 'webp', + 'gif', + 'bmp', + 'tiff', + 'tif', + 'avif', + 'heic', + 'heif', + 'svg', + 'ico' +]) + +module.exports = { IMAGE_EXTENSIONS } diff --git a/plugins/batch-image-tools/public/preload/lib/output.js b/plugins/batch-image-tools/public/preload/lib/output.js new file mode 100644 index 000000000..69b822f4c --- /dev/null +++ b/plugins/batch-image-tools/public/preload/lib/output.js @@ -0,0 +1,44 @@ +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } +} + +function createTempPath(prefix) { + return path.join( + os.tmpdir(), + prefix + '-' + process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.tmp' + ) +} + +function finalizeOutput(tempPath, outputPath, inputPath) { + if (outputPath === inputPath) { + fs.unlinkSync(inputPath) + } else if (fs.existsSync(outputPath)) { + fs.unlinkSync(tempPath) + return { success: false, error: '输出文件已存在: ' + outputPath } + } + fs.renameSync(tempPath, outputPath) + return { success: true } +} + +function safeUnlink(filePath) { + if (filePath && fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath) + } catch { + // ignore + } + } +} + +module.exports = { + ensureDir, + createTempPath, + finalizeOutput, + safeUnlink +} diff --git a/plugins/batch-image-tools/public/preload/lib/paths.js b/plugins/batch-image-tools/public/preload/lib/paths.js new file mode 100644 index 000000000..c1f79a32c --- /dev/null +++ b/plugins/batch-image-tools/public/preload/lib/paths.js @@ -0,0 +1,72 @@ +const path = require('node:path') +const { IMAGE_EXTENSIONS } = require('./constants') + +function getExtension(filePath) { + return path.extname(filePath).slice(1).toLowerCase() +} + +function isImageFile(filePath) { + return IMAGE_EXTENSIONS.has(getExtension(filePath)) +} + +function normalizeFormat(format) { + if (!format || format === 'original') return null + if (format === 'jpg') return 'jpeg' + if (format === 'ico') return 'png' + return format +} + +function resolveOutputPath(inputPath, options) { + const outFormat = normalizeFormat(options.format) + const ext = outFormat || getExtension(inputPath) + const baseName = path.basename(inputPath, path.extname(inputPath)) + const outputMode = options.outputMode || 'same-folder' + + if (outputMode === 'overwrite') { + if (outFormat && outFormat !== getExtension(inputPath)) { + return path.join(path.dirname(inputPath), baseName + '.' + ext) + } + return inputPath + } + + if (outputMode === 'output-dir') { + const dir = options.outputDir || path.dirname(inputPath) + return path.join(dir, baseName + '.' + ext) + } + + const suffix = options.suffix || '_processed' + const dir = path.dirname(inputPath) + if (outFormat) { + return path.join(dir, baseName + suffix + '.' + ext) + } + return path.join(dir, baseName + suffix + path.extname(inputPath)) +} + +function resolveMergeOutputPath(firstInputPath, options) { + const mergeMode = options.mergeMode || 'long-vertical' + const outputMode = options.outputMode || 'same-folder' + const dir = + outputMode === 'output-dir' && options.outputDir + ? options.outputDir + : path.dirname(firstInputPath) + const suffix = options.suffix || '_merged' + const baseName = path.basename(firstInputPath, path.extname(firstInputPath)) + + if (mergeMode === 'pdf') { + return path.join(dir, baseName + suffix + '.pdf') + } + if (mergeMode === 'gif-animated') { + return path.join(dir, baseName + suffix + '.gif') + } + + const format = normalizeFormat(options.format) || 'jpeg' + return path.join(dir, baseName + suffix + '.' + format) +} + +module.exports = { + getExtension, + isImageFile, + normalizeFormat, + resolveOutputPath, + resolveMergeOutputPath +} diff --git a/plugins/batch-image-tools/public/preload/lib/sharp-loader.js b/plugins/batch-image-tools/public/preload/lib/sharp-loader.js new file mode 100644 index 000000000..e1051a45e --- /dev/null +++ b/plugins/batch-image-tools/public/preload/lib/sharp-loader.js @@ -0,0 +1,12 @@ +const fs = require('node:fs') +const path = require('node:path') + +function loadSharp() { + const localPath = path.join(__dirname, '..', 'node_modules', 'sharp') + if (fs.existsSync(localPath)) { + return require(localPath) + } + return require('sharp') +} + +module.exports = loadSharp() diff --git a/plugins/batch-image-tools/public/preload/package-lock.json b/plugins/batch-image-tools/public/preload/package-lock.json new file mode 100644 index 000000000..a6c28ce6b --- /dev/null +++ b/plugins/batch-image-tools/public/preload/package-lock.json @@ -0,0 +1,746 @@ +{ + "name": "batch-image-tools-preload", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "batch-image-tools-preload", + "dependencies": { + "gifenc": "^1.0.3", + "pdfkit": "^0.17.2", + "sharp": "^0.34.5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://mirrors.cloud.tencent.com/npm/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/gifenc": { + "version": "1.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/gifenc/-/gifenc-1.0.3.tgz", + "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==", + "license": "MIT" + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/jpeg-exif/-/jpeg-exif-1.1.4.tgz", + "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://mirrors.cloud.tencent.com/npm/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://mirrors.cloud.tencent.com/npm/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdfkit": { + "version": "0.17.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/pdfkit/-/pdfkit-0.17.2.tgz", + "integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^2.0.4", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://mirrors.cloud.tencent.com/npm/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + } + } +} diff --git a/plugins/batch-image-tools/public/preload/package.json b/plugins/batch-image-tools/public/preload/package.json new file mode 100644 index 000000000..7f38fbc61 --- /dev/null +++ b/plugins/batch-image-tools/public/preload/package.json @@ -0,0 +1,10 @@ +{ + "name": "batch-image-tools-preload", + "private": true, + "type": "commonjs", + "dependencies": { + "gifenc": "^1.0.3", + "pdfkit": "^0.17.2", + "sharp": "^0.34.5" + } +} diff --git a/plugins/batch-image-tools/public/preload/processors/merge.js b/plugins/batch-image-tools/public/preload/processors/merge.js new file mode 100644 index 000000000..078d475f3 --- /dev/null +++ b/plugins/batch-image-tools/public/preload/processors/merge.js @@ -0,0 +1,181 @@ +const fs = require('node:fs') +const path = require('node:path') +const sharp = require('../lib/sharp-loader') +const { resolveMergeOutputPath } = require('../lib/paths') +const { ensureDir, createTempPath, safeUnlink } = require('../lib/output') + +async function loadImageMeta(inputPath) { + const meta = await sharp(inputPath, { failOn: 'none' }).metadata() + const buf = await sharp(inputPath, { failOn: 'none' }) + .resize(meta.width, meta.height, { fit: 'inside' }) + .png() + .toBuffer() + return { meta, buf } +} + +async function mergeLongImage(inputPaths, options) { + const direction = options.mergeDirection || 'vertical' + const images = [] + for (const p of inputPaths) { + images.push(await loadImageMeta(p)) + } + + if (images.length === 0) throw new Error('没有可合并的图片') + + let canvasWidth = 0 + let canvasHeight = 0 + const composites = [] + let offsetX = 0 + let offsetY = 0 + + if (direction === 'vertical') { + canvasWidth = Math.max(...images.map((i) => i.meta.width || 0)) + canvasHeight = images.reduce((sum, i) => sum + (i.meta.height || 0), 0) + for (const img of images) { + composites.push({ input: img.buf, top: offsetY, left: Math.round((canvasWidth - (img.meta.width || 0)) / 2) }) + offsetY += img.meta.height || 0 + } + } else { + canvasWidth = images.reduce((sum, i) => sum + (i.meta.width || 0), 0) + canvasHeight = Math.max(...images.map((i) => i.meta.height || 0)) + for (const img of images) { + composites.push({ input: img.buf, left: offsetX, top: Math.round((canvasHeight - (img.meta.height || 0)) / 2) }) + offsetX += img.meta.width || 0 + } + } + + const quality = Number(options.quality) || 80 + const format = options.format === 'png' ? 'png' : 'jpeg' + const pipeline = sharp({ + create: { + width: canvasWidth, + height: canvasHeight, + channels: 4, + background: { r: 255, g: 255, b: 255, alpha: 1 } + } + }).composite(composites) + + if (format === 'png') { + return pipeline.png({ compressionLevel: 9 }).toBuffer() + } + return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer() +} + +async function mergeAnimatedGif(inputPaths, options) { + const delay = Number(options.gifDelay) || 500 + const firstMeta = await sharp(inputPaths[0], { failOn: 'none' }).metadata() + const width = firstMeta.width || 640 + const height = firstMeta.height || 480 + const { GIFEncoder, quantize, applyPalette } = require('gifenc') + const encoder = GIFEncoder() + + for (const inputPath of inputPaths) { + const { data, info } = await sharp(inputPath, { failOn: 'none' }) + .resize(width, height, { + fit: 'contain', + background: { r: 255, g: 255, b: 255, alpha: 1 } + }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }) + + const palette = quantize(data, 256) + const index = applyPalette(data, palette) + encoder.writeFrame(index, info.width, info.height, { + palette, + delay: Math.max(20, Math.round(delay / 10)) + }) + } + + encoder.finish() + return Buffer.from(encoder.bytes()) +} + +async function mergePdf(inputPaths, options) { + let PDFDocument + try { + PDFDocument = require('pdfkit') + } catch { + throw new Error('PDF 合并需要 pdfkit 依赖,请在 preload 目录安装') + } + + return new Promise((resolve, reject) => { + const chunks = [] + const doc = new PDFDocument({ autoFirstPage: false }) + doc.on('data', (chunk) => chunks.push(chunk)) + doc.on('end', () => resolve(Buffer.concat(chunks))) + doc.on('error', reject) + + ;(async () => { + try { + for (const inputPath of inputPaths) { + const meta = await sharp(inputPath, { failOn: 'none' }).metadata() + const imgBuf = await sharp(inputPath, { failOn: 'none' }).jpeg({ quality: 90 }).toBuffer() + const pageWidth = meta.width || 595 + const pageHeight = meta.height || 842 + doc.addPage({ size: [pageWidth, pageHeight], margin: 0 }) + doc.image(imgBuf, 0, 0, { width: pageWidth, height: pageHeight }) + } + doc.end() + } catch (err) { + reject(err) + } + })() + }) +} + +async function processMerge(inputPaths, options) { + if (!inputPaths || inputPaths.length < 2) { + return { + success: false, + error: '合并至少需要 2 张图片' + } + } + + const outputPath = resolveMergeOutputPath(inputPaths[0], options) + ensureDir(path.dirname(outputPath)) + const tempPath = createTempPath('batch-merge') + + try { + const mergeMode = options.mergeMode || 'long-vertical' + let buffer + + if (mergeMode === 'pdf') { + buffer = await mergePdf(inputPaths, options) + } else if (mergeMode === 'gif-animated') { + buffer = await mergeAnimatedGif(inputPaths, options) + } else { + buffer = await mergeLongImage(inputPaths, { + ...options, + mergeDirection: mergeMode === 'long-horizontal' ? 'horizontal' : 'vertical' + }) + } + + fs.writeFileSync(tempPath, buffer) + + if (fs.existsSync(outputPath)) { + safeUnlink(tempPath) + return { success: false, error: '输出文件已存在: ' + outputPath } + } + + fs.renameSync(tempPath, outputPath) + const inputSize = inputPaths.reduce((sum, p) => sum + fs.statSync(p).size, 0) + const outputSize = fs.statSync(outputPath).size + + return { + success: true, + outputPath, + inputPaths, + inputSize, + outputSize + } + } catch (err) { + safeUnlink(tempPath) + return { + success: false, + error: err && err.message ? err.message : String(err) + } + } +} + +module.exports = { processMerge } diff --git a/plugins/batch-image-tools/public/preload/processors/pipeline.js b/plugins/batch-image-tools/public/preload/processors/pipeline.js new file mode 100644 index 000000000..356e30e22 --- /dev/null +++ b/plugins/batch-image-tools/public/preload/processors/pipeline.js @@ -0,0 +1,288 @@ +const sharp = require('../lib/sharp-loader') +const { normalizeFormat } = require('../lib/paths') + +function applyFormat(writer, targetFormat, quality) { + const q = Number(quality) || 80 + switch (targetFormat) { + case 'jpeg': + return writer.jpeg({ quality: q, mozjpeg: true }) + case 'png': + return writer.png({ compressionLevel: 9, quality: q }) + case 'webp': + return writer.webp({ quality: q }) + case 'avif': + return writer.avif({ quality: q }) + case 'tiff': + return writer.tiff({ quality: q }) + case 'gif': + return writer.gif() + case 'bmp': + return writer.toFormat('bmp') + default: + return writer + } +} + +async function applyResize(writer, meta, options) { + const resizeMode = options.resizeMode || 'pixel' + let width = options.width ? Number(options.width) : null + let height = options.height ? Number(options.height) : null + + if (resizeMode === 'percent') { + const percent = Number(options.scalePercent) || 100 + width = Math.round((meta.width || 0) * (percent / 100)) || null + height = Math.round((meta.height || 0) * (percent / 100)) || null + } + + if (!width && !height) return writer + + const fit = options.fit || 'inside' + const withoutEnlargement = options.withoutEnlargement !== false + + if (options.keepAspectRatio !== false && width && height) { + return writer.resize(width, height, { fit, withoutEnlargement }) + } + + return writer.resize(width || null, height || null, { + fit: 'fill', + withoutEnlargement + }) +} + +async function applyRotateFlip(writer, options) { + const angle = Number(options.rotateAngle) || 0 + if (angle) { + writer = writer.rotate(angle, { + background: options.rotateBackground || { r: 255, g: 255, b: 255, alpha: 0 } + }) + } + if (options.flipHorizontal) writer = writer.flop() + if (options.flipVertical) writer = writer.flip() + return writer +} + +async function applyCrop(writer, meta, options) { + if (!meta.width || !meta.height) return writer + + const mode = options.cropMode || 'ratio' + + if (mode === 'manual') { + const left = Math.max(0, Math.round((Number(options.cropLeft) || 0) * meta.width)) + const top = Math.max(0, Math.round((Number(options.cropTop) || 0) * meta.height)) + let width = Math.round((Number(options.cropWidth) || 1) * meta.width) + let height = Math.round((Number(options.cropHeight) || 1) * meta.height) + + width = Math.max(1, Math.min(width, meta.width - left)) + height = Math.max(1, Math.min(height, meta.height - top)) + + return writer.extract({ left, top, width, height }) + } + + const ratioW = Number(options.cropRatioW) || 0 + const ratioH = Number(options.cropRatioH) || 0 + if (!ratioW || !ratioH) return writer + + const targetRatio = ratioW / ratioH + const currentRatio = meta.width / meta.height + let cropW = meta.width + let cropH = meta.height + let left = 0 + let top = 0 + + if (currentRatio > targetRatio) { + cropW = Math.round(meta.height * targetRatio) + left = Math.round((meta.width - cropW) / 2) + } else if (currentRatio < targetRatio) { + cropH = Math.round(meta.width / targetRatio) + top = Math.round((meta.height - cropH) / 2) + } + + return writer.extract({ left, top, width: cropW, height: cropH }) +} + +function parseColor(color, fallback = '#ffffff') { + const value = (color || fallback).replace('#', '') + if (value.length === 3) { + return { + r: parseInt(value[0] + value[0], 16), + g: parseInt(value[1] + value[1], 16), + b: parseInt(value[2] + value[2], 16), + alpha: 1 + } + } + if (value.length === 6) { + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + alpha: 1 + } + } + if (value.length === 8) { + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + alpha: parseInt(value.slice(6, 8), 16) / 255 + } + } + return { r: 255, g: 255, b: 255, alpha: 1 } +} + +async function applyStyle(writer, meta, options) { + let pipeline = writer + const width = meta.width || 0 + const height = meta.height || 0 + if (!width || !height) return pipeline + + const radiusPercent = Number(options.borderRadius) || 0 + if (radiusPercent > 0) { + const radius = Math.round((Math.min(width, height) / 2) * (radiusPercent / 100)) + const mask = Buffer.from( + `` + ) + pipeline = pipeline.composite([{ input: mask, blend: 'dest-in' }]) + } + + const padding = Number(options.padding) || 0 + const borderWidth = Number(options.borderWidth) || 0 + const padColor = parseColor(options.paddingColor, '#ffffff') + padColor.alpha = Number(options.paddingOpacity ?? 100) / 100 + + if (padding > 0) { + pipeline = pipeline.extend({ + top: padding, + bottom: padding, + left: padding, + right: padding, + background: padColor + }) + } + + if (borderWidth > 0) { + const borderColor = parseColor(options.borderColor, '#000000') + pipeline = pipeline.extend({ + top: borderWidth, + bottom: borderWidth, + left: borderWidth, + right: borderWidth, + background: borderColor + }) + } + + return pipeline +} + +async function applyWatermark(writer, meta, options) { + const type = options.watermarkType || 'none' + if (type === 'none') return writer + + const opacity = Math.min(1, Math.max(0, Number(options.watermarkOpacity ?? 50) / 100)) + const composites = [] + + if (type === 'text' && options.watermarkText) { + const fontSize = Number(options.watermarkFontSize) || 32 + const color = options.watermarkColor || '#ffffff' + const text = options.watermarkText + .replace(/&/g, '&') + .replace(//g, '>') + const svg = Buffer.from( + ` + ${text} + ` + ) + composites.push({ input: svg, gravity: options.watermarkPosition || 'center' }) + } + + if (type === 'image' && options.watermarkImagePath) { + const scale = Number(options.watermarkScale) || 20 + const wmWidth = Math.round(((meta.width || 100) * scale) / 100) + const wm = await sharp(options.watermarkImagePath) + .resize(wmWidth) + .ensureAlpha() + .modulate({ brightness: 1 }) + .toBuffer() + composites.push({ + input: wm, + gravity: options.watermarkPosition || 'southeast', + blend: 'over' + }) + } + + if (composites.length === 0) return writer + return writer.composite(composites) +} + +async function compressToTargetSize(inputPath, options, targetFormat) { + const targetBytes = Number(options.targetSizeKb) * 1024 + let low = 10 + let high = 100 + let best = null + + while (low <= high) { + const quality = Math.floor((low + high) / 2) + const { writer } = await buildPipeline(inputPath, { ...options, quality }) + const buf = await applyFormat(writer, targetFormat, quality).toBuffer() + + if (buf.length <= targetBytes) { + best = { buf, quality } + low = quality + 1 + } else { + high = quality - 1 + } + } + + return best +} + +async function buildPipeline(inputPath, options) { + const meta = await sharp(inputPath, { failOn: 'none' }).metadata() + let writer = sharp(inputPath, { failOn: 'none' }) + + const tool = options.tool || 'compress' + + if (tool === 'resize' || options.applyResize) { + writer = await applyResize(writer, meta, options) + } + if (tool === 'rotate' || options.applyRotate) { + writer = await applyRotateFlip(writer, options) + } + if (tool === 'crop' || options.applyCrop) { + writer = await applyCrop(writer, meta, options) + } + if (tool === 'style' || options.applyStyle) { + writer = await applyStyle(writer, meta, options) + } + if (tool === 'watermark' || options.applyWatermark) { + writer = await applyWatermark(writer, meta, options) + } + + return { writer, meta } +} + +async function renderToBuffer(inputPath, options) { + const { writer, meta } = await buildPipeline(inputPath, options) + const outFormat = normalizeFormat(options.format) || meta.format || 'jpeg' + + if (options.targetSizeKb && Number(options.targetSizeKb) > 0) { + const result = await compressToTargetSize(inputPath, options, outFormat) + if (result) return result.buf + } + + const quality = Number(options.quality) || 80 + return applyFormat(writer, outFormat, quality).toBuffer() +} + +module.exports = { + applyFormat, + applyResize, + applyRotateFlip, + applyCrop, + applyStyle, + applyWatermark, + buildPipeline, + renderToBuffer, + normalizeFormat +} diff --git a/plugins/batch-image-tools/public/preload/processors/rename.js b/plugins/batch-image-tools/public/preload/processors/rename.js new file mode 100644 index 000000000..9c1a0df29 --- /dev/null +++ b/plugins/batch-image-tools/public/preload/processors/rename.js @@ -0,0 +1,94 @@ +const fs = require('node:fs') +const path = require('node:path') + +const INVALID_CHARS = /[<>:"/\\|?*\x00-\x1f]/g + +function sanitizeBaseName(name) { + return String(name || '') + .replace(INVALID_CHARS, '_') + .replace(/^\.+/, '') + .trim() +} + +function buildTargetBaseName(baseName, options, index) { + if (options.renameMode === 'replace') { + const findText = String(options.findText || '') + if (!findText) { + throw new Error('请填写查找内容') + } + if (!baseName.includes(findText)) { + throw new Error('文件名未匹配到查找内容,已跳过') + } + const replaceText = String(options.replaceText ?? '') + return sanitizeBaseName(baseName.split(findText).join(replaceText)) + } + + const startIndex = Number(options.startIndex) + const padLength = Math.max(1, Number(options.padLength) || 3) + const seq = Number.isFinite(startIndex) ? startIndex : 1 + const number = String(seq + index).padStart(padLength, '0') + const prefix = String(options.prefix || '') + const separator = options.separator == null ? '_' : String(options.separator) + const raw = prefix ? `${prefix}${separator}${number}` : number + return sanitizeBaseName(raw) +} + +function renameOneImage(inputPath, options, index = 0) { + try { + if (!fs.existsSync(inputPath)) { + return { inputPath, success: false, error: '源文件不存在' } + } + + const dir = path.dirname(inputPath) + const ext = path.extname(inputPath) + const baseName = path.basename(inputPath, ext) + const nextBase = buildTargetBaseName(baseName, options, index) + + if (!nextBase) { + return { inputPath, success: false, error: '新文件名为空,已跳过' } + } + + const outputPath = path.join(dir, nextBase + ext) + const samePath = path.resolve(outputPath) === path.resolve(inputPath) + + if (samePath) { + const stat = fs.statSync(inputPath) + return { + inputPath, + outputPath, + success: true, + inputSize: stat.size, + outputSize: stat.size + } + } + + if (fs.existsSync(outputPath)) { + return { + inputPath, + outputPath, + success: false, + error: `目标文件名已存在,已跳过:${path.basename(outputPath)}` + } + } + + const oldStat = fs.statSync(inputPath) + fs.renameSync(inputPath, outputPath) + const newStat = fs.statSync(outputPath) + + return { + inputPath, + outputPath, + success: true, + inputSize: oldStat.size, + outputSize: newStat.size + } + } catch (err) { + return { + inputPath, + success: false, + error: err && err.message ? err.message : String(err) + } + } +} + +module.exports = { renameOneImage, buildTargetBaseName, sanitizeBaseName } diff --git a/plugins/batch-image-tools/public/preload/processors/single.js b/plugins/batch-image-tools/public/preload/processors/single.js new file mode 100644 index 000000000..12203b06c --- /dev/null +++ b/plugins/batch-image-tools/public/preload/processors/single.js @@ -0,0 +1,52 @@ +const fs = require('node:fs') +const sharp = require('../lib/sharp-loader') +const { resolveOutputPath, normalizeFormat } = require('../lib/paths') +const { ensureDir, createTempPath, finalizeOutput, safeUnlink } = require('../lib/output') +const { applyFormat, buildPipeline } = require('./pipeline') + +async function processOneImage(inputPath, options) { + const outputPath = resolveOutputPath(inputPath, options) + ensureDir(require('node:path').dirname(outputPath)) + + const tempPath = createTempPath('batch-image') + try { + const { writer, meta } = await buildPipeline(inputPath, options) + const outFormat = normalizeFormat(options.format) || meta.format || 'jpeg' + const quality = Number(options.quality) || 80 + + if (options.targetSizeKb && Number(options.targetSizeKb) > 0 && options.tool === 'compress') { + const { renderToBuffer } = require('./pipeline') + const buf = await renderToBuffer(inputPath, options) + fs.writeFileSync(tempPath, buf) + } else { + await applyFormat(writer, outFormat, quality).toFile(tempPath) + } + + const oldStat = fs.statSync(inputPath) + const finalize = finalizeOutput(tempPath, outputPath, inputPath) + if (!finalize.success) { + return { inputPath, outputPath, success: false, error: finalize.error } + } + + const newStat = fs.statSync(outputPath) + return { + inputPath, + outputPath, + success: true, + inputSize: oldStat.size, + outputSize: newStat.size, + width: meta.width, + height: meta.height, + format: outFormat + } + } catch (err) { + safeUnlink(tempPath) + return { + inputPath, + success: false, + error: err && err.message ? err.message : String(err) + } + } +} + +module.exports = { processOneImage } diff --git a/plugins/batch-image-tools/public/preload/services.js b/plugins/batch-image-tools/public/preload/services.js new file mode 100644 index 000000000..f23ad7d78 --- /dev/null +++ b/plugins/batch-image-tools/public/preload/services.js @@ -0,0 +1,51 @@ +const fs = require('node:fs') +const path = require('node:path') +const sharp = require('./lib/sharp-loader') +const { getExtension, isImageFile } = require('./lib/paths') +const { collectImagesFromPaths } = require('./lib/collect') +const { processOneImage } = require('./processors/single') +const { processMerge } = require('./processors/merge') +const { renameOneImage } = require('./processors/rename') + +function saveBase64Image(base64Url) { + const match = /^data:image\/([a-z0-9+]+);base64,/i.exec(base64Url) + if (!match) { + throw new Error('无效的图片数据') + } + const ext = match[1] === 'jpeg' ? 'jpg' : match[1] + const downloads = window.ztools.getPath('downloads') + const filePath = path.join(downloads, 'paste-' + Date.now() + '.' + ext) + fs.writeFileSync(filePath, base64Url.substring(match[0].length), { encoding: 'base64' }) + return filePath +} + +window.services = { + isImageFile, + collectImages: (paths, recursive = true) => collectImagesFromPaths(paths, recursive), + saveBase64Image, + + getImageInfo: async (filePath) => { + const stat = fs.statSync(filePath) + const meta = await sharp(filePath, { failOn: 'none' }).metadata() + return { + path: filePath, + name: path.basename(filePath), + size: stat.size, + width: meta.width || 0, + height: meta.height || 0, + format: meta.format || getExtension(filePath) + } + }, + + getThumbnail: async (filePath, size = 64) => { + const buf = await sharp(filePath, { failOn: 'none' }) + .resize(size, size, { fit: 'cover' }) + .jpeg({ quality: 70 }) + .toBuffer() + return 'data:image/jpeg;base64,' + buf.toString('base64') + }, + + processOne: processOneImage, + processMerge, + renameOne: renameOneImage +} diff --git a/plugins/batch-image-tools/scripts/build-preload.mjs b/plugins/batch-image-tools/scripts/build-preload.mjs new file mode 100644 index 000000000..4bde99440 --- /dev/null +++ b/plugins/batch-image-tools/scripts/build-preload.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +const distPreload = path.resolve('dist/preload') + +if (!fs.existsSync(distPreload)) { + console.error('dist/preload not found, run vite build first') + process.exit(1) +} + +console.log('Installing preload dependencies into dist/preload ...') +execSync('npm install --omit=dev', { cwd: distPreload, stdio: 'inherit' }) +console.log('Preload dependencies ready.') diff --git a/plugins/batch-image-tools/scripts/clean-build.mjs b/plugins/batch-image-tools/scripts/clean-build.mjs new file mode 100644 index 000000000..669461e2c --- /dev/null +++ b/plugins/batch-image-tools/scripts/clean-build.mjs @@ -0,0 +1,9 @@ +import fs from 'node:fs' +import path from 'node:path' + +const target = path.resolve('dist') + +if (fs.existsSync(target)) { + fs.rmSync(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) + console.log('Cleaned dist/') +} diff --git a/plugins/batch-image-tools/scripts/copy-public.mjs b/plugins/batch-image-tools/scripts/copy-public.mjs new file mode 100644 index 000000000..96c4825d7 --- /dev/null +++ b/plugins/batch-image-tools/scripts/copy-public.mjs @@ -0,0 +1,22 @@ +import fs from 'node:fs' +import path from 'node:path' + +const SKIP_DIRS = new Set(['node_modules', '.git']) + +export function copyPublicAssets(srcDir, destDir) { + if (!fs.existsSync(srcDir)) return + + fs.mkdirSync(destDir, { recursive: true }) + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + if (SKIP_DIRS.has(entry.name)) continue + + const srcPath = path.join(srcDir, entry.name) + const destPath = path.join(destDir, entry.name) + + if (entry.isDirectory()) { + copyPublicAssets(srcPath, destPath) + } else if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath) + } + } +} diff --git a/plugins/batch-image-tools/scripts/pack-plugin.mjs b/plugins/batch-image-tools/scripts/pack-plugin.mjs new file mode 100644 index 000000000..25b784a2a --- /dev/null +++ b/plugins/batch-image-tools/scripts/pack-plugin.mjs @@ -0,0 +1,30 @@ +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +const distDir = path.resolve('dist') +const pluginJson = path.join(distDir, 'plugin.json') +const outputZip = path.resolve('batch-image-tools.zip') + +if (!fs.existsSync(pluginJson)) { + console.error('dist/ 未就绪,请先执行: npm run build') + process.exit(1) +} + +if (fs.existsSync(outputZip)) { + fs.unlinkSync(outputZip) +} + +const distGlob = path.join(distDir, '*').replace(/\\/g, '/') + +if (process.platform === 'win32') { + execSync( + `powershell -NoProfile -Command "Compress-Archive -Path '${distGlob}' -DestinationPath '${outputZip}' -Force"`, + { stdio: 'inherit' } + ) +} else { + execSync(`cd "${distDir}" && zip -r "${outputZip}" .`, { stdio: 'inherit', shell: true }) +} + +console.log(`\n插件包已生成: ${outputZip}`) +console.log('在 ZTools → 已安装插件 → 更多 → 导入本地插件,选择此 zip 文件') diff --git a/plugins/batch-image-tools/scripts/remove-legacy-dirs.mjs b/plugins/batch-image-tools/scripts/remove-legacy-dirs.mjs new file mode 100644 index 000000000..9511bb595 --- /dev/null +++ b/plugins/batch-image-tools/scripts/remove-legacy-dirs.mjs @@ -0,0 +1,17 @@ +import fs from 'node:fs' +import path from 'node:path' + +const legacyDirs = ['dist', 'release'] + +for (const name of legacyDirs) { + const target = path.resolve(name) + if (!fs.existsSync(target)) continue + + const trash = path.resolve(`${name}_legacy_${Date.now()}`) + try { + fs.renameSync(target, trash) + console.log(`Moved ${name}/ -> ${path.basename(trash)}/`) + } catch (error) { + console.warn(`Skip ${name}/: ${error.message}`) + } +} diff --git a/plugins/batch-image-tools/scripts/setup-preload.mjs b/plugins/batch-image-tools/scripts/setup-preload.mjs new file mode 100644 index 000000000..935193267 --- /dev/null +++ b/plugins/batch-image-tools/scripts/setup-preload.mjs @@ -0,0 +1,7 @@ +import { execSync } from 'node:child_process' +import path from 'node:path' + +const preloadDir = path.resolve('public/preload') + +console.log('Installing preload dependencies ...') +execSync('npm install --omit=dev', { cwd: preloadDir, stdio: 'inherit' }) diff --git a/plugins/batch-image-tools/src/App.tsx b/plugins/batch-image-tools/src/App.tsx new file mode 100644 index 000000000..d939a9807 --- /dev/null +++ b/plugins/batch-image-tools/src/App.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react' +import BatchImage, { type EnterAction } from './BatchImage' + +const DEV_ENTER_ACTION: EnterAction = { code: 'batch' } + +function isOutsideZTools() { + return import.meta.env.DEV && typeof window.ztools?.isDev !== 'function' +} + +export default function App() { + const [enterAction, setEnterAction] = useState( + () => (import.meta.env.DEV ? DEV_ENTER_ACTION : {}) + ) + + useEffect(() => { + const ztools = window.ztools + if (!ztools) return + + ztools.onPluginEnter((action) => { + setEnterAction(action as EnterAction) + ztools.setExpendHeight(720) + }) + ztools.onPluginOut(() => { + setEnterAction(import.meta.env.DEV ? DEV_ENTER_ACTION : {}) + }) + }, []) + + if (enterAction.code !== 'batch') { + return null + } + + return ( + <> + {isOutsideZTools() && ( +
+ 开发预览模式:请在 ZTools 中搜索「批量图片」打开插件以使用完整功能 +
+ )} + + + ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/common/CropEditor.tsx b/plugins/batch-image-tools/src/BatchImage/components/common/CropEditor.tsx new file mode 100644 index 000000000..06f5a1681 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/common/CropEditor.tsx @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' + +export interface CropRect { + left: number + top: number + width: number + height: number +} + +type Handle = 'move' | 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' + +interface CropEditorProps { + imagePath?: string + imageWidth?: number + imageHeight?: number + value: CropRect + aspectRatio?: number | null + disabled?: boolean + onChange: (value: CropRect) => void +} + +const MIN_SIZE = 0.05 + +function clampRect(rect: CropRect, aspectRatio?: number | null): CropRect { + let { left, top, width, height } = rect + + width = Math.max(MIN_SIZE, Math.min(1, width)) + height = Math.max(MIN_SIZE, Math.min(1, height)) + + if (aspectRatio && aspectRatio > 0) { + if (width / height > aspectRatio) { + width = height * aspectRatio + } else { + height = width / aspectRatio + } + width = Math.min(width, 1) + height = Math.min(height, 1) + } + + left = Math.max(0, Math.min(left, 1 - width)) + top = Math.max(0, Math.min(top, 1 - height)) + + return { + left: Number(left.toFixed(4)), + top: Number(top.toFixed(4)), + width: Number(width.toFixed(4)), + height: Number(height.toFixed(4)) + } +} + +export default function CropEditor({ + imagePath, + imageWidth, + imageHeight, + value, + aspectRatio, + disabled, + onChange +}: CropEditorProps) { + const stageRef = useRef(null) + const dragRef = useRef<{ + handle: Handle + startX: number + startY: number + origin: CropRect + } | null>(null) + + const [preview, setPreview] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + let cancelled = false + if (!imagePath || !window.services?.getThumbnail) { + setPreview('') + return + } + + setLoading(true) + window.services + .getThumbnail(imagePath, 720) + .then((data) => { + if (!cancelled) setPreview(data) + }) + .catch(() => { + if (!cancelled) setPreview('') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [imagePath]) + + const updateFromPointer = useCallback( + (clientX: number, clientY: number) => { + const drag = dragRef.current + const stage = stageRef.current + if (!drag || !stage) return + + const rect = stage.getBoundingClientRect() + const dx = (clientX - drag.startX) / rect.width + const dy = (clientY - drag.startY) / rect.height + const o = drag.origin + + let next: CropRect = { ...o } + + if (drag.handle === 'move') { + next = { + left: o.left + dx, + top: o.top + dy, + width: o.width, + height: o.height + } + } else { + let left = o.left + let top = o.top + let right = o.left + o.width + let bottom = o.top + o.height + + if (drag.handle.includes('w')) left = o.left + dx + if (drag.handle.includes('e')) right = o.left + o.width + dx + if (drag.handle.includes('n')) top = o.top + dy + if (drag.handle.includes('s')) bottom = o.top + o.height + dy + + if (right - left < MIN_SIZE) { + if (drag.handle.includes('w')) left = right - MIN_SIZE + else right = left + MIN_SIZE + } + if (bottom - top < MIN_SIZE) { + if (drag.handle.includes('n')) top = bottom - MIN_SIZE + else bottom = top + MIN_SIZE + } + + next = { + left, + top, + width: right - left, + height: bottom - top + } + } + + onChange(clampRect(next, aspectRatio)) + }, + [aspectRatio, onChange] + ) + + useEffect(() => { + const onMove = (event: PointerEvent) => { + if (!dragRef.current) return + updateFromPointer(event.clientX, event.clientY) + } + const onUp = () => { + dragRef.current = null + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + }, [updateFromPointer]) + + const startDrag = (handle: Handle, event: ReactPointerEvent) => { + if (disabled) return + event.preventDefault() + event.stopPropagation() + dragRef.current = { + handle, + startX: event.clientX, + startY: event.clientY, + origin: { ...value } + } + } + + if (!imagePath) { + return ( +
+ 请先添加图片,然后拖拽裁剪框调整范围 +
+ ) + } + + const pixelW = imageWidth ? Math.round(value.width * imageWidth) : 0 + const pixelH = imageHeight ? Math.round(value.height * imageHeight) : 0 + + const stageStyle = + imageWidth && imageHeight + ? { aspectRatio: `${imageWidth} / ${imageHeight}` } + : undefined + + return ( +
+
+ {loading &&
预览加载中...
} + {preview && } +
startDrag('move', e)} + > + + {(['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'] as Handle[]).map((handle) => ( + startDrag(handle, e)} + /> + ))} +
+
+
+ 裁剪区域 {Math.round(value.width * 100)}% × {Math.round(value.height * 100)}% + {imageWidth && imageHeight ? ` · 约 ${pixelW}×${pixelH}px` : ''} + 批量时按相对比例应用到每张图 +
+
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/common/FieldRow.tsx b/plugins/batch-image-tools/src/BatchImage/components/common/FieldRow.tsx new file mode 100644 index 000000000..c1b896221 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/common/FieldRow.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react' + +export function FieldRow({ + label, + children, + wide +}: { + label: string + children: ReactNode + wide?: boolean +}) { + return ( +
+ +
{children}
+
+ ) +} + +export function SegmentedButtons({ + value, + options, + onChange, + disabled +}: { + value: T + options: Array<{ value: T; label: string }> + onChange: (value: T) => void + disabled?: boolean +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/common/OutputSettings.tsx b/plugins/batch-image-tools/src/BatchImage/components/common/OutputSettings.tsx new file mode 100644 index 000000000..f8b81d7b6 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/common/OutputSettings.tsx @@ -0,0 +1,68 @@ +import type { OutputMode } from '../../types' +import { FieldRow } from './FieldRow' + +interface OutputSettingsProps { + outputMode: OutputMode + outputDir: string + suffix: string + disabled?: boolean + onOutputModeChange: (mode: OutputMode) => void + onOutputDirChange: (dir: string) => void + onSuffixChange: (suffix: string) => void + onPickOutputDir: () => void +} + +export default function OutputSettings({ + outputMode, + outputDir, + suffix, + disabled, + onOutputModeChange, + onOutputDirChange, + onSuffixChange, + onPickOutputDir +}: OutputSettingsProps) { + return ( + <> + + + + + {outputMode === 'same-folder' && ( + + onSuffixChange(e.target.value)} + disabled={disabled} + /> + + )} + + {outputMode === 'output-dir' && ( + +
+ onOutputDirChange(e.target.value)} + /> + +
+
+ )} + + ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/layout/ActionBar.tsx b/plugins/batch-image-tools/src/BatchImage/components/layout/ActionBar.tsx new file mode 100644 index 000000000..42831b4ec --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/layout/ActionBar.tsx @@ -0,0 +1,62 @@ +interface ActionBarProps { + imageCount: number + recursive: boolean + processing: boolean + progress: number + disabled?: boolean + onPickFiles: () => void + onPickFolder: () => void + onRecursiveChange: (value: boolean) => void + onProcess: () => void +} + +export default function ActionBar({ + imageCount, + recursive, + processing, + progress, + disabled, + onPickFiles, + onPickFolder, + onRecursiveChange, + onProcess +}: ActionBarProps) { + return ( +
+
+
+ + + +
+ +
+ {processing && ( +
+
+
+ )} +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/layout/ErrorBanner.tsx b/plugins/batch-image-tools/src/BatchImage/components/layout/ErrorBanner.tsx new file mode 100644 index 000000000..5f689a227 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/layout/ErrorBanner.tsx @@ -0,0 +1,18 @@ +interface ErrorBannerProps { + message: string + onDismiss: () => void +} + +export default function ErrorBanner({ message, onDismiss }: ErrorBannerProps) { + if (!message) return null + + return ( +
+ ! +
{message}
+ +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/layout/FileTable.tsx b/plugins/batch-image-tools/src/BatchImage/components/layout/FileTable.tsx new file mode 100644 index 000000000..d03500b34 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/layout/FileTable.tsx @@ -0,0 +1,68 @@ +import type { ImageItem } from '../../types' +import { formatBytes } from '../../utils' + +interface FileTableProps { + images: ImageItem[] + disabled?: boolean + onRemove: (path: string) => void + onClear?: () => void +} + +export default function FileTable({ images, disabled, onRemove, onClear }: FileTableProps) { + if (images.length === 0) { + return ( +
+
🖼
+

尚未添加图片

+ 点击底部「添加图片」或「添加文件夹」开始 +
+ ) + } + + return ( +
+
+ 文件名 + 尺寸 + 大小 + + 操作 + {onClear && images.length > 0 && ( + + )} + +
+
    + {images.map((item) => ( +
  • +
    + {item.thumb ? ( + + ) : ( + + )} +
    + + {item.name} + + {item.format.toUpperCase()} +
    +
    + {item.width}×{item.height} + {formatBytes(item.size)} + +
  • + ))} +
+
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/layout/ResultBanner.tsx b/plugins/batch-image-tools/src/BatchImage/components/layout/ResultBanner.tsx new file mode 100644 index 000000000..9515bb35c --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/layout/ResultBanner.tsx @@ -0,0 +1,65 @@ +import type { ProcessResult } from '../../types' +import { formatBytes, pathBasename, savingsPercent } from '../../utils' + +interface ResultBannerProps { + results: ProcessResult[] + mode?: 'process' | 'rename' + maxItems?: number + onOpenInFolder?: () => void + onDismiss: () => void +} + +function formatResultLine(item: ProcessResult, mode: 'process' | 'rename') { + if (!item.success) return item.error || '处理失败' + + const from = pathBasename(item.inputPath || '') + const to = pathBasename(item.outputPath || item.inputPath || '') + + if (mode === 'rename') { + return from === to ? `${to}(文件名未变化)` : `${from} → ${to}` + } + + return `${from || to} · ${formatBytes(item.inputSize || 0)} → ${formatBytes(item.outputSize || 0)} (${savingsPercent(item.inputSize || 0, item.outputSize || 0)}%)` +} + +export default function ResultBanner({ + results, + mode = 'process', + maxItems = 6, + onOpenInFolder, + onDismiss +}: ResultBannerProps) { + if (results.length === 0) return null + + const successCount = results.filter((item) => item.success).length + + return ( +
+
+ + 成功 {successCount}/{results.length} + +
+ {onOpenInFolder ? ( + + ) : null} + +
+
+
    + {results.slice(0, maxItems).map((item) => ( +
  • + {formatResultLine(item, mode)} +
  • + ))} +
+
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/layout/Sidebar.tsx b/plugins/batch-image-tools/src/BatchImage/components/layout/Sidebar.tsx new file mode 100644 index 000000000..69a88130d --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/layout/Sidebar.tsx @@ -0,0 +1,31 @@ +import { TOOLS } from '../../config/tools' +import type { ToolId } from '../../types' + +interface SidebarProps { + activeTool: ToolId + disabled?: boolean + onChange: (tool: ToolId) => void +} + +export default function Sidebar({ activeTool, disabled, onChange }: SidebarProps) { + return ( + + ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/CompressPanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/CompressPanel.tsx new file mode 100644 index 000000000..7e7f9c7fe --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/CompressPanel.tsx @@ -0,0 +1,70 @@ +import { FORMAT_OPTIONS } from '../../config/tools' +import type { CompressOptions } from '../../types' +import { FieldRow } from '../common/FieldRow' +import OutputSettings from '../common/OutputSettings' + +interface PanelProps { + options: CompressOptions + disabled?: boolean + onChange: (options: CompressOptions) => void + onPickOutputDir: () => void +} + +export default function CompressPanel({ options, disabled, onChange, onPickOutputDir }: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + + return ( +
+ + patch({ quality: Number(e.target.value) })} + disabled={disabled} + /> + {options.quality} + + + + + patch({ targetSizeKb: e.target.value ? Number(e.target.value) : null }) + } + disabled={disabled} + /> + KB + + + + + + + patch({ outputMode })} + onOutputDirChange={(outputDir) => patch({ outputDir })} + onSuffixChange={(suffix) => patch({ suffix })} + onPickOutputDir={onPickOutputDir} + /> +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/ConvertPanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/ConvertPanel.tsx new file mode 100644 index 000000000..aabba66e6 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/ConvertPanel.tsx @@ -0,0 +1,57 @@ +import { FORMAT_OPTIONS } from '../../config/tools' +import type { ConvertOptions } from '../../types' +import { FieldRow } from '../common/FieldRow' +import OutputSettings from '../common/OutputSettings' + +interface PanelProps { + options: ConvertOptions + disabled?: boolean + onChange: (options: ConvertOptions) => void + onPickOutputDir: () => void +} + +export default function ConvertPanel({ options, disabled, onChange, onPickOutputDir }: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + const formats = FORMAT_OPTIONS.filter((item) => item.value !== 'original') + + return ( +
+ + + + + + patch({ quality: Number(e.target.value) })} + disabled={disabled} + /> + {options.quality} + + + patch({ outputMode })} + onOutputDirChange={(outputDir) => patch({ outputDir })} + onSuffixChange={(suffix) => patch({ suffix })} + onPickOutputDir={onPickOutputDir} + /> +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/CropPanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/CropPanel.tsx new file mode 100644 index 000000000..43dbfce8a --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/CropPanel.tsx @@ -0,0 +1,192 @@ +import { CROP_PRESETS, FORMAT_OPTIONS } from '../../config/tools' +import type { CropOptions, ImageItem } from '../../types' +import CropEditor from '../common/CropEditor' +import { FieldRow, SegmentedButtons } from '../common/FieldRow' +import OutputSettings from '../common/OutputSettings' + +interface PanelProps { + options: CropOptions + previewImage?: ImageItem | null + disabled?: boolean + onChange: (options: CropOptions) => void + onPickOutputDir: () => void +} + +export default function CropPanel({ + options, + previewImage, + disabled, + onChange, + onPickOutputDir +}: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + + const applyPreset = (w: number, h: number) => { + const ratio = w / h + let cropWidth = 0.8 + let cropHeight = cropWidth / ratio + if (cropHeight > 0.8) { + cropHeight = 0.8 + cropWidth = cropHeight * ratio + } + patch({ + cropMode: 'manual', + cropRatioW: w, + cropRatioH: h, + cropLeft: (1 - cropWidth) / 2, + cropTop: (1 - cropHeight) / 2, + cropWidth, + cropHeight + }) + } + + return ( +
+ + patch({ cropMode })} + disabled={disabled} + /> + + + {(options.cropMode || 'manual') === 'manual' ? ( + <> + +
+ {CROP_PRESETS.map((preset) => ( + + ))} + +
+
+ +
+ 0 && options.cropRatioH > 0 + ? options.cropRatioW / options.cropRatioH + : null + } + disabled={disabled} + onChange={(rect) => + patch({ + cropLeft: rect.left, + cropTop: rect.top, + cropWidth: rect.width, + cropHeight: rect.height + }) + } + /> +
+ + ) : ( + <> + +
+ {CROP_PRESETS.map((preset) => ( + + ))} +
+
+ + + patch({ cropRatioW: Number(e.target.value) })} + disabled={disabled} + /> + : + patch({ cropRatioH: Number(e.target.value) })} + disabled={disabled} + /> + + + )} + + + + + + patch({ outputMode })} + onOutputDirChange={(outputDir) => patch({ outputDir })} + onSuffixChange={(suffix) => patch({ suffix })} + onPickOutputDir={onPickOutputDir} + /> +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/MergePanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/MergePanel.tsx new file mode 100644 index 000000000..77a9184a7 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/MergePanel.tsx @@ -0,0 +1,73 @@ +import { MERGE_MODE_OPTIONS } from '../../config/tools' +import type { MergeOptions } from '../../types' +import { FieldRow } from '../common/FieldRow' +import OutputSettings from '../common/OutputSettings' + +interface PanelProps { + options: MergeOptions + disabled?: boolean + onChange: (options: MergeOptions) => void + onPickOutputDir: () => void +} + +export default function MergePanel({ options, disabled, onChange, onPickOutputDir }: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + + return ( +
+ + + + + {options.mergeMode === 'gif-animated' && ( + + patch({ gifDelay: Number(e.target.value) })} + disabled={disabled} + /> + ms + + )} + + {options.mergeMode !== 'pdf' && options.mergeMode !== 'gif-animated' && ( + + patch({ quality: Number(e.target.value) })} + disabled={disabled} + /> + {options.quality} + + )} + + patch({ outputMode })} + onOutputDirChange={(outputDir) => patch({ outputDir })} + onSuffixChange={(suffix) => patch({ suffix })} + onPickOutputDir={onPickOutputDir} + /> +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/RenamePanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/RenamePanel.tsx new file mode 100644 index 000000000..3b7a2cfc5 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/RenamePanel.tsx @@ -0,0 +1,134 @@ +import { useMemo } from 'react' +import type { RenameOptions } from '../../types' +import { FieldRow, SegmentedButtons } from '../common/FieldRow' +import { pathBasename } from '../../utils' + +interface PanelProps { + options: RenameOptions + sampleName?: string + disabled?: boolean + onChange: (options: RenameOptions) => void +} + +function previewName(options: RenameOptions, sampleName: string, index: number) { + const extMatch = /\.[^.]+$/.exec(sampleName) + const ext = extMatch ? extMatch[0] : '' + const base = sampleName.slice(0, sampleName.length - ext.length) + + if (options.renameMode === 'replace') { + const findText = options.findText || '' + if (!findText) return `${base || '文件名'}${ext || '.jpg'}` + return `${base.split(findText).join(options.replaceText || '')}${ext || '.jpg'}` + } + + const pad = Math.max(1, options.padLength || 3) + const start = Number.isFinite(options.startIndex) ? options.startIndex : 1 + const number = String(start + index).padStart(pad, '0') + const prefix = options.prefix || '' + const separator = options.separator ?? '_' + const nextBase = prefix ? `${prefix}${separator}${number}` : number + return `${nextBase}${ext || '.jpg'}` +} + +export default function RenamePanel({ options, sampleName, disabled, onChange }: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + const sample = sampleName || 'example.jpg' + + const previews = useMemo( + () => [0, 1, 2].map((index) => previewName(options, sample, index)), + [options, sample] + ) + + return ( +
+ + patch({ renameMode })} + disabled={disabled} + /> + + + {options.renameMode === 'sequence' ? ( + <> + + patch({ prefix: e.target.value })} + disabled={disabled} + /> + + + patch({ startIndex: Number(e.target.value) })} + disabled={disabled} + /> + + + patch({ padLength: Number(e.target.value) })} + disabled={disabled} + /> + + + patch({ separator: e.target.value })} + disabled={disabled} + /> + + + ) : ( + <> + + patch({ findText: e.target.value })} + disabled={disabled} + /> + + + patch({ replaceText: e.target.value })} + disabled={disabled} + /> + + + )} + + +
+
示例:{pathBasename(sample)}
+
    + {previews.map((name, index) => ( +
  • + {index + 1}. {name} +
  • + ))} +
+

原地改名,保留扩展名;目标名已存在则跳过该文件。

+
+
+
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/ResizePanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/ResizePanel.tsx new file mode 100644 index 000000000..40fca640b --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/ResizePanel.tsx @@ -0,0 +1,114 @@ +import { FORMAT_OPTIONS } from '../../config/tools' +import type { ResizeOptions } from '../../types' +import { FieldRow, SegmentedButtons } from '../common/FieldRow' +import OutputSettings from '../common/OutputSettings' + +interface PanelProps { + options: ResizeOptions + disabled?: boolean + onChange: (options: ResizeOptions) => void + onPickOutputDir: () => void +} + +export default function ResizePanel({ options, disabled, onChange, onPickOutputDir }: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + + return ( +
+ + patch({ resizeMode })} + disabled={disabled} + /> + + + {options.resizeMode === 'percent' ? ( + + patch({ scalePercent: Number(e.target.value) })} + disabled={disabled} + /> + % + + ) : ( + <> + + patch({ width: e.target.value })} + disabled={disabled} + /> + + + patch({ height: e.target.value })} + disabled={disabled} + /> + + + )} + + + + + + + + + + + patch({ outputMode })} + onOutputDirChange={(outputDir) => patch({ outputDir })} + onSuffixChange={(suffix) => patch({ suffix })} + onPickOutputDir={onPickOutputDir} + /> +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/RotatePanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/RotatePanel.tsx new file mode 100644 index 000000000..3bc10aab3 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/RotatePanel.tsx @@ -0,0 +1,91 @@ +import { FORMAT_OPTIONS } from '../../config/tools' +import type { RotateOptions } from '../../types' +import { FieldRow } from '../common/FieldRow' +import OutputSettings from '../common/OutputSettings' + +interface PanelProps { + options: RotateOptions + disabled?: boolean + onChange: (options: RotateOptions) => void + onPickOutputDir: () => void +} + +export default function RotatePanel({ options, disabled, onChange, onPickOutputDir }: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + + return ( +
+ + patch({ rotateAngle: Number(e.target.value) })} + disabled={disabled} + /> + ° + + + + {[90, 180, 270, -90].map((angle) => ( + + ))} + + + + + + + + + + + + patch({ outputMode })} + onOutputDirChange={(outputDir) => patch({ outputDir })} + onSuffixChange={(suffix) => patch({ suffix })} + onPickOutputDir={onPickOutputDir} + /> +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/StylePanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/StylePanel.tsx new file mode 100644 index 000000000..df0c2ad59 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/StylePanel.tsx @@ -0,0 +1,103 @@ +import { FORMAT_OPTIONS } from '../../config/tools' +import type { StyleOptions } from '../../types' +import { FieldRow } from '../common/FieldRow' +import OutputSettings from '../common/OutputSettings' + +interface PanelProps { + options: StyleOptions + disabled?: boolean + onChange: (options: StyleOptions) => void + onPickOutputDir: () => void +} + +export default function StylePanel({ options, disabled, onChange, onPickOutputDir }: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + + return ( +
+ + patch({ borderRadius: Number(e.target.value) })} + disabled={disabled} + /> + {options.borderRadius ?? 0}% + + + + patch({ borderWidth: Number(e.target.value) })} + disabled={disabled} + /> + patch({ borderColor: e.target.value })} + disabled={disabled} + /> + + + + patch({ padding: Number(e.target.value) })} + disabled={disabled} + /> + px + + + + patch({ paddingColor: e.target.value })} + disabled={disabled} + /> + patch({ paddingOpacity: Number(e.target.value) })} + disabled={disabled} + /> + {options.paddingOpacity ?? 100}% + + + + + + + patch({ outputMode })} + onOutputDirChange={(outputDir) => patch({ outputDir })} + onSuffixChange={(suffix) => patch({ suffix })} + onPickOutputDir={onPickOutputDir} + /> +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/ToolPanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/ToolPanel.tsx new file mode 100644 index 000000000..be444010e --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/ToolPanel.tsx @@ -0,0 +1,118 @@ +import type { ImageItem, ToolId, ToolOptions } from '../../types' +import CompressPanel from './CompressPanel' +import ConvertPanel from './ConvertPanel' +import CropPanel from './CropPanel' +import MergePanel from './MergePanel' +import RenamePanel from './RenamePanel' +import ResizePanel from './ResizePanel' +import RotatePanel from './RotatePanel' +import StylePanel from './StylePanel' +import WatermarkPanel from './WatermarkPanel' + +interface ToolPanelProps { + activeTool: ToolId + options: ToolOptions + previewImage?: ImageItem | null + disabled?: boolean + onChange: (options: ToolOptions) => void + onPickOutputDir: () => void + onPickWatermarkImage: () => void +} + +export default function ToolPanel({ + activeTool, + options, + previewImage, + disabled, + onChange, + onPickOutputDir, + onPickWatermarkImage +}: ToolPanelProps) { + switch (activeTool) { + case 'compress': + return ( + } + disabled={disabled} + onChange={onChange} + onPickOutputDir={onPickOutputDir} + /> + ) + case 'convert': + return ( + } + disabled={disabled} + onChange={onChange} + onPickOutputDir={onPickOutputDir} + /> + ) + case 'resize': + return ( + } + disabled={disabled} + onChange={onChange} + onPickOutputDir={onPickOutputDir} + /> + ) + case 'rotate': + return ( + } + disabled={disabled} + onChange={onChange} + onPickOutputDir={onPickOutputDir} + /> + ) + case 'crop': + return ( + } + previewImage={previewImage} + disabled={disabled} + onChange={onChange} + onPickOutputDir={onPickOutputDir} + /> + ) + case 'watermark': + return ( + } + disabled={disabled} + onChange={onChange} + onPickOutputDir={onPickOutputDir} + onPickWatermarkImage={onPickWatermarkImage} + /> + ) + case 'style': + return ( + } + disabled={disabled} + onChange={onChange} + onPickOutputDir={onPickOutputDir} + /> + ) + case 'merge': + return ( + } + disabled={disabled} + onChange={onChange} + onPickOutputDir={onPickOutputDir} + /> + ) + case 'rename': + return ( + } + sampleName={previewImage?.name} + disabled={disabled} + onChange={onChange} + /> + ) + default: + return null + } +} diff --git a/plugins/batch-image-tools/src/BatchImage/components/panels/WatermarkPanel.tsx b/plugins/batch-image-tools/src/BatchImage/components/panels/WatermarkPanel.tsx new file mode 100644 index 000000000..fdf380d1e --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/components/panels/WatermarkPanel.tsx @@ -0,0 +1,134 @@ +import { FORMAT_OPTIONS, WATERMARK_POSITIONS } from '../../config/tools' +import type { WatermarkOptions } from '../../types' +import { FieldRow, SegmentedButtons } from '../common/FieldRow' +import OutputSettings from '../common/OutputSettings' + +interface PanelProps { + options: WatermarkOptions + disabled?: boolean + onChange: (options: WatermarkOptions) => void + onPickOutputDir: () => void + onPickWatermarkImage: () => void +} + +export default function WatermarkPanel({ + options, + disabled, + onChange, + onPickOutputDir, + onPickWatermarkImage +}: PanelProps) { + const patch = (partial: Partial) => onChange({ ...options, ...partial }) + + return ( +
+ + patch({ watermarkType })} + disabled={disabled} + /> + + + {options.watermarkType === 'text' ? ( + <> + + patch({ watermarkText: e.target.value })} + disabled={disabled} + /> + + + patch({ watermarkFontSize: Number(e.target.value) })} + disabled={disabled} + /> + + + patch({ watermarkColor: e.target.value })} + disabled={disabled} + /> + + + ) : ( + +
+ + +
+
+ )} + + + patch({ watermarkOpacity: Number(e.target.value) })} + disabled={disabled} + /> + {options.watermarkOpacity ?? 50}% + + + + + + + + + + + patch({ outputMode })} + onOutputDirChange={(outputDir) => patch({ outputDir })} + onSuffixChange={(suffix) => patch({ suffix })} + onPickOutputDir={onPickOutputDir} + /> +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/config/tools.ts b/plugins/batch-image-tools/src/BatchImage/config/tools.ts new file mode 100644 index 000000000..74a5d60e2 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/config/tools.ts @@ -0,0 +1,191 @@ +import type { + CompressOptions, + ConvertOptions, + CropOptions, + MergeOptions, + RenameOptions, + ResizeOptions, + RotateOptions, + StyleOptions, + ToolDefinition, + ToolId, + ToolOptions, + WatermarkOptions +} from '../types' + +export const TOOLS: ToolDefinition[] = [ + { id: 'compress', label: '图片压缩', icon: '压', description: '调节质量或目标体积', minImages: 1, batchMode: 'single' }, + { id: 'convert', label: '格式转换', icon: '转', description: '批量转换输出格式', minImages: 1, batchMode: 'single' }, + { id: 'resize', label: '更改尺寸', icon: '尺', description: '像素或百分比缩放', minImages: 1, batchMode: 'single' }, + { id: 'rotate', label: '旋转翻转', icon: '旋', description: '旋转角度与镜像', minImages: 1, batchMode: 'single' }, + { id: 'crop', label: '裁剪图片', icon: '裁', description: '固定比例居中裁剪', minImages: 1, batchMode: 'single' }, + { id: 'watermark', label: '添加水印', icon: '印', description: '文字或图片水印', minImages: 1, batchMode: 'single' }, + { id: 'style', label: '圆角边框', icon: '饰', description: '圆角、边框与边距', minImages: 1, batchMode: 'single' }, + { id: 'merge', label: '合并图片', icon: '合', description: '长图 / PDF / GIF', minImages: 2, batchMode: 'merge' }, + { id: 'rename', label: '批量重命名', icon: '名', description: '序号模板或查找替换', minImages: 1, batchMode: 'single' } +] + +export const FORMAT_OPTIONS = [ + { value: 'original', label: '保持原格式' }, + { value: 'jpeg', label: 'JPEG' }, + { value: 'png', label: 'PNG' }, + { value: 'webp', label: 'WebP' }, + { value: 'avif', label: 'AVIF' }, + { value: 'tiff', label: 'TIFF' }, + { value: 'gif', label: 'GIF' }, + { value: 'bmp', label: 'BMP' }, + { value: 'ico', label: 'ICO' } +] as const + +export const MERGE_MODE_OPTIONS = [ + { value: 'long-vertical', label: '纵向长图' }, + { value: 'long-horizontal', label: '横向长图' }, + { value: 'pdf', label: '合并 PDF' }, + { value: 'gif-animated', label: '动画 GIF' } +] as const + +export const WATERMARK_POSITIONS = [ + { value: 'northwest', label: '左上' }, + { value: 'north', label: '上' }, + { value: 'northeast', label: '右上' }, + { value: 'west', label: '左' }, + { value: 'center', label: '居中' }, + { value: 'east', label: '右' }, + { value: 'southwest', label: '左下' }, + { value: 'south', label: '下' }, + { value: 'southeast', label: '右下' } +] as const + +export const CROP_PRESETS = [ + { label: '1:1', w: 1, h: 1 }, + { label: '4:3', w: 4, h: 3 }, + { label: '3:4', w: 3, h: 4 }, + { label: '16:9', w: 16, h: 9 }, + { label: '9:16', w: 9, h: 16 } +] as const + +const baseOutput = { + outputMode: 'same-folder' as const, + outputDir: '', + suffix: '_processed' +} + +export function createDefaultOptions(tool: ToolId): ToolOptions { + switch (tool) { + case 'compress': + return { + tool: 'compress', + quality: 80, + targetSizeKb: null, + format: 'original', + ...baseOutput + } satisfies CompressOptions + case 'convert': + return { + tool: 'convert', + format: 'webp', + quality: 90, + ...baseOutput + } satisfies ConvertOptions + case 'resize': + return { + tool: 'resize', + resizeMode: 'pixel', + width: '', + height: '', + scalePercent: 100, + keepAspectRatio: true, + withoutEnlargement: true, + format: 'original', + quality: 90, + ...baseOutput + } satisfies ResizeOptions + case 'rotate': + return { + tool: 'rotate', + rotateAngle: 90, + flipHorizontal: false, + flipVertical: false, + format: 'original', + quality: 90, + ...baseOutput + } satisfies RotateOptions + case 'crop': + return { + tool: 'crop', + cropMode: 'manual', + cropRatioW: 1, + cropRatioH: 1, + cropLeft: 0.1, + cropTop: 0.1, + cropWidth: 0.8, + cropHeight: 0.8, + format: 'original', + quality: 90, + ...baseOutput + } satisfies CropOptions + case 'watermark': + return { + tool: 'watermark', + watermarkType: 'text', + watermarkText: 'Watermark', + watermarkFontSize: 32, + watermarkColor: '#ffffff', + watermarkOpacity: 50, + watermarkPosition: 'southeast', + watermarkScale: 20, + format: 'original', + quality: 90, + ...baseOutput + } satisfies WatermarkOptions + case 'style': + return { + tool: 'style', + borderRadius: 0, + borderWidth: 0, + borderColor: '#333333', + padding: 0, + paddingColor: '#ffffff', + paddingOpacity: 100, + format: 'original', + quality: 90, + ...baseOutput + } satisfies StyleOptions + case 'merge': + return { + tool: 'merge', + mergeMode: 'long-vertical', + gifDelay: 500, + format: 'jpeg', + quality: 90, + ...baseOutput, + suffix: '_merged' + } satisfies MergeOptions + case 'rename': + return { + tool: 'rename', + renameMode: 'sequence', + prefix: 'image', + startIndex: 1, + padLength: 3, + separator: '_', + findText: '', + replaceText: '', + ...baseOutput, + suffix: '' + } satisfies RenameOptions + default: + return createDefaultOptions('compress') + } +} + +export function createDefaultOptionsMap(): Record { + return Object.fromEntries(TOOLS.map((item) => [item.id, createDefaultOptions(item.id)])) as Record< + ToolId, + ToolOptions + > +} + +export function getToolDefinition(tool: ToolId) { + return TOOLS.find((item) => item.id === tool) ?? TOOLS[0] +} diff --git a/plugins/batch-image-tools/src/BatchImage/hooks/useBatchProcess.ts b/plugins/batch-image-tools/src/BatchImage/hooks/useBatchProcess.ts new file mode 100644 index 000000000..3072db0fb --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/hooks/useBatchProcess.ts @@ -0,0 +1,89 @@ +import { useCallback, useState } from 'react' +import type { ProcessResult, ToolOptions } from '../types' +import { getToolDefinition } from '../config/tools' +import { formatBytes } from '../utils' + +export function useBatchProcess() { + const [processing, setProcessing] = useState(false) + const [progress, setProgress] = useState(0) + const [results, setResults] = useState([]) + + const reset = useCallback(() => { + setProcessing(false) + setProgress(0) + setResults([]) + }, []) + + const runProcess = useCallback(async (imagePaths: string[], options: ToolOptions) => { + const tool = getToolDefinition(options.tool) + + if (imagePaths.length < tool.minImages) { + return { ok: false, message: `当前功能至少需要 ${tool.minImages} 张图片` } + } + + if (options.tool !== 'rename' && options.outputMode === 'output-dir' && !options.outputDir) { + return { ok: false, message: '请选择输出目录' } + } + + if (options.tool === 'watermark' && options.watermarkType === 'image' && !options.watermarkImagePath) { + return { ok: false, message: '请选择水印图片' } + } + + if (options.tool === 'rename' && options.renameMode === 'replace' && !options.findText) { + return { ok: false, message: '请填写查找内容' } + } + + setProcessing(true) + setProgress(0) + setResults([]) + + try { + if (tool.batchMode === 'merge') { + const result = await window.services.processMerge(imagePaths, options) + setResults([result]) + setProgress(100) + if (result.success) { + window.ztools.showNotification('合并完成') + } + return { ok: result.success, message: result.error } + } + + const batchResults: ProcessResult[] = [] + for (let i = 0; i < imagePaths.length; i++) { + const result = + options.tool === 'rename' + ? await window.services.renameOne(imagePaths[i], options, i) + : await window.services.processOne(imagePaths[i], options) + batchResults.push(result) + setProgress(Math.round(((i + 1) / imagePaths.length) * 100)) + } + + setResults(batchResults) + const successCount = batchResults.filter((item) => item.success).length + const totalSaved = batchResults.reduce((sum, item) => { + if (!item.success || !item.inputSize || !item.outputSize) return sum + return sum + (item.inputSize - item.outputSize) + }, 0) + + const notify = + options.tool === 'rename' + ? `重命名完成:${successCount}/${batchResults.length} 张成功` + : `处理完成:${successCount}/${batchResults.length} 张成功` + + (totalSaved > 0 ? `,节省 ${formatBytes(totalSaved)}` : '') + + window.ztools.showNotification(notify) + return { ok: successCount > 0, results: batchResults } + } finally { + setProcessing(false) + } + }, []) + + return { + processing, + progress, + results, + setResults, + reset, + runProcess + } +} diff --git a/plugins/batch-image-tools/src/BatchImage/hooks/useImageQueue.ts b/plugins/batch-image-tools/src/BatchImage/hooks/useImageQueue.ts new file mode 100644 index 000000000..12ef54661 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/hooks/useImageQueue.ts @@ -0,0 +1,102 @@ +import { useCallback, useState } from 'react' +import type { EnterAction, ImageItem, ProcessResult } from '../types' +import { pathBasename, resolveEnterPaths } from '../utils' + +export function useImageQueue(enterAction: EnterAction) { + const [images, setImages] = useState([]) + const [recursive, setRecursive] = useState(true) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const loadImages = useCallback( + async (paths: string[]) => { + setError('') + setLoading(true) + try { + if (!window.services?.collectImages) { + throw new Error('图片服务未就绪,请重新导入插件或重启 ZTools') + } + + const collected = window.services.collectImages(paths, recursive) + if (collected.length === 0) { + setImages([]) + setError('未找到可处理的图片文件') + return + } + + const items: ImageItem[] = [] + for (const filePath of collected) { + const info = await window.services.getImageInfo(filePath) + const thumb = await window.services.getThumbnail(filePath) + items.push({ ...info, thumb }) + } + setImages(items) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + } finally { + setLoading(false) + } + }, + [recursive] + ) + + const removeImage = useCallback((path: string) => { + setImages((prev) => prev.filter((item) => item.path !== path)) + }, []) + + const clearImages = useCallback(() => { + setImages([]) + }, []) + + const applyPathUpdates = useCallback((results: ProcessResult[]) => { + const map = new Map() + for (const item of results) { + if (item.success && item.inputPath && item.outputPath && item.inputPath !== item.outputPath) { + map.set(item.inputPath, item.outputPath) + } + } + if (map.size === 0) return + + setImages((prev) => + prev.map((image) => { + const nextPath = map.get(image.path) + if (!nextPath) return image + return { + ...image, + path: nextPath, + name: pathBasename(nextPath) + } + }) + ) + }, []) + + const reset = useCallback(() => { + setImages([]) + setRecursive(true) + setError('') + setLoading(false) + }, []) + + const handleEnterAction = useCallback(() => { + const paths = resolveEnterPaths(enterAction) + if (paths.length > 0) { + loadImages(paths) + } + }, [enterAction, loadImages]) + + return { + images, + recursive, + setRecursive, + error, + setError, + loading, + loadImages, + removeImage, + clearImages, + applyPathUpdates, + reset, + handleEnterAction + } +} diff --git a/plugins/batch-image-tools/src/BatchImage/index.css b/plugins/batch-image-tools/src/BatchImage/index.css new file mode 100644 index 000000000..6f439972e --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/index.css @@ -0,0 +1,965 @@ +.batch-app { + --layui-primary: #1e9fff; + --layui-primary-dark: #0d8aee; + --layui-success: #16b777; + --layui-warning: #ffb800; + --layui-danger: #ff5722; + --layui-bg: #f2f2f2; + --layui-card: #ffffff; + --layui-border: #e6e6e6; + --layui-text: #333333; + --layui-text-muted: #999999; + --layui-sidebar-active: rgba(30, 159, 255, 0.1); + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 12px; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06); + + display: flex; + flex-direction: column; + height: 100%; + max-height: 100%; + overflow: hidden; + background: var(--layui-bg); + color: var(--layui-text); +} + +.batch-app__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 18px; + background: var(--layui-card); + border-bottom: 1px solid var(--layui-border); + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} + +.batch-app__header-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.batch-app__reset { + white-space: nowrap; +} + +.batch-app__brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.batch-app__logo { + width: 36px; + height: 36px; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--layui-primary), #4db3ff); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 700; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(30, 159, 255, 0.35); +} + +.batch-app__header h1 { + margin: 0; + font-size: 17px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.batch-app__header p { + margin: 2px 0 0; + font-size: 12px; + color: var(--layui-text-muted); +} + +.batch-app__stat { + display: flex; + align-items: baseline; + gap: 4px; + padding: 6px 14px; + border-radius: 999px; + background: var(--layui-sidebar-active); + border: 1px solid rgba(30, 159, 255, 0.15); + flex-shrink: 0; +} + +.batch-app__stat-count { + font-size: 16px; + font-weight: 700; + color: var(--layui-primary); +} + +.batch-app__stat-label { + font-size: 12px; + color: var(--layui-primary); + opacity: 0.85; +} + +.batch-app__body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.sidebar { + width: 148px; + flex-shrink: 0; + background: var(--layui-card); + border-right: 1px solid var(--layui-border); + padding: 10px 8px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar__title { + font-size: 11px; + font-weight: 600; + color: var(--layui-text-muted); + padding: 4px 10px 8px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.sidebar__nav { + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; + flex: 1; +} + +.sidebar__item { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + text-align: left; + line-height: 1.35; + transition: background 0.15s, color 0.15s; +} + +.sidebar__item:hover:not(:disabled):not(.is-active) { + background: rgba(0, 0, 0, 0.03); +} + +.sidebar__item.is-active { + background: var(--layui-sidebar-active); + color: var(--layui-primary); + font-weight: 500; +} + +.sidebar__item.is-active::before { + content: ''; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 3px; + border-radius: 0 3px 3px 0; + background: var(--layui-primary); +} + +.sidebar__icon { + width: 24px; + height: 24px; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + background: rgba(30, 159, 255, 0.1); + color: var(--layui-primary); + flex-shrink: 0; +} + +.sidebar__item.is-active .sidebar__icon { + background: var(--layui-primary); + color: #fff; +} + +.sidebar__label { + font-size: 12px; +} + +.batch-app__main { + flex: 1; + min-width: 0; + min-height: 0; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; + overflow: auto; +} + +.settings-card, +.list-card, +.result-card { + background: var(--layui-card); + border: 1px solid var(--layui-border); + border-radius: var(--radius-md); + padding: 12px 14px; + box-shadow: var(--shadow-sm); + flex-shrink: 0; +} + +.list-card { + flex: 1; + min-height: 160px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--layui-border); +} + +.card-head__title { + font-size: 14px; + font-weight: 600; +} + +.card-head__badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: #f5f5f5; + color: var(--layui-text-muted); +} + +.tool-panel { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 16px; +} + +.tool-panel--crop { + grid-template-columns: 1fr; +} + +.crop-editor { + width: 100%; +} + +.crop-editor--empty { + padding: 28px 16px; + text-align: center; + color: var(--layui-text-muted); + border: 1px dashed var(--layui-border); + border-radius: var(--radius-sm); + background: #fafafa; + font-size: 12px; +} + +.crop-editor__stage { + position: relative; + width: 100%; + max-height: 280px; + aspect-ratio: 4 / 3; + margin: 0 auto; + background: #1a1a1a; + border-radius: var(--radius-sm); + overflow: hidden; + user-select: none; + touch-action: none; +} + +.crop-editor__stage img { + width: 100%; + height: 100%; + object-fit: fill; + display: block; + pointer-events: none; +} + +.crop-editor__loading { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 12px; + background: rgba(0, 0, 0, 0.35); + z-index: 2; +} + +.crop-editor__box { + position: absolute; + border: 2px solid #fff; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45); + cursor: move; + z-index: 3; +} + +.crop-editor__grid { + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.35) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.35) 1px, transparent 1px); + background-size: 33.333% 33.333%; +} + +.crop-editor__handle { + position: absolute; + width: 10px; + height: 10px; + background: #fff; + border: 1px solid var(--layui-primary); + border-radius: 2px; + z-index: 4; +} + +.crop-editor__handle--nw { left: -5px; top: -5px; cursor: nwse-resize; } +.crop-editor__handle--ne { right: -5px; top: -5px; cursor: nesw-resize; } +.crop-editor__handle--sw { left: -5px; bottom: -5px; cursor: nesw-resize; } +.crop-editor__handle--se { right: -5px; bottom: -5px; cursor: nwse-resize; } +.crop-editor__handle--n { left: 50%; top: -5px; transform: translateX(-50%); cursor: ns-resize; } +.crop-editor__handle--s { left: 50%; bottom: -5px; transform: translateX(-50%); cursor: ns-resize; } +.crop-editor__handle--w { left: -5px; top: 50%; transform: translateY(-50%); cursor: ew-resize; } +.crop-editor__handle--e { right: -5px; top: 50%; transform: translateY(-50%); cursor: ew-resize; } + +.crop-editor__meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + margin-top: 8px; + font-size: 12px; + color: var(--layui-text-muted); +} + +.crop-editor__meta span { + opacity: 0.8; +} + +.field-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.field-row--wide { + grid-column: 1 / -1; +} + +.field-row label { + min-width: 72px; + flex-shrink: 0; + color: var(--layui-text-muted); + font-size: 12px; +} + +.field-row__control { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.field-row input[type='number'], +.field-row input[type='text'], +.field-row select { + flex: 1; + min-width: 0; + padding: 7px 10px; + border: 1px solid var(--layui-border); + border-radius: var(--radius-sm); + background: #fff; + color: inherit; + font-size: 13px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.field-row input:focus, +.field-row select:focus { + outline: none; + border-color: var(--layui-primary); + box-shadow: 0 0 0 2px rgba(30, 159, 255, 0.15); +} + +.field-row input[type='range'] { + flex: 1; + accent-color: var(--layui-primary); +} + +.value-tag { + min-width: 40px; + font-size: 12px; + font-weight: 500; + color: var(--layui-primary); + text-align: right; +} + +.inline-check { + display: inline-flex; + align-items: center; + gap: 5px; + margin-right: 12px; + font-size: 12px; + cursor: pointer; +} + +.inline-check input { + accent-color: var(--layui-primary); +} + +.segmented { + display: inline-flex; + border: 1px solid var(--layui-border); + border-radius: var(--radius-sm); + overflow: hidden; + background: #fafafa; +} + +.segmented button { + padding: 0 14px; + line-height: 2.1; + background: transparent; + color: inherit; + font-size: 12px; + transition: background 0.15s, color 0.15s; +} + +.segmented button.is-active { + background: var(--layui-primary); + color: #fff; +} + +.chip-group { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chip-btn { + padding: 0 12px; + line-height: 2; + border-radius: 999px; + background: #fff; + color: inherit; + border: 1px solid var(--layui-border); + font-size: 12px; + transition: all 0.15s; +} + +.chip-btn:hover:not(:disabled):not(.is-active) { + border-color: var(--layui-primary); + color: var(--layui-primary); +} + +.chip-btn.is-active { + border-color: var(--layui-primary); + color: #fff; + background: var(--layui-primary); +} + +.dir-picker { + display: flex; + gap: 8px; + width: 100%; +} + +.dir-picker input { + flex: 1; +} + +.dir-picker .btn, +.dir-picker button { + padding: 0 12px; + line-height: 2.2; + border-radius: var(--radius-sm); + background: var(--layui-primary); + color: #fff; + font-size: 12px; + white-space: nowrap; +} + +.file-table { + border: 1px solid var(--layui-border); + border-radius: var(--radius-sm); + overflow: hidden; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.file-table--empty { + padding: 36px 20px; + text-align: center; + color: var(--layui-text-muted); + background: linear-gradient(180deg, #fafafa, #fff); + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.file-table__empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} + +.file-table--empty p { + margin: 0 0 6px; + color: var(--layui-text); + font-weight: 500; +} + +.file-table--empty span { + font-size: 12px; +} + +.file-table__head, +.file-table__body li { + display: grid; + grid-template-columns: minmax(0, 2fr) 88px 72px 52px; + gap: 8px; + align-items: center; + padding: 8px 12px; + font-size: 12px; +} + +.file-table__head { + background: #fafafa; + color: var(--layui-text-muted); + border-bottom: 1px solid var(--layui-border); + font-weight: 500; +} + +.file-table__head span:last-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; +} + +.file-table__body { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + min-height: 0; + overflow: auto; +} + +.file-table__body li { + transition: background 0.12s; +} + +.file-table__body li:hover { + background: rgba(30, 159, 255, 0.04); +} + +.file-table__body li + li { + border-top: 1px solid var(--layui-border); +} + +.file-table__name { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.file-table__name img, +.file-table__thumb-placeholder { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--layui-border); +} + +.file-table__thumb-placeholder { + background: #f0f0f0; +} + +.file-table__meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.file-table__filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.file-table__format { + font-size: 10px; + color: var(--layui-text-muted); + letter-spacing: 0.04em; +} + +.file-table__dim, +.file-table__size { + color: var(--layui-text-muted); + font-variant-numeric: tabular-nums; +} + +.action-bar { + background: var(--layui-card); + border-top: 1px solid var(--layui-border); + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.04); + flex-shrink: 0; +} + +.action-bar__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; +} + +.action-bar__left { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.action-bar__check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--layui-text-muted); + cursor: pointer; + padding-left: 4px; +} + +.action-bar__check input { + accent-color: var(--layui-primary); +} + +.action-bar__progress { + height: 3px; + background: rgba(30, 159, 255, 0.12); +} + +.action-bar__progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--layui-primary), #4db3ff); + transition: width 0.25s ease; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 16px; + line-height: 2.3; + border-radius: var(--radius-sm); + font-size: 13px; + transition: background 0.15s, border-color 0.15s, transform 0.1s; +} + +.btn__icon { + font-size: 14px; + opacity: 0.85; +} + +.btn--ghost { + background: #fff; + color: var(--layui-text); + border: 1px solid var(--layui-border); +} + +.btn--ghost:hover:not(:disabled) { + border-color: var(--layui-primary); + color: var(--layui-primary); +} + +.btn--primary { + background: var(--layui-primary); + color: #fff; + font-weight: 600; + min-width: 140px; + justify-content: center; + box-shadow: 0 2px 8px rgba(30, 159, 255, 0.3); +} + +.btn--primary:hover:not(:disabled) { + background: var(--layui-primary-dark); +} + +.btn:not(:disabled):active { + transform: scale(0.98); +} + +.link-btn { + background: transparent; + color: var(--layui-primary); + line-height: 1.8; + padding: 0; + font-size: 12px; +} + +.link-btn--inline { + font-size: 11px; + opacity: 0.8; +} + +.link-btn--danger { + color: var(--layui-danger); +} + +.link-btn--danger:hover:not(:disabled) { + opacity: 0.8; +} + +.result-card { + border-color: rgba(22, 183, 119, 0.25); + background: linear-gradient(180deg, rgba(22, 183, 119, 0.06), var(--layui-card)); +} + +.result-card__head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 13px; + margin-bottom: 8px; + font-weight: 500; + color: var(--layui-success); +} + +.result-card__actions { + display: flex; + align-items: center; + gap: 12px; +} + +.result-card__close { + color: var(--layui-text-muted); +} + +.result-card__close:hover { + color: var(--layui-text); +} + +.result-card ul { + margin: 0; + padding: 0; + list-style: none; + font-size: 12px; +} + +.result-card li { + padding: 4px 0; +} + +.result-card li.ok { + color: var(--layui-success); +} + +.result-card li.fail { + color: var(--layui-danger); +} + +.batch-app__error { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: rgba(255, 87, 34, 0.08); + border: 1px solid rgba(255, 87, 34, 0.2); + color: var(--layui-danger); + font-size: 13px; +} + +.batch-app__error-text { + flex: 1; + min-width: 0; +} + +.batch-app__error-close { + color: var(--layui-danger); + flex-shrink: 0; +} + +.batch-app__error span { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--layui-danger); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +.rename-preview { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: #f7f9fb; + border: 1px dashed var(--layui-border); + font-size: 12px; + color: var(--layui-text); +} + +.rename-preview__sample { + color: var(--layui-text-muted); + margin-bottom: 6px; +} + +.rename-preview ul { + margin: 0; + padding-left: 18px; +} + +.rename-preview__hint { + margin: 8px 0 0; + color: var(--layui-text-muted); + font-size: 11px; +} + +.list-card__loading { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--layui-text-muted); + margin-bottom: 10px; +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid var(--layui-border); + border-top-color: var(--layui-primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-color-scheme: dark) { + .batch-app { + --layui-bg: #1a1a1a; + --layui-card: #262626; + --layui-border: #3a3a3a; + --layui-text: #ececec; + --layui-text-muted: #aaa; + --layui-sidebar-active: rgba(30, 159, 255, 0.15); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3); + } + + .batch-app__logo { + box-shadow: 0 2px 8px rgba(30, 159, 255, 0.2); + } + + .card-head__badge { + background: #333; + } + + .field-row input[type='number'], + .field-row input[type='text'], + .field-row select, + .segmented, + .chip-btn, + .btn--ghost { + background: #333; + border-color: #444; + color: #ececec; + } + + .segmented button { + background: transparent; + } + + .file-table__head, + .file-table--empty { + background: #2a2a2a; + } + + .file-table__body li:hover { + background: rgba(30, 159, 255, 0.08); + } + + .file-table__thumb-placeholder { + background: #333; + } + + .sidebar__item:hover:not(:disabled):not(.is-active) { + background: rgba(255, 255, 255, 0.04); + } +} + +@media (max-width: 720px) { + .batch-app__body { + flex-direction: column; + } + + .sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--layui-border); + max-height: 120px; + } + + .sidebar__nav { + flex-direction: row; + flex-wrap: wrap; + } + + .tool-panel { + grid-template-columns: 1fr; + } + + .action-bar__inner { + flex-direction: column; + align-items: stretch; + } + + .btn--primary { + width: 100%; + } +} diff --git a/plugins/batch-image-tools/src/BatchImage/index.tsx b/plugins/batch-image-tools/src/BatchImage/index.tsx new file mode 100644 index 000000000..57ad231db --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/index.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { createDefaultOptionsMap, getToolDefinition } from './config/tools' +import ActionBar from './components/layout/ActionBar' +import ErrorBanner from './components/layout/ErrorBanner' +import FileTable from './components/layout/FileTable' +import ResultBanner from './components/layout/ResultBanner' +import Sidebar from './components/layout/Sidebar' +import ToolPanel from './components/panels/ToolPanel' +import { useBatchProcess } from './hooks/useBatchProcess' +import { useImageQueue } from './hooks/useImageQueue' +import type { EnterAction, ToolId, ToolOptions } from './types' +import { formatBytes } from './utils' +import { pickDirectory, pickImageFiles, pickImageFolder, pickWatermarkImage } from './utils/filePicker' +import './index.css' + +export type { EnterAction } from './types' + +export default function BatchImage({ enterAction }: { enterAction: EnterAction }) { + const [activeTool, setActiveTool] = useState('compress') + const [optionsMap, setOptionsMap] = useState>(createDefaultOptionsMap) + + const { + images, + recursive, + setRecursive, + error, + setError, + loading, + loadImages, + removeImage, + clearImages, + applyPathUpdates, + reset: resetQueue, + handleEnterAction + } = useImageQueue(enterAction) + + const { processing, progress, results, setResults, reset: resetProcess, runProcess } = useBatchProcess() + const activeOptions = optionsMap[activeTool] + const toolDef = getToolDefinition(activeTool) + + useEffect(() => { + handleEnterAction() + }, [handleEnterAction]) + + const updateOptions = useCallback((next: ToolOptions) => { + setOptionsMap((prev) => ({ ...prev, [next.tool]: next })) + }, []) + + const handleReset = useCallback(() => { + resetQueue() + resetProcess() + setOptionsMap(createDefaultOptionsMap()) + setActiveTool('compress') + }, [resetProcess, resetQueue]) + + const pickOutputDir = useCallback(async () => { + const dir = await pickDirectory('选择输出目录') + if (!dir) return + updateOptions({ ...activeOptions, outputDir: dir }) + }, [activeOptions, updateOptions]) + + const handlePickWatermarkImage = useCallback(async () => { + const filePath = await pickWatermarkImage() + if (!filePath) return + if (activeOptions.tool === 'watermark') { + updateOptions({ ...activeOptions, watermarkImagePath: filePath }) + } + }, [activeOptions, updateOptions]) + + const handlePickFiles = async () => { + const files = await pickImageFiles() + if (!files) return + loadImages(files) + } + + const handlePickFolder = async () => { + const paths = await pickImageFolder() + if (!paths) return + loadImages(paths) + } + + const handleProcess = async () => { + setError('') + const result = await runProcess( + images.map((item) => item.path), + activeOptions + ) + if (result && 'message' in result && result.message) { + setError(result.message) + } + if (activeOptions.tool === 'rename' && result && 'results' in result && Array.isArray(result.results)) { + applyPathUpdates(result.results) + } + } + + const handleOpenLastOutput = () => { + const last = results.find((item) => item.success && item.outputPath) + if (last?.outputPath) { + window.ztools.shellShowItemInFolder(last.outputPath) + } + } + + const totalInputSize = useMemo( + () => images.reduce((sum, item) => sum + item.size, 0), + [images] + ) + + return ( +
+
+
+
+
+

批量图片处理

+

{toolDef.description}

+
+
+
+ {images.length > 0 && ( +
+ {images.length} + 张 · {formatBytes(totalInputSize)} +
+ )} + +
+
+ +
+ + +
+
+
+
{toolDef.label}
+ 参数设置 +
+ +
+ +
+
+
文件队列
+ {images.length > 0 && {images.length} 项} +
+ {loading ?
加载中...
: null} + +
+ + setResults([])} + /> + + setError('')} /> +
+
+ + +
+ ) +} diff --git a/plugins/batch-image-tools/src/BatchImage/types.ts b/plugins/batch-image-tools/src/BatchImage/types.ts new file mode 100644 index 000000000..bf7d0b0a1 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/types.ts @@ -0,0 +1,175 @@ +export type ToolId = + | 'compress' + | 'convert' + | 'resize' + | 'rotate' + | 'crop' + | 'watermark' + | 'style' + | 'merge' + | 'rename' + +export type RenameMode = 'sequence' | 'replace' + +export type OutputMode = 'same-folder' | 'overwrite' | 'output-dir' + +export type ImageFormat = + | 'original' + | 'jpeg' + | 'jpg' + | 'png' + | 'webp' + | 'avif' + | 'tiff' + | 'gif' + | 'bmp' + | 'ico' + +export type MergeMode = 'long-vertical' | 'long-horizontal' | 'pdf' | 'gif-animated' + +export interface EnterAction { + code?: string + type?: string + payload?: string | Array<{ path: string }> +} + +export interface ImageItem { + path: string + name: string + size: number + width: number + height: number + format: string + thumb?: string +} + +export interface BaseProcessOptions { + tool: ToolId + outputMode: OutputMode + outputDir?: string + suffix?: string +} + +export interface CompressOptions extends BaseProcessOptions { + tool: 'compress' + quality: number + targetSizeKb?: number | null + format: ImageFormat +} + +export interface ConvertOptions extends BaseProcessOptions { + tool: 'convert' + format: ImageFormat + quality: number +} + +export interface ResizeOptions extends BaseProcessOptions { + tool: 'resize' + resizeMode: 'pixel' | 'percent' + width?: string + height?: string + scalePercent?: number + keepAspectRatio: boolean + withoutEnlargement: boolean + format: ImageFormat + quality: number +} + +export interface RotateOptions extends BaseProcessOptions { + tool: 'rotate' + rotateAngle: number + flipHorizontal: boolean + flipVertical: boolean + format: ImageFormat + quality: number +} + +export interface CropOptions extends BaseProcessOptions { + tool: 'crop' + cropMode: 'ratio' | 'manual' + cropRatioW: number + cropRatioH: number + /** 相对原图的裁剪框,取值 0–1 */ + cropLeft: number + cropTop: number + cropWidth: number + cropHeight: number + format: ImageFormat + quality: number +} + +export interface WatermarkOptions extends BaseProcessOptions { + tool: 'watermark' + watermarkType: 'none' | 'text' | 'image' + watermarkText?: string + watermarkImagePath?: string + watermarkFontSize?: number + watermarkColor?: string + watermarkOpacity?: number + watermarkPosition?: string + watermarkScale?: number + format: ImageFormat + quality: number +} + +export interface StyleOptions extends BaseProcessOptions { + tool: 'style' + borderRadius?: number + borderWidth?: number + borderColor?: string + padding?: number + paddingColor?: string + paddingOpacity?: number + format: ImageFormat + quality: number +} + +export interface MergeOptions extends BaseProcessOptions { + tool: 'merge' + mergeMode: MergeMode + gifDelay?: number + format: ImageFormat + quality: number +} + +/** 原地重命名;outputMode/suffix 仅占位以兼容统一处理入口 */ +export interface RenameOptions extends BaseProcessOptions { + tool: 'rename' + renameMode: RenameMode + prefix: string + startIndex: number + padLength: number + separator: string + findText: string + replaceText: string +} + +export type ToolOptions = + | CompressOptions + | ConvertOptions + | ResizeOptions + | RotateOptions + | CropOptions + | WatermarkOptions + | StyleOptions + | MergeOptions + | RenameOptions + +export interface ProcessResult { + inputPath?: string + inputPaths?: string[] + outputPath?: string + success: boolean + inputSize?: number + outputSize?: number + error?: string +} + +export interface ToolDefinition { + id: ToolId + label: string + icon: string + description: string + minImages: number + batchMode: 'single' | 'merge' +} diff --git a/plugins/batch-image-tools/src/BatchImage/utils.ts b/plugins/batch-image-tools/src/BatchImage/utils.ts new file mode 100644 index 000000000..267b59e43 --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/utils.ts @@ -0,0 +1,29 @@ +export function formatBytes(bytes: number) { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(2) + ' MB' +} + +export function savingsPercent(input: number, output: number) { + if (!input) return 0 + return Math.round(((input - output) / input) * 100) +} + +export function pathBasename(filePath: string) { + const parts = filePath.split(/[/\\]/) + return parts[parts.length - 1] || filePath +} + +export function resolveEnterPaths(action: { + type?: string + payload?: string | Array<{ path: string }> +}) { + if (action.type === 'files' && Array.isArray(action.payload)) { + return action.payload.map((item) => item.path) + } + if (action.type === 'img' && typeof action.payload === 'string') { + const saved = window.services.saveBase64Image(action.payload) + return [saved] + } + return [] +} diff --git a/plugins/batch-image-tools/src/BatchImage/utils/filePicker.ts b/plugins/batch-image-tools/src/BatchImage/utils/filePicker.ts new file mode 100644 index 000000000..b9fbf7d3f --- /dev/null +++ b/plugins/batch-image-tools/src/BatchImage/utils/filePicker.ts @@ -0,0 +1,163 @@ +const IMAGE_EXTENSIONS = [ + 'jpg', + 'jpeg', + 'png', + 'webp', + 'gif', + 'bmp', + 'tiff', + 'avif', + 'svg', + 'ico' +] + +type OpenDialogOptions = Parameters[0] + +function isRealZTools() { + return typeof window.ztools?.isDev === 'function' +} + +async function resolveDialogResult( + result: string[] | undefined | Promise +): Promise { + const paths = await Promise.resolve(result) + if (!paths || paths.length === 0) return null + return paths +} + +async function pickWithNativeDialog(options: OpenDialogOptions): Promise { + const ztools = window.ztools + if (!ztools?.showOpenDialog || !isRealZTools()) return null + return resolveDialogResult(ztools.showOpenDialog(options)) +} + +function pathsFromFileList(files: FileList | null): string[] { + if (!files || files.length === 0) return [] + + const ztools = window.ztools + if (!ztools?.getPathForFile) return [] + + const paths: string[] = [] + for (const file of files) { + try { + const filePath = ztools.getPathForFile(file) + if (filePath) paths.push(filePath) + } catch { + // ignore invalid file entries + } + } + return paths +} + +function pickWithFileInput(mode: 'files' | 'directory'): Promise { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + input.style.display = 'none' + + if (mode === 'files') { + input.multiple = true + input.accept = 'image/*' + } else { + input.webkitdirectory = true + } + + const cleanup = () => { + input.remove() + } + + input.addEventListener('change', () => { + const paths = pathsFromFileList(input.files) + cleanup() + resolve(paths.length > 0 ? paths : null) + }) + + document.body.appendChild(input) + input.click() + }) +} + +function notifyPickUnavailable() { + const ztools = window.ztools + const message = '请在 ZTools 中打开插件以选择本地图片' + + if (ztools?.showNotification && isRealZTools()) { + ztools.showNotification(message) + return + } + + if (import.meta.env.DEV) { + console.warn(message) + } +} + +export async function pickImageFiles(): Promise { + const fromDialog = await pickWithNativeDialog({ + title: '选择图片', + properties: ['openFile', 'multiSelections'], + filters: [{ name: '图片', extensions: IMAGE_EXTENSIONS }] + }) + if (fromDialog) return fromDialog + + if (isRealZTools()) { + const fromInput = await pickWithFileInput('files') + if (fromInput) return fromInput + } + + notifyPickUnavailable() + return null +} + +export async function pickImageFolder(): Promise { + const fromDialog = await pickWithNativeDialog({ + title: '选择文件夹', + properties: ['openDirectory'] + }) + if (fromDialog) return fromDialog + + if (isRealZTools()) { + const fromInput = await pickWithFileInput('directory') + if (fromInput) return fromInput + } + + notifyPickUnavailable() + return null +} + +export async function pickDirectory(title = '选择目录'): Promise { + const fromDialog = await pickWithNativeDialog({ + title, + properties: ['openDirectory'] + }) + if (fromDialog?.[0]) return fromDialog[0] + + if (isRealZTools()) { + const fromInput = await pickWithFileInput('directory') + if (fromInput?.[0]) { + const first = fromInput[0] + const sep = first.includes('\\') ? '\\' : '/' + const dir = first.slice(0, first.lastIndexOf(sep)) + return dir || null + } + } + + notifyPickUnavailable() + return null +} + +export async function pickWatermarkImage(): Promise { + const fromDialog = await pickWithNativeDialog({ + title: '选择水印图片', + properties: ['openFile'], + filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'webp'] }] + }) + if (fromDialog?.[0]) return fromDialog[0] + + if (isRealZTools()) { + const fromInput = await pickWithFileInput('files') + if (fromInput?.[0]) return fromInput[0] + } + + notifyPickUnavailable() + return null +} diff --git a/plugins/batch-image-tools/src/dev-mock.ts b/plugins/batch-image-tools/src/dev-mock.ts new file mode 100644 index 000000000..3a3d68acb --- /dev/null +++ b/plugins/batch-image-tools/src/dev-mock.ts @@ -0,0 +1,60 @@ +/** + * 在浏览器直接打开 Vite 开发服务器时,注入 ZTools API 桩以便预览 UI。 + * 须延迟安装,避免在 ZTools 注入真实 API 之前覆盖 window.ztools。 + */ +export function setupDevMock() { + if (!import.meta.env.DEV) return + + const install = () => { + if (typeof window.ztools?.isDev === 'function') return + + if (!window.ztools) { + const noop = () => {} + window.ztools = { + onPluginEnter: noop, + onPluginOut: noop, + setExpendHeight: noop, + showOpenDialog: () => undefined, + showNotification: (msg: string) => console.info('[dev notification]', msg), + shellShowItemInFolder: noop, + } as unknown as typeof ztools + } + + if (!window.services) { + window.services = { + isImageFile: () => false, + collectImages: () => [], + saveBase64Image: () => '', + getImageInfo: async () => ({ + path: '', + name: '', + size: 0, + width: 0, + height: 0, + format: '', + }), + getThumbnail: async () => '', + processOne: async (inputPath) => ({ + inputPath, + success: false, + error: '请在 ZTools 中打开插件以使用图片处理功能', + }), + processMerge: async () => ({ + success: false, + error: '请在 ZTools 中打开插件以使用图片处理功能', + }), + renameOne: async (inputPath) => ({ + inputPath, + success: false, + error: '请在 ZTools 中打开插件以使用图片处理功能', + }), + } + } + } + + if (typeof window.ztools?.isDev === 'function') return + + requestAnimationFrame(() => { + setTimeout(install, 50) + }) +} diff --git a/plugins/batch-image-tools/src/env.d.ts b/plugins/batch-image-tools/src/env.d.ts new file mode 100644 index 000000000..4155203f5 --- /dev/null +++ b/plugins/batch-image-tools/src/env.d.ts @@ -0,0 +1,27 @@ +/// +/// + +import type { ToolOptions, ProcessResult, ImageItem, RenameOptions } from './BatchImage/types' + +interface Services { + isImageFile: (filePath: string) => boolean + collectImages: (paths: string[], recursive?: boolean) => string[] + saveBase64Image: (base64Url: string) => string + getImageInfo: (filePath: string) => Promise + getThumbnail: (filePath: string, size?: number) => Promise + processOne: (inputPath: string, options: ToolOptions) => Promise + processMerge: (inputPaths: string[], options: ToolOptions) => Promise + renameOne: (inputPath: string, options: RenameOptions, index?: number) => Promise +} + +declare global { + interface ZToolsApi { + getPathForFile(file: File): string + } + + interface Window { + services: Services + } +} + +export {} diff --git a/plugins/batch-image-tools/src/main.css b/plugins/batch-image-tools/src/main.css new file mode 100644 index 000000000..f08036e3c --- /dev/null +++ b/plugins/batch-image-tools/src/main.css @@ -0,0 +1,60 @@ +:root { + --layui-primary: #1e9fff; + --layui-success: #16b777; + --layui-warning: #ffb800; + --layui-danger: #ff5722; + --layui-bg: #f2f2f2; + --layui-card: #ffffff; + --layui-border: #e6e6e6; + --layui-text: #333333; + --layui-text-muted: #999999; +} + +html, +body, +#root { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + background: var(--layui-bg); + color: var(--layui-text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +button { + border: none; + cursor: pointer; + transition: opacity 0.2s, background 0.2s; +} + +button:disabled { + filter: grayscale(0.2); + opacity: 0.6; + cursor: not-allowed; +} + +button:not(:disabled):active { + opacity: 0.75; +} + +.dev-hint { + padding: 8px 14px; + font-size: 12px; + color: #8a6d00; + background: rgba(255, 184, 0, 0.12); + border-bottom: 1px solid rgba(255, 184, 0, 0.25); + text-align: center; +} + +@media (prefers-color-scheme: dark) { + body { + background: #1f1f1f; + color: #ececec; + } + + .dev-hint { + color: #ccc; + background: rgba(255, 184, 0, 0.1); + } +} diff --git a/plugins/batch-image-tools/src/main.tsx b/plugins/batch-image-tools/src/main.tsx new file mode 100644 index 000000000..e7e3712e3 --- /dev/null +++ b/plugins/batch-image-tools/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { setupDevMock } from './dev-mock' +import './main.css' +import App from './App' + +setupDevMock() + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/plugins/batch-image-tools/static/images/feature-compress.png b/plugins/batch-image-tools/static/images/feature-compress.png new file mode 100644 index 000000000..b749e0bbe Binary files /dev/null and b/plugins/batch-image-tools/static/images/feature-compress.png differ diff --git a/plugins/batch-image-tools/static/images/feature-convert.png b/plugins/batch-image-tools/static/images/feature-convert.png new file mode 100644 index 000000000..246d5d7b9 Binary files /dev/null and b/plugins/batch-image-tools/static/images/feature-convert.png differ diff --git a/plugins/batch-image-tools/static/images/feature-crop.png b/plugins/batch-image-tools/static/images/feature-crop.png new file mode 100644 index 000000000..ab620e5ea Binary files /dev/null and b/plugins/batch-image-tools/static/images/feature-crop.png differ diff --git a/plugins/batch-image-tools/static/images/feature-merge.png b/plugins/batch-image-tools/static/images/feature-merge.png new file mode 100644 index 000000000..ada6b9525 Binary files /dev/null and b/plugins/batch-image-tools/static/images/feature-merge.png differ diff --git a/plugins/batch-image-tools/static/images/feature-resize.png b/plugins/batch-image-tools/static/images/feature-resize.png new file mode 100644 index 000000000..e783302b8 Binary files /dev/null and b/plugins/batch-image-tools/static/images/feature-resize.png differ diff --git a/plugins/batch-image-tools/static/images/feature-rotate.png b/plugins/batch-image-tools/static/images/feature-rotate.png new file mode 100644 index 000000000..9da3b39d3 Binary files /dev/null and b/plugins/batch-image-tools/static/images/feature-rotate.png differ diff --git a/plugins/batch-image-tools/static/images/feature-style.png b/plugins/batch-image-tools/static/images/feature-style.png new file mode 100644 index 000000000..5563a2719 Binary files /dev/null and b/plugins/batch-image-tools/static/images/feature-style.png differ diff --git a/plugins/batch-image-tools/static/images/feature-watermark.png b/plugins/batch-image-tools/static/images/feature-watermark.png new file mode 100644 index 000000000..2476cbd91 Binary files /dev/null and b/plugins/batch-image-tools/static/images/feature-watermark.png differ diff --git a/plugins/batch-image-tools/static/images/overview.png b/plugins/batch-image-tools/static/images/overview.png new file mode 100644 index 000000000..b749e0bbe Binary files /dev/null and b/plugins/batch-image-tools/static/images/overview.png differ diff --git a/plugins/batch-image-tools/tsconfig.json b/plugins/batch-image-tools/tsconfig.json new file mode 100644 index 000000000..d49c64dec --- /dev/null +++ b/plugins/batch-image-tools/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": false, + "noImplicitAny": false, + "types": ["@ztools-center/ztools-api-types"] + }, + "include": ["src"] +} diff --git a/plugins/batch-image-tools/vite.config.js b/plugins/batch-image-tools/vite.config.js new file mode 100644 index 000000000..34849e998 --- /dev/null +++ b/plugins/batch-image-tools/vite.config.js @@ -0,0 +1,22 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import { copyPublicAssets } from './scripts/copy-public.mjs' + +function copyPluginPublicAssets() { + return { + name: 'copy-plugin-public-assets', + closeBundle() { + copyPublicAssets('public', 'dist') + } + } +} + +export default defineConfig({ + plugins: [react(), copyPluginPublicAssets()], + build: { + outDir: 'dist', + copyPublicDir: false, + emptyOutDir: true + }, + base: './' +})