commit 8fb110adbdb18db88748eda71c1cae8093571e8c Author: macro Date: Sat Apr 25 00:01:40 2026 +0800 Initial commit: Add cutPic, dexcel, and zip tools diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b35a9c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Rust +target/ +**/target/ +debug/ +release/ +*.pdb + +# Cargo +Cargo.lock + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Skill files (optional, uncomment if you don't want to track them) +# *-skill.md + +# Test files +test.xlsx +test.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..d00e2f0 --- /dev/null +++ b/README.md @@ -0,0 +1,305 @@ +# Tools 工具箱 🧰 + +这是一个实用的命令行工具集合,包含三个高效的 Rust 开发的工具,用于日常文件处理和数据处理任务。 + +## 📦 工具列表 + +### 1. [cutpic](./cutPic/README.md) - 图片裁切工具 🖼️ + +**功能**: 将图片按指定的行列数均匀分割成多个小图片 + +**适用场景**: +- 制作拼图素材 +- 提取 Sprite Sheet 中的动画帧 +- 分割图标集 +- 创建 Instagram 九宫格 +- 处理大幅面图片 + +**快速开始**: +```bash +# 直接使用全局命令 +cutpic -i image.png -w 3 -h 2 +``` + +[查看详细文档 →](./cutPic/README.md) + +--- + +### 2. [zip](./zip/README.md) - ZIP 打包工具 📦 + +**功能**: 将文件或文件夹快速打包成 zip 压缩文件 + +**适用场景**: +- 项目备份 +- 文件分享 +- 代码归档 +- 日志收集 +- 资源打包 + +**特色功能**: +- ✅ 支持单个文件、多个文件或整个文件夹 +- ✅ 智能命名(自动生成有意义的文件名) +- ✅ 剪贴板集成(Windows 上可复制文件到剪贴板) +- ✅ 详细的打包统计信息 + +**快速开始**: +```bash +# 直接使用全局命令 +zip myfolder -n backup.zip -v +``` + +[查看详细文档 →](./zip/README.md) + +--- + +### 3. [dexcel](./dexcel/README.md) - Excel 读写工具 📊 + +**功能**: 命令行 Excel 文件读取和写入工具 + +**适用场景**: +- 数据分析 +- 数据转换 +- 报告生成 +- 数据验证 +- 批量数据录入 + +**特色功能**: +- ✅ 灵活的范围读取(行、列选择) +- ✅ 负数列索引(从右往左倒数) +- ✅ 智能数据保留(写入时不覆盖现有数据) +- ✅ TSV 格式输出(便于管道处理) + +**快速开始**: +```bash +# 直接使用全局命令 + +# 读取 Excel +dexcel read -f data.xlsx + +# 写入 Excel +dexcel write -f data.xlsx -x 1 -y 1 -v "Hello" +``` + +[查看详细文档 →](./dexcel/README.md) + +--- + +## 🚀 安装方式 + +### 方式一:Cargo Install(推荐) + +在每个工具目录下执行: + +```bash +# 安装 cutpic +cd cutPic +cargo install --path . + +# 安装 dexcel +cd ../dexcel +cargo install --path . + +# 安装 zip +cd ../zip +cargo install --path . +``` + +安装后,可以直接在命令行中使用 `cutpic`、`dexcel`、`zip` 命令。 + +### 方式二:本地编译 + +```bash +# Windows PowerShell +Get-ChildItem -Directory | Where-Object { $_.Name -ne 'target' } | ForEach-Object { + Write-Host "Building $($_.Name)..." + Push-Location $_.FullName + cargo build --release + Pop-Location +} + +# Linux/macOS +for dir in */; do + if [ -f "$dir/Cargo.toml" ]; then + echo "Building $dir..." + cd "$dir" && cargo build --release && cd .. + fi +done +``` + +编译后的可执行文件位于各工具的 `target/release/` 目录下。 + +--- + +## 💡 综合使用示例 + +### 示例 1: 项目管理自动化 + +```bash +# 1. 打包项目源代码 +zip src/ README.md LICENSE -n project-source.zip + +# 2. 创建版本信息 Excel +dexcel write -f version-info.xlsx -x 1 -y 1 -v "版本" +dexcel write -f version-info.xlsx -x 1 -y 2 -v "日期" +dexcel write -f version-info.xlsx -x 1 -y 3 -v "说明" +dexcel write -f version-info.xlsx -x 2 -y 1 -v "1.0.0" +dexcel write -f version-info.xlsx -x 2 -y 2 -v "2024-01-01" +dexcel write -f version-info.xlsx -x 2 -y 3 -v "初始版本" + +# 3. 打包发布文件 +zip project-source.zip version-info.xlsx -n release-v1.0.0.zip -v +``` + +### 示例 2: 图片资源处理工作流 + +```bash +# 1. 分割游戏精灵图 +cutpic -i spritesheet.png -w 8 -h 4 --out ./frames + +# 2. 创建资源清单 Excel +dexcel write -f assets.xlsx -x 1 -y 1 -v "文件名" +dexcel write -f assets.xlsx -x 1 -y 2 -v "类型" +dexcel write -f assets.xlsx -x 1 -y 3 -v "尺寸" + +# 添加资源信息 +dexcel write -f assets.xlsx -x 2 -y 1 -v "frame_1.png" +dexcel write -f assets.xlsx -x 2 -y 2 -v "动画帧" +dexcel write -f assets.xlsx -x 2 -y 3 -v "64x64" + +# 3. 打包所有资源 +zip frames/ assets.xlsx -n game-assets.zip +``` + +### 示例 3: 数据备份和处理 + +```bash +# 1. 备份重要文档 +zip documents/ reports/ -n docs-backup.zip + +# 2. 创建备份清单 +dexcel write -f backup-log.xlsx -x 1 -y 1 -v "文件名" +dexcel write -f backup-log.xlsx -x 1 -y 2 -v "大小" +dexcel write -f backup-log.xlsx -x 1 -y 3 -v "日期" +dexcel write -f backup-log.xlsx -x 2 -y 1 -v "docs-backup.zip" +dexcel write -f backup-log.xlsx -x 2 -y 2 -v "15MB" +dexcel write -f backup-log.xlsx -x 2 -y 3 -v "2024-01-01" + +# 3. 打包备份和日志 +zip docs-backup.zip backup-log.xlsx -n complete-backup.zip -v +``` + +--- + +## 🎯 工具对比 + +| 特性 | cutpic | zip | dexcel | +|------|--------|-----|--------| +| **主要用途** | 图片分割 | 文件压缩 | Excel 读写 | +| **输入** | 单张图片 | 多文件/文件夹 | Excel 文件 | +| **输出** | 多张图片 | ZIP 压缩包 | 控制台/Excel | +| **平台支持** | 全平台 | 全平台 | 全平台 | +| **依赖数量** | 少 | 中 | 中 | +| **学习曲线** | 简单 | 简单 | 中等 | +| **适用人群** | 设计师、开发者 | 所有人 | 数据分析师、开发者 | + +--- + +## 🔧 技术栈 + +所有工具都使用 **Rust** 开发,具有以下优势: + +- ⚡ **高性能**: 编译型语言,运行速度快 +- 🔒 **内存安全**: 无垃圾回收,无内存泄漏 +- 📦 **单文件分发**: 无需运行时环境 +- 🌍 **跨平台**: Windows、macOS、Linux 原生支持 +- 🛠️ **现代工具链**: Cargo 包管理,优秀的开发体验 + +--- + +## 📊 项目结构 + +``` +tools/ +├── cutPic/ # 图片裁切工具 +│ ├── src/ +│ │ └── main.rs +│ ├── Cargo.toml +│ ├── README.md +│ └── target/ +│ └── release/ +│ └── cutpic.exe +│ +├── zip/ # ZIP 打包工具 +│ ├── src/ +│ │ └── main.rs +│ ├── Cargo.toml +│ ├── README.md +│ └── target/ +│ └── release/ +│ └── zip.exe +│ +├── dexcel/ # Excel 读写工具 +│ ├── src/ +│ │ └── main.rs +│ ├── Cargo.toml +│ ├── README.md +│ ├── example.bat +│ └── target/ +│ └── release/ +│ └── dexcel.exe +│ +└── README.md # 本文件 +``` + +--- + +## 🤝 贡献指南 + +欢迎贡献代码、报告问题或提出建议! + +### 提交 Issue +- 描述清楚问题和复现步骤 +- 提供环境信息(操作系统、Rust 版本等) +- 如果可能,提供截图或日志 + +### 提交 Pull Request +1. Fork 本项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 开启 Pull Request + +### 开发规范 +- 遵循 Rust 官方代码风格 +- 添加必要的注释和文档 +- 确保代码通过 `cargo clippy` 检查 +- 更新相关的 README 文档 + +--- + +## 📄 许可证 + +本项目采用 MIT 许可证 - 详见各子项目的 LICENSE 文件。 + +--- + +## 🙏 致谢 + +感谢以下开源项目和社区: +- [Rust](https://www.rust-lang.org/) - 编程语言 +- [Cargo](https://doc.rust-lang.org/cargo/) - 包管理器 +- 所有依赖库的作者和维护者 +- 所有贡献者和用户 + +--- + +## 📞 联系方式 + +- 项目地址: `c:\Users\macro\Documents\workspace\self\tools` +- 问题反馈: 提交 GitHub Issue + +--- + +**Made with ❤️ using Rust** + +*最后更新: 2024年* diff --git a/cutPic/.gitignore b/cutPic/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/cutPic/.gitignore @@ -0,0 +1 @@ +/target diff --git a/cutPic/Cargo.toml b/cutPic/Cargo.toml new file mode 100644 index 0000000..3394dc0 --- /dev/null +++ b/cutPic/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cutpic" +version = "0.1.0" +edition = "2024" + +[dependencies] +image = "0.25" +clap = { version = "4.5", features = ["derive"] } diff --git a/cutPic/README.md b/cutPic/README.md new file mode 100644 index 0000000..dd619a1 --- /dev/null +++ b/cutPic/README.md @@ -0,0 +1,283 @@ +# cutpic - 图片裁切工具 🖼️ + +一个简单易用的命令行图片裁切工具,可以将图片按指定的行列数均匀分割成多个小图片。 + +## ✨ 功能特性 + +- 📐 **灵活分割**: 支持自定义水平和垂直分割数 +- 🎯 **均匀裁切**: 自动计算每块尺寸,均匀分割图片 +- 📁 **智能输出**: 默认在输入文件同级目录创建 dist 文件夹 +- 🖼️ **多格式支持**: 支持 PNG, JPG, JPEG, BMP, GIF, WebP 等常见图片格式 +- 🔢 **智能命名**: 自动生成带序号的文件名,方便识别和管理 +- ⚡ **高性能**: 基于 Rust 开发,处理速度快 + +## 📦 安装 + +### 从源码编译 + +```bash +cd cutPic +cargo build --release +``` + +编译后的可执行文件位于 `target/release/cutpic.exe` + +### 添加到系统路径(可选) + +将 `target/release/cutpic.exe` 复制到系统 PATH 目录,或将其所在目录添加到 PATH 环境变量中。 + +## 🚀 快速开始 + +### 基本用法 + +```bash +# 将图片分割成 3x2 的网格(共6块) +cutpic -i image.png -w 3 -h 2 + +# 将图片分割成 4x4 的网格(共16块) +cutpic -i photo.jpg -w 4 -h 4 +``` + +### 指定输出目录 + +```bash +# 指定输出到 custom_output 目录 +cutpic -i image.png -w 2 -h 2 --out ./custom_output +``` + +## 📖 命令参数 + +```bash +cutpic [OPTIONS] -i + +参数: + -i, --input 📁 输入图片路径(必需) + -w, --width 📐 水平方向分割数(默认: 1) + -h, --height 📏 垂直方向分割数(默认: 1) + --out 📂 输出目录(默认: 输入文件同级的 dist 文件夹) + -h, --help 显示帮助信息 + -V, --version 显示版本信息 +``` + +## 💡 使用示例 + +### 示例 1: 制作拼图素材 + +将一张大图分割成 5x5 的拼图块: + +```bash +cutpic -i puzzle.png -w 5 -h 5 +``` + +**输出:** +- 生成 25 张图片 +- 文件命名: `puzzle_1_25.png`, `puzzle_2_25.png`, ..., `puzzle_25_25.png` +- 输出位置: `./dist/` 目录 + +### 示例 2: 提取图标集中的单个图标 + +从 4x4 排列的图标集中提取每个图标: + +```bash +cutpic -i icons.png -w 4 -h 4 --out ./individual_icons +``` + +**输出:** +- 生成 16 张单独的图标 +- 输出到 `./individual_icons/` 目录 + +### 示例 3: 分割游戏精灵图(Sprite Sheet) + +横向分割角色动画帧(8 帧动画): + +```bash +cutpic -i character.png -w 8 -h 1 --out ./animation_frames +``` + +**输出:** +- 生成 8 张动画帧图片 +- 适合用于游戏开发中的帧动画 + +### 示例 4: 只进行水平分割 + +将横幅广告图分割成 4 部分: + +```bash +cutpic -i banner.png -w 4 -h 1 +``` + +### 示例 5: 只进行垂直分割 + +将长图分割成 3 段: + +```bash +cutpic -i long-image.jpg -w 1 -h 3 +``` + +## 📊 输出说明 + +### 文件命名规则 + +生成的文件遵循以下命名格式: + +``` +{原文件名}_{序号}_{总数}.{扩展名} +``` + +**示例:** +- 原文件: `image.png` +- 分割: 3x2 (共6块) +- 输出文件: + - `image_1_6.png` - 第1块 + - `image_2_6.png` - 第2块 + - ... + - `image_6_6.png` - 第6块 + +### 输出目录结构 + +``` +项目目录/ +├── image.png # 原始图片 +└── dist/ # 默认输出目录 + ├── image_1_6.png + ├── image_2_6.png + ├── image_3_6.png + ├── image_4_6.png + ├── image_5_6.png + └── image_6_6.png +``` + +如果指定了 `--out` 参数: + +``` +项目目录/ +├── image.png # 原始图片 +└── my_output/ # 自定义输出目录 + ├── image_1_6.png + ├── image_2_6.png + └── ... +``` + +## ⚙️ 工作原理 + +1. **加载图片**: 读取输入图片并获取尺寸信息 +2. **计算尺寸**: + - 每块宽度 = 总宽度 ÷ 水平分割数 + - 每块高度 = 总高度 ÷ 垂直分割数 +3. **逐块裁切**: 按行列顺序遍历,裁切每一块 +4. **处理边界**: 最后一行/列可能包含剩余像素,自动调整尺寸 +5. **保存文件**: 按命名规则保存到输出目录 + +### 边界处理示例 + +假设图片尺寸为 100x100 像素,分割为 3x3: + +- 理论每块: 33.33 x 33.33 像素 +- 实际处理: + - 第1、2块: 33 x 33 像素 + - 第3块: 34 x 34 像素(包含剩余像素) + +这样确保所有像素都被保留,不会丢失任何内容。 + +## 🎯 应用场景 + +### 1. 游戏开发 +- 分割 Sprite Sheet 为单独的动画帧 +- 提取瓦片地图(Tilemap)的单个瓦片 +- 处理角色、道具等资源图片 + +### 2. 网页设计 +- 将大图分割成小块用于懒加载 +- 制作图片拼图效果 +- 优化大图的加载性能 + +### 3. 社交媒体 +- 制作 Instagram 九宫格图片 +- 分割长图为适合移动端查看的多张图片 +- 创建有趣的图片分割效果 + +### 4. 数据处理 +- 批量处理扫描文档 +- 分割大幅面图纸 +- 处理卫星图像或地图切片 + +### 5. 教育用途 +- 制作教学用拼图 +- 分解复杂图表 +- 创建互动学习材料 + +## ⚠️ 注意事项 + +1. **图片格式**: 输出格式与输入格式相同 +2. **最小分割**: 分割数为 1x1 时,会生成原图的副本 +3. **内存使用**: 处理超大图片时会占用较多内存 +4. **文件数量**: 分割数越多,生成的文件越多,注意磁盘空间 +5. **索引顺序**: 文件按先行后列的顺序编号(从左到右,从上到下) + +## 🔧 技术细节 + +### 依赖库 +- **clap**: 命令行参数解析 +- **image**: 图片处理和裁切 + +### 性能特点 +- 使用 Rust 编写,性能优异 +- 支持多线程(取决于 image 库的实现) +- 内存效率高,逐块处理 + +### 平台支持 +- ✅ Windows +- ✅ macOS +- ✅ Linux + +## 📝 完整示例工作流 + +### 工作流 1: 制作 Instagram 九宫格 + +```bash +# 1. 准备一张正方形图片(建议 1080x1080 或更高) +# 2. 分割成 3x3 的九宫格 +cutpic -i instagram-post.png -w 3 -h 3 --out ./instagram-grid + +# 3. 上传生成的 9 张图片到 Instagram(按逆序上传以获得正确显示顺序) +``` + +### 工作流 2: 提取游戏资源 + +```bash +# 1. 从游戏中提取 sprite sheet +# 2. 分析 sprite sheet 的布局(例如 8x4 排列) +cutpic -i spritesheet.png -w 8 -h 4 --out ./game-assets + +# 3. 重命名生成的文件为有意义的名称 +# 4. 在游戏引擎中使用这些单独的资源 +``` + +### 工作流 3: 创建拼图游戏 + +```bash +# 1. 选择一张有趣的图片 +# 2. 根据难度选择分割数(简单: 3x3, 中等: 5x5, 困难: 8x8) +cutpic -i puzzle-image.jpg -w 5 -h 5 --out ./puzzle-pieces + +# 3. 打乱文件顺序 +# 4. 开发拼图游戏逻辑 +``` + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +## 📄 许可证 + +本项目采用 MIT 许可证。 + +## 🙏 致谢 + +感谢以下开源项目: +- [clap](https://github.com/clap-rs/clap) - 优秀的命令行参数解析库 +- [image](https://github.com/image-rs/image) - 强大的图片处理库 + +--- + +**Made with ❤️ using Rust** diff --git a/cutPic/src/main.rs b/cutPic/src/main.rs new file mode 100644 index 0000000..e593824 --- /dev/null +++ b/cutPic/src/main.rs @@ -0,0 +1,108 @@ +use clap::Parser; +use image::GenericImageView; +use std::path::PathBuf; + +/// 图片裁切工具 - 将图片按行列分割成多份 +#[derive(Parser, Debug)] +#[command(name = "cutpic")] +#[command(author, version, about, long_about = None)] +#[command( + about = "🖼️ 图片裁切工具 - 将图片按指定的行列数分割成多个小图片", + long_about = "🖼️ 图片裁切工具\n\n将一张图片按照指定的水平和垂直分割数,均匀裁切成多个小图片。\n适用于制作拼图、图标集、 sprite sheet 等场景。\n\n示例:\n cutpic -i image.png -w 3 -h 2\n cutpic -i photo.jpg --width 4 --height 4 --out ./output" +)] +struct Args { + /// 📁 输入图片路径(支持 PNG, JPG, JPEG, BMP, GIF, WebP 等格式) + #[arg(short = 'i', long = "input", value_name = "FILE")] + input: PathBuf, + + /// 📐 水平方向分割数(将宽度分成几份,默认 1) + #[arg(short = 'w', long = "width", default_value_t = 1, value_name = "NUM")] + width: u32, + + /// 📏 垂直方向分割数(将高度分成几份,默认 1) + #[arg(short = 'h', long = "height", default_value_t = 1, value_name = "NUM")] + height: u32, + + /// 📂 输出目录(默认为输入文件同级目录下的 dist 文件夹) + #[arg(long = "out", value_name = "DIR", default_value = ".")] + output: PathBuf, +} + +fn main() -> Result<(), Box> { + let args = Args::parse(); + + // 加载图片 + println!("\n🔄 正在加载图片: {:?}", args.input); + let img = image::open(&args.input)?; + let (width, height) = img.dimensions(); + + println!("📊 图片尺寸: {}x{} 像素", width, height); + println!("✂️ 分割方式: {}x{} (共 {} 份)", args.width, args.height, args.width * args.height); + + // 计算每个子图的尺寸 + let tile_width = width / args.width; + let tile_height = height / args.height; + + println!("📏 每块尺寸: {}x{} 像素", tile_width, tile_height); + + // 确定输出目录:如果用户指定了输出目录则使用,否则在输入文件旁边创建 dist 目录 + let output_dir = if args.output != PathBuf::from(".") { + args.output.clone() + } else { + let mut default_output = args.input.parent().unwrap_or(std::path::Path::new(".")).to_path_buf(); + default_output.push("dist"); + default_output + }; + + println!("📁 输出目录: {:?}\n", output_dir); + + // 创建输出目录(如果不存在) + std::fs::create_dir_all(&output_dir)?; + + // 获取文件名(不含扩展名)和扩展名 + let file_stem = args + .input + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("image"); + let extension = args + .input + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("png"); + + // 裁切并保存每一块 + for row in 0..args.height { + for col in 0..args.width { + let x = col * tile_width; + let y = row * tile_height; + + // 最后一块可能需要调整尺寸以适应剩余部分 + let actual_width = if col == args.width - 1 { + width - x + } else { + tile_width + }; + let actual_height = if row == args.height - 1 { + height - y + } else { + tile_height + }; + + // 裁切图片 + let tile = img.crop_imm(x, y, actual_width, actual_height); + + // 生成输出文件名 + let index = row * args.width + col + 1; + let output_filename = format!("{}_{}_{}.{}", file_stem, index, args.width * args.height, extension); + let output_path = output_dir.join(output_filename); + + // 保存图片 + tile.save(&output_path)?; + println!("已保存: {:?}", output_path); + } + } + + println!("\n✅ 完成! 共生成 {} 张图片", args.width * args.height); + Ok(()) +} diff --git a/dexcel/.gitignore b/dexcel/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/dexcel/.gitignore @@ -0,0 +1 @@ +/target diff --git a/dexcel/Cargo.toml b/dexcel/Cargo.toml new file mode 100644 index 0000000..88618d2 --- /dev/null +++ b/dexcel/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "dexcel" +version = "0.1.0" +edition = "2024" + +[dependencies] +calamine = { version = "0.26", features = ["dates"] } +clap = { version = "4.5", features = ["derive"] } +rust_xlsxwriter = "0.80" +tabled = "0.16" +chrono = "0.4" diff --git a/dexcel/README.md b/dexcel/README.md new file mode 100644 index 0000000..845990a --- /dev/null +++ b/dexcel/README.md @@ -0,0 +1,514 @@ +# dexcel - Excel 读写工具 📊 + +一个简单易用的命令行 Excel 处理工具,支持查询、统计、插入、更新和删除操作。 + +## ✨ 功能特性 + +### 核心操作 +- 🔍 **query**: 灵活查询 Excel 数据,支持行范围选择和自定义分隔符 +- 📊 **count**: 快速统计行数,适合脚本使用 +- ➕ **insert**: 插入新行,支持指定位置或追加到末尾 +- ✏️ **update**: 更新现有数据,支持部分列更新 +- ❌ **delete**: 删除行或单元格,带安全确认机制 + +### 通用特性 +- 📑 **多 Sheet 支持**: 所有操作都支持 `--sheet` 参数指定工作表 +- 🔢 **负数索引**: 支持负数行号(-1 表示最后一行) +- 🆕 **自动创建**: insert 操作在文件不存在时自动创建 +- ⚠️ **安全确认**: delete 操作需要用户确认 +- 📤 **灵活输出**: query 支持自定义分隔符(默认 |) + +## 📦 安装 + +### 从源码编译 + +```bash +cd dexcel +cargo build --release +``` + +编译后的可执行文件位于 `target/release/dexcel.exe` (Windows) + +### 全局安装 + +```bash +cargo install --path . --force +``` + +安装后可直接使用 `dexcel` 命令。 + +## 🚀 使用方法 + +**基本格式:** +```bash +dexcel <文件路径> <操作> [参数] +``` + +**注意:文件路径是第一个位置参数,不需要 `--file` 标志!** + +### 1. 查询数据 (query) + +```bash +# 查看整个文件(默认显示最后20行) +dexcel data.xlsx query + +# 查看前5行 +dexcel data.xlsx query 5 + +# 查看最后3行 +dexcel data.xlsx query -3 + +# 查看最后一行 +dexcel data.xlsx query --last + +# 从第10行开始查看 +dexcel data.xlsx query --start 10 + +# 指定 Sheet 名称 +dexcel data.xlsx query --sheet "Sales" +``` + +#### query 参数说明 +- `[COUNT]`: 获取行数(正数从前,负数从后),默认0显示最后20行 +- `--start `: 开始行号(支持负数) +- `--last`: 获取最后一行 +- `--sheet `: Sheet 名称(可选,默认第一个 Sheet) + +### 2. 统计行数 (count) + +```bash +# 统计总行数 +dexcel data.xlsx count + +# 统计指定 Sheet 的行数 +dexcel data.xlsx count --sheet "Sales" +``` + +#### count 参数说明 +- `--sheet `: Sheet 名称(可选,默认第一个 Sheet) + +**输出**: 只输出一个数字,表示总行数,适合脚本中使用。 + +### 3. 插入数据 (insert) + +```bash +# 创建新文件并插入表头 +dexcel new-file.xlsx insert "姓名|年龄|城市" + +# 追加数据到末尾 +dexcel data.xlsx insert "张三|18|北京" + +# 在指定位置插入(第2行) +dexcel data.xlsx insert "李四|20|上海" --row 2 + +# 使用不同分隔符 +dexcel data.xlsx insert "王五,25,广州" --split "," + +# 指定 Sheet +dexcel data.xlsx insert "数据" --sheet "Sheet2" +``` + +#### insert 参数说明 +- ``: 要插入的值(用 split 分隔列)**必需** +- `--row `: 插入位置(不指定则追加到末尾) +- `--sheet `: Sheet 名称(可选) +- `--split `: 分隔符(默认 |) + +### 4. 更新数据 (update) + +```bash +# 更新整行(从第1列开始) +dexcel data.xlsx update "赵六|22|深圳" --row 2 + +# 更新部分列(从第2列开始) +dexcel data.xlsx update "新年龄" --row 5 --cell 2 + +# 更新多列(从第3列开始) +dexcel data.xlsx update "新城市|新备注" --row 4 --cell 3 + +# 指定 Sheet +dexcel data.xlsx update "新值" --row 1 --sheet "Sheet2" +``` + +#### update 参数说明 +- ``: 要更新的值(用 split 分隔列)**必需** +- `--row `: 行号(必需,支持负数) +- `--cell `: 起始列号(从1开始,默认1) +- `--sheet `: Sheet 名称(可选) +- `--split `: 分隔符(默认 |) + +### 5. 删除数据 (delete) + +```bash +# 删除整行(带确认) +dexcel data.xlsx delete --row 5 + +# 删除最后一行 +dexcel data.xlsx delete --row -1 + +# 删除单元格(第3行第2列) +dexcel data.xlsx delete --row 3 --cell 2 + +# 指定 Sheet +dexcel data.xlsx delete --row 1 --sheet "Sheet2" +``` + +#### delete 参数说明 +- `[COUNT]`: 删除数量(默认1) +- `--row `: 行号(必需,支持负数) +- `--cell `: 列号(可选,不指定则删除整行) +- `--sheet `: Sheet 名称(可选) + +**注意**: delete 操作需要用户确认(输入 y 继续) + +## 💡 使用示例 + +### 示例 1: 创建学生信息表 + +```bash +# 创建表头 +dexcel students.xlsx insert "姓名|年龄|班级" + +# 添加学生数据 +dexcel students.xlsx insert "张三|18|一班" +dexcel students.xlsx insert "李四|19|二班" +dexcel students.xlsx insert "王五|20|三班" + +# 查看所有数据 +dexcel students.xlsx query + +# 统计总行数 +dexcel students.xlsx count +``` + +### 示例 2: 数据查询和分析 + +```bash +# 查看前5行 +dexcel data.xlsx query 5 + +# 查看最后10行 +dexcel data.xlsx query -10 + +# 从第50行开始查看 +dexcel data.xlsx query --start 50 + +# 导出到文件 +dexcel data.xlsx query > output.txt + +# 使用逗号分隔 +dexcel data.xlsx query --split "," +``` + +### 示例 3: 数据更新 + +```bash +# 更新第2行的所有数据 +dexcel data.xlsx update "新姓名|新年龄|新城市" --row 2 + +# 只更新第2行的第2列 +dexcel data.xlsx update "新年龄" --row 2 --cell 2 + +# 批量更新(脚本) +for i in 2 3 4 5; do + dexcel data.xlsx update "已审核" --row $i --cell 4 +done +``` + +### 示例 4: 数据清理 + +```bash +# 删除错误数据行 +dexcel data.xlsx delete --row 10 + +# 删除最后一行 +dexcel data.xlsx delete --row -1 + +# 删除特定单元格 +dexcel data.xlsx delete --row 5 --cell 3 +``` + +### 示例 5: 多 Sheet 操作 + +```bash +# 在不同 Sheet 中操作 +dexcel data.xlsx insert "数据1" --sheet "Sheet1" +dexcel data.xlsx insert "数据2" --sheet "Sheet2" + +# 查询指定 Sheet +dexcel data.xlsx query --sheet "Sales" + +# 统计指定 Sheet 的行数 +dexcel data.xlsx count --sheet "Sales" +``` + +## 📊 输出格式 + +### query 输出(表格格式) +``` +┌──────┬──────┬──────┐ +│ 姓名 │ 年龄 │ 城市 │ +├──────┼──────┼──────┤ +│ 张三 │ 25 │ 北京 │ +├──────┼──────┼──────┤ +│ 李四 │ 30 │ 上海 │ +└──────┴──────┴──────┘ + +共输出 2 行数据,3 列 +``` + +**特点**: +- ✅ 使用美观的表格格式(基于 tabled 库) +- ✅ 自动对齐列宽 +- ✅ 清晰的分隔线和边框 +- ✅ 显示行数和列数统计 +- ✅ 易于阅读和查看 + +### count 输出 +``` +100 +``` + +**特点**: +- 只输出一个数字 +- 适合脚本中使用 +- 可与其他命令组合 + +### 操作反馈 + +**insert 成功**: +``` +✓ 操作成功:已创建新文件并插入一行数据 + 文件: data.xlsx, Sheet: Sheet1 +``` + +**update 成功**: +``` +✓ 操作成功:已更新第 5 行,从第 1 列开始 + 文件: data.xlsx, Sheet: Sheet1 +``` + +**delete 成功**: +``` +✓ 操作成功:已删除第 5 行 + 剩余行数: 9 + 文件: data.xlsx, Sheet: Sheet1 +``` + +## ⚙️ 技术细节 + +### 依赖库 + +- **calamine**: Excel 文件读取库,支持 .xlsx 格式 +- **rust_xlsxwriter**: Excel 文件写入库,功能强大 +- **clap**: 命令行参数解析库 +- **chrono**: 日期时间处理库 + +### 工作原理 + +#### 查询流程 +1. 打开 Excel 文件 +2. 选择指定的 Sheet(默认第一个) +3. 根据参数计算读取范围 +4. 遍历单元格并提取数据 +5. 以指定分隔符格式输出 + +#### 写入流程 +1. 检查文件是否存在(insert 可自动创建) +2. 读取现有数据到新工作簿 +3. 执行插入/更新/删除操作 +4. 保存文件 + +### 数据类型支持 + +**读取时支持:** +- 字符串 (String) +- 整数 (Int) +- 浮点数 (Float) +- 布尔值 (Bool) +- 日期时间 (DateTime) - 自动识别并格式化 +- 空值 (Empty) +- 错误值 (Error) + +**写入时:** +- 所有数据都作为字符串写入 + +## ⚠️ 注意事项 + +1. **索引从 1 开始**: 行和列的索引都是从 1 开始,不是 0 +2. **负数索引**: `-1` = 最后一行/列,`-2` = 倒数第二 +3. **文件自动创建**: insert 操作在文件不存在时自动创建 +4. **分隔符默认**: 默认使用 `|` 分隔列 +5. **文件格式**: 仅支持 `.xlsx`,不支持 `.xls` +6. **删除需确认**: delete 操作必须用户确认才能执行 +7. **日期自动识别**: 自动识别并格式化日期单元格 +8. **不可撤销**: delete 操作不可撤销,请谨慎操作! + +### 限制 + +**读取限制:** +- 公式: 不计算公式,只读取原始值 +- 样式: 不读取单元格样式(颜色、字体等) +- 图表: 不支持读取图表 +- 宏: 不支持 VBA 宏 +- 大文件: > 10MB 的文件可能较慢 + +**写入限制:** +- 无样式: 不能设置颜色、字体等样式 +- 无公式: 不能写入公式 +- 覆盖警告: update 会覆盖目标单元格的原有内容 +- insert 下移: insert 会将原有数据下移 + +## 🔧 常见问题 + +### Q1: 如何查看文件有多少行? + +```bash +dexcel data.xlsx count +``` + +### Q2: 如何追加多行数据? + +多次调用 insert 命令: +```bash +dexcel data.xlsx insert "数据1|数据2" +dexcel data.xlsx insert "数据3|数据4" +``` + +### Q3: 负数参数怎么用? + +```bash +dexcel data.xlsx query -5 # 最后5行 +dexcel data.xlsx delete --row -1 # 删除最后一行 +``` + +### Q4: 如何处理中文内容? + +直接传入,完全支持 UTF-8: +```bash +dexcel data.xlsx insert "张三|18|北京" +``` + +### Q5: 如何在脚本中使用 count? + +```bash +# 获取行数并存储到变量 +$lines = dexcel data.xlsx count + +# 条件判断 +if ((dexcel data.xlsx count) -gt 100) { + Write-Host "文件超过100行" +} +``` + +### Q6: 删除操作能撤销吗? + +不能撤销,请谨慎操作!建议先备份文件。 + +## 🎯 应用场景 + +### 1. 数据预览 +快速查看大型 Excel 文件的部分内容: +```bash +dexcel large-file.xlsx query 5 +``` + +### 2. 数据提取 +提取特定数据并导出: +```bash +dexcel data.xlsx query -10 > last-rows.txt +``` + +### 3. 报告生成 +创建报告模板: +```bash +dexcel report.xlsx insert "月度报告||||" +dexcel report.xlsx insert "日期|销售额|利润|备注" +``` + +### 4. 数据更新 +批量更新某列数据: +```bash +dexcel data.xlsx update "新值" --row 5 --cell 2 +dexcel data.xlsx update "新值" --row 6 --cell 2 +``` + +### 5. 数据清理 +删除错误数据: +```bash +dexcel data.xlsx delete --row 10 +``` + +### 6. 自动化脚本 +PowerShell 批量导入: +```powershell +$students = @( + @("张三", "18", "一班"), + @("李四", "19", "二班") +) + +foreach ($student in $students) { + dexcel students.xlsx insert "$($student[0])|$($student[1])|$($student[2])" +} +``` + +## 📝 批处理脚本示例 + +### Windows 批量导入 (import.bat) + +```batch +@echo off +echo 正在导入数据... + +dexcel data.xlsx insert "ID|名称|数量" +dexcel data.xlsx insert "001|产品A|100" +dexcel data.xlsx insert "002|产品B|200" +dexcel data.xlsx insert "003|产品C|300" + +echo 导入完成! +dexcel data.xlsx query +pause +``` + +### PowerShell 数据处理 (process.ps1) + +```powershell +# 读取并处理数据 +$output = dexcel data.xlsx query 100 + +# 过滤包含关键词的行 +$filtered = $output | Select-String "关键词" + +# 保存到文件 +$filtered | Out-File -FilePath filtered.txt -Encoding UTF8 + +Write-Host "处理完成!" -ForegroundColor Green +``` + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +可能的改进方向: +- 支持更多数据类型(公式等) +- 支持单元格样式(颜色、字体等) +- 支持批量写入优化 +- 支持复制 Sheet +- 添加数据验证功能 +- 支持更多输出格式(CSV, JSON 等) + +## 📄 许可证 + +本项目采用 MIT 许可证。 + +## 🙏 致谢 + +感谢以下开源项目: +- [calamine](https://github.com/tafia/calamine) - 优秀的 Excel 读取库 +- [rust_xlsxwriter](https://github.com/jmcnamara/rust_xlsxwriter) - 强大的 Excel 写入库 +- [clap](https://github.com/clap-rs/clap) - 优秀的命令行参数解析库 +- [chrono](https://github.com/chronotope/chrono) - 日期时间处理库 + +--- + +**Made with ❤️ using Rust** diff --git a/dexcel/example.bat b/dexcel/example.bat new file mode 100644 index 0000000..3779bfc --- /dev/null +++ b/dexcel/example.bat @@ -0,0 +1,48 @@ +@echo off +set DEXCEL=%~dp0target\release\dexcel.exe +echo ==================================== +echo dexcel 工具使用示例 +echo ==================================== +echo. + +echo 1. 创建测试 Excel 文件 +echo ------------------- +%DEXCEL% write -f example.xlsx -x 1 -y 1 -v "姓名" +%DEXCEL% write -f example.xlsx -x 1 -y 2 -v "年龄" +%DEXCEL% write -f example.xlsx -x 1 -y 3 -v "城市" +%DEXCEL% write -f example.xlsx -x 2 -y 1 -v "张三" +%DEXCEL% write -f example.xlsx -x 2 -y 2 -v "25" +%DEXCEL% write -f example.xlsx -x 2 -y 3 -v "北京" +%DEXCEL% write -f example.xlsx -x 3 -y 1 -v "李四" +%DEXCEL% write -f example.xlsx -x 3 -y 2 -v "30" +%DEXCEL% write -f example.xlsx -x 3 -y 3 -v "上海" +echo. + +echo 2. 读取整个文件 +echo ------------------- +%DEXCEL% read -f example.xlsx +echo. + +echo 3. 读取第2行开始的数据(2行) +echo ------------------- +%DEXCEL% read -f example.xlsx -r 2 -n 2 +echo. + +echo 4. 只读取姓名列(第1列) +echo ------------------- +%DEXCEL% read -f example.xlsx -c 1 -m 1 +echo. + +echo 5. 读取最后一列(城市列) +echo ------------------- +%DEXCEL% read -f example.xlsx -c -1 -m 1 +echo. + +echo 6. 读取特定单元格(第2行,第2列) +echo ------------------- +%DEXCEL% read -f example.xlsx -r 2 -n 1 -c 2 -m 1 +echo. + +echo ==================================== +echo 示例完成! +echo ==================================== diff --git a/dexcel/src/main.rs b/dexcel/src/main.rs new file mode 100644 index 0000000..ab044b2 --- /dev/null +++ b/dexcel/src/main.rs @@ -0,0 +1,706 @@ +use calamine::{open_workbook, Data, DataType, Reader, Xlsx}; +use chrono::{Datelike, Timelike}; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser)] +#[command(name = "dexcel")] +#[command(about = "Excel 读写工具", long_about = None)] +struct Cli { + /// Excel 文件路径 + file: PathBuf, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// 查询数据 + Query { + /// 获取行数(正数从前获取,负数从后获取) + #[arg(default_value_t = 0, allow_hyphen_values = true)] + count: i32, + + /// 开始行号(从1开始,负数表示倒数) + #[arg(long, allow_hyphen_values = true)] + start: Option, + + /// 获取最后一行 + #[arg(long)] + last: bool, + + /// Sheet 名称(可选,默认为第一个sheet) + #[arg(long)] + sheet: Option, + }, + /// 统计行数 + Count { + /// Sheet 名称(可选,默认为第一个sheet) + #[arg(long)] + sheet: Option, + }, + /// 插入行 + Insert { + /// 要插入的值(使用 split 分隔列) + value: String, + + /// 插入位置(行号,不指定则追加到末尾) + #[arg(long, allow_hyphen_values = true)] + row: Option, + + /// Sheet 名称(可选,默认为第一个sheet) + #[arg(long)] + sheet: Option, + + /// 分割符号(默认 |) + #[arg(long, default_value = "|")] + split: String, + }, + /// 更新数据 + Update { + /// 要更新的值(使用 split 分隔列) + value: String, + + /// 行号(必需) + #[arg(long, allow_hyphen_values = true)] + row: i32, + + /// 起始列号(从1开始,不指定则从第1列开始) + #[arg(long)] + cell: Option, + + /// Sheet 名称(可选,默认为第一个sheet) + #[arg(long)] + sheet: Option, + + /// 分割符号(默认 |) + #[arg(long, default_value = "|")] + split: String, + }, + /// 删除数据 + Delete { + /// 删除数量(单元格数或行数) + #[arg(default_value_t = 1)] + count: u32, + + /// 行号(必需) + #[arg(long, allow_hyphen_values = true)] + row: i32, + + /// 列号(可选,不指定则删除整行) + #[arg(long)] + cell: Option, + + /// Sheet 名称(可选,默认为第一个sheet) + #[arg(long)] + sheet: Option, + }, +} + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Query { + count, + start, + last, + sheet, + } => { + if let Err(e) = query_excel(&cli.file, count, start, last, &sheet) { + eprintln!("错误: {}", e); + std::process::exit(1); + } + } + Commands::Count { sheet } => { + if let Err(e) = count_excel(&cli.file, &sheet) { + eprintln!("错误: {}", e); + std::process::exit(1); + } + } + Commands::Insert { + value, + row, + sheet, + split, + } => { + if let Err(e) = insert_excel(&cli.file, &value, row, &sheet, &split) { + eprintln!("错误: {}", e); + std::process::exit(1); + } + } + Commands::Update { + value, + row, + cell, + sheet, + split, + } => { + if let Err(e) = update_excel(&cli.file, &value, row, cell, &sheet, &split) { + eprintln!("错误: {}", e); + std::process::exit(1); + } + } + Commands::Delete { + count, + row, + cell, + sheet, + } => { + if let Err(e) = delete_excel(&cli.file, count, row, cell, &sheet) { + eprintln!("错误: {}", e); + std::process::exit(1); + } + } + } +} + +// 格式化单元格值为字符串 +fn format_cell(cell: &Data) -> String { + match cell { + Data::Empty => String::new(), + Data::Int(v) => v.to_string(), + Data::Float(v) => { + if *v > 1.0 && *v < 2958466.0 && (*v - v.floor()).abs() < 0.0001 { + let temp_data = Data::Float(*v); + if let Some(dt) = temp_data.as_datetime() { + let year = dt.year(); + if year >= 1900 && year <= 2100 { + return dt.format("%Y-%m-%d").to_string(); + } + } + } + v.to_string() + } + Data::String(s) => s.clone(), + Data::Bool(b) => b.to_string(), + Data::DateTime(d) => { + if let Some(dt) = d.as_datetime() { + if dt.hour() == 0 && dt.minute() == 0 && dt.second() == 0 { + dt.format("%Y-%m-%d").to_string() + } else { + dt.format("%Y-%m-%d %H:%M:%S").to_string() + } + } else { + d.to_string() + } + } + Data::DateTimeIso(s) => s.clone(), + Data::DurationIso(s) => s.clone(), + Data::Error(e) => format!("#{}", e), + _ => String::new(), + } +} + +// 统计行数(count 命令) +fn count_excel( + file: &PathBuf, + sheet_name: &Option, +) -> Result<(), Box> { + let mut workbook: Xlsx<_> = open_workbook(file)?; + + let sheet = match sheet_name { + Some(name) => name.clone(), + None => { + let sheets = workbook.sheet_names(); + if sheets.is_empty() { + return Err("Excel 文件中没有 sheet".into()); + } + sheets[0].clone() + } + }; + + let range = workbook + .worksheet_range(&sheet) + .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; + + let total_rows = range.height(); + + println!("{}", total_rows); + Ok(()) +} + +// 查询数据(query 命令) +fn query_excel( + file: &PathBuf, + count: i32, + start: Option, + last: bool, + sheet_name: &Option, +) -> Result<(), Box> { + let mut workbook: Xlsx<_> = open_workbook(file)?; + + let sheet = match sheet_name { + Some(name) => name.clone(), + None => { + let sheets = workbook.sheet_names(); + if sheets.is_empty() { + return Err("Excel 文件中没有 sheet".into()); + } + sheets[0].clone() + } + }; + + let range = workbook + .worksheet_range(&sheet) + .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; + + let total_rows = range.height(); + let _total_cols = range.width(); + + if total_rows == 0 { + println!("表格为空"); + return Ok(()); + } + + // 计算要读取的行范围 + let (start_row, end_row) = if last { + (total_rows - 1, total_rows) + } else if let Some(s) = start { + // 使用 start 参数 + let actual_start = if s < 0 { + let pos = total_rows as i32 + s; + if pos < 0 { 0 } else { pos as usize } + } else { + (s as usize).saturating_sub(1) + }; + + if count > 0 { + (actual_start, (actual_start + count as usize).min(total_rows)) + } else if count < 0 { + let abs_count = (-count) as usize; + let end = (actual_start + abs_count).min(total_rows); + (actual_start, end) + } else { + // count 为 0,显示从 start 到末尾(最多20行) + let max_display = 20; + let end = (actual_start + max_display).min(total_rows); + if total_rows - actual_start > max_display { + eprintln!("提示: 从第 {} 行开始共有 {} 行数据,仅显示 {} 行", + actual_start + 1, total_rows - actual_start, max_display); + } + (actual_start, end) + } + } else if count > 0 { + (0, count as usize) + } else if count < 0 { + let abs_count = (-count) as usize; + let start = if abs_count >= total_rows { 0 } else { total_rows - abs_count }; + (start, total_rows) + } else { + // 默认显示最后20行 + let max_display = 20; + if total_rows > max_display { + eprintln!("提示: 共有 {} 行数据,仅显示最后 {} 行", total_rows, max_display); + eprintln!("如需查看更多数据,请使用 dexcel query <行数> 或 --last\n"); + (total_rows - max_display, total_rows) + } else { + (0, total_rows) + } + }; + + let end_row = end_row.min(total_rows); + + // 收集所有数据 + let mut data: Vec> = Vec::new(); + for r in start_row..end_row { + let mut row_values = Vec::new(); + for c in 0..range.width() { + let cell = range.get((r, c)); + if let Some(cell_data) = cell { + row_values.push(format_cell(cell_data)); + } else { + row_values.push(String::new()); + } + } + data.push(row_values); + } + + if data.is_empty() { + println!("没有数据"); + return Ok(()); + } + + // 使用 tabled 输出表格 + let output_count = data.len(); + let col_count = data[0].len(); + + // 使用 Builder API 创建动态表格 + use tabled::builder::Builder; + let mut builder = Builder::default(); + + // 添加所有行 + for row in &data { + builder.push_record(row.iter().map(|s| s.as_str())); + } + + let mut table = builder.build(); + + // 设置样式 + table.with(tabled::settings::Style::modern()); + + println!("{}", table); + eprintln!("\n共输出 {} 行数据,{} 列", output_count, col_count); + Ok(()) +} + +// 插入行(insert 命令) +fn insert_excel( + file: &PathBuf, + value: &str, + row: Option, + sheet_name: &Option, + split: &str, +) -> Result<(), Box> { + use rust_xlsxwriter::*; + + let sheet = match sheet_name { + Some(name) => name.clone(), + None => "Sheet1".to_string(), + }; + + // 如果文件不存在,创建新文件 + if !file.exists() { + let mut workbook = Workbook::new(); + let worksheet = workbook.add_worksheet(); + worksheet.set_name(&sheet)?; + + let values: Vec<&str> = value.split(split).collect(); + for (col_idx, val) in values.iter().enumerate() { + let trimmed = val.trim(); + if !trimmed.is_empty() { + worksheet.write_string(0, col_idx as u16, trimmed)?; + } + } + + workbook.save(file.as_path())?; + println!("✓ 操作成功:已创建新文件并插入一行数据"); + eprintln!(" 文件: {}, Sheet: {}", file.display(), sheet); + return Ok(()); + } + + let mut workbook: Xlsx<_> = open_workbook(file.as_path())?; + + let range = workbook + .worksheet_range(&sheet) + .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; + + let total_rows = range.height(); + let total_cols = range.width(); + + // 确定插入位置 + let insert_row = match row { + Some(r) => { + if r < 0 { + let pos = total_rows as i32 + r + 1; + if pos < 0 || pos > total_rows as i32 { + return Err(format!("行号无效: {}", r).into()); + } + pos as usize + } else { + let pos = r as usize - 1; + if pos > total_rows { + return Err(format!("行号超出范围: {} (总行数: {})", r, total_rows).into()); + } + pos + } + } + None => total_rows, // 默认追加到末尾 + }; + + // 创建新工作簿 + let mut new_workbook = Workbook::new(); + let new_worksheet = new_workbook.add_worksheet(); + new_worksheet.set_name(&sheet)?; + + let values: Vec<&str> = value.split(split).collect(); + let mut new_row = 0u32; + + // 复制插入行之前的数据 + for r in 0..insert_row { + for c in 0..range.width() { + if let Some(cell_data) = range.get((r, c)) { + write_cell(new_worksheet, new_row, c as u16, cell_data)?; + } + } + new_row += 1; + } + + // 写入新行 + for (col_idx, val) in values.iter().enumerate() { + let trimmed = val.trim(); + if !trimmed.is_empty() { + new_worksheet.write_string(new_row, col_idx as u16, trimmed)?; + } + } + new_row += 1; + + // 复制插入行之后的数据 + for r in insert_row..total_rows { + for c in 0..range.width() { + if let Some(cell_data) = range.get((r, c)) { + write_cell(new_worksheet, new_row, c as u16, cell_data)?; + } + } + new_row += 1; + } + + // 保存文件 + new_workbook.save(file.as_path())?; + + println!("✓ 操作成功:已在第 {} 行插入一行数据", insert_row + 1); + eprintln!(" 文件: {}, Sheet: {}, 总行数: {}", file.display(), sheet, total_rows + 1); + + Ok(()) +} + +// 更新数据(update 命令) +fn update_excel( + file: &PathBuf, + value: &str, + row: i32, + cell: Option, + sheet_name: &Option, + split: &str, +) -> Result<(), Box> { + use rust_xlsxwriter::*; + + if !file.exists() { + return Err(format!("文件不存在: {}", file.display()).into()); + } + + let mut workbook: Xlsx<_> = open_workbook(file.as_path())?; + + let sheet = match sheet_name { + Some(name) => name.clone(), + None => { + let sheets = workbook.sheet_names(); + if sheets.is_empty() { + return Err("Excel 文件中没有 sheet".into()); + } + sheets[0].clone() + } + }; + + let range = workbook + .worksheet_range(&sheet) + .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; + + let total_rows = range.height(); + let total_cols = range.width(); + + // 确定行号 + let target_row = if row < 0 { + let pos = total_rows as i32 + row; + if pos < 0 || pos >= total_rows as i32 { + return Err(format!("行号无效: {}", row).into()); + } + pos as usize + } else { + let pos = row as usize - 1; + if pos >= total_rows { + return Err(format!("行号超出范围: {} (总行数: {})", row, total_rows).into()); + } + pos + }; + + // 确定起始列 + let start_col = cell.map(|c| (c - 1) as usize).unwrap_or(0); + + // 创建新工作簿并复制所有数据 + let mut new_workbook = Workbook::new(); + let new_worksheet = new_workbook.add_worksheet(); + new_worksheet.set_name(&sheet)?; + + let values: Vec<&str> = value.split(split).collect(); + + for r in 0..total_rows { + for c in 0..range.width() { + if r == target_row && c >= start_col { + // 更新这一行的指定列 + let col_offset = c - start_col; + if col_offset < values.len() { + let trimmed = values[col_offset].trim(); + if !trimmed.is_empty() { + new_worksheet.write_string(r as u32, c as u16, trimmed)?; + } + } else { + // 超出提供的值,保持原样 + if let Some(cell_data) = range.get((r, c)) { + write_cell(new_worksheet, r as u32, c as u16, cell_data)?; + } + } + } else { + // 复制原有数据 + if let Some(cell_data) = range.get((r, c)) { + write_cell(new_worksheet, r as u32, c as u16, cell_data)?; + } + } + } + } + + // 保存文件 + new_workbook.save(file.as_path())?; + + println!("✓ 操作成功:已更新第 {} 行,从第 {} 列开始", target_row + 1, start_col + 1); + eprintln!(" 文件: {}, Sheet: {}", file.display(), sheet); + + Ok(()) +} + +// 删除数据(delete 命令) +fn delete_excel( + file: &PathBuf, + count: u32, + row: i32, + cell: Option, + sheet_name: &Option, +) -> Result<(), Box> { + use rust_xlsxwriter::*; + + if !file.exists() { + return Err(format!("文件不存在: {}", file.display()).into()); + } + + let mut workbook: Xlsx<_> = open_workbook(file.as_path())?; + + let sheet = match sheet_name { + Some(name) => name.clone(), + None => { + let sheets = workbook.sheet_names(); + if sheets.is_empty() { + return Err("Excel 文件中没有 sheet".into()); + } + sheets[0].clone() + } + }; + + let range = workbook + .worksheet_range(&sheet) + .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; + + let total_rows = range.height(); + let total_cols = range.width(); + + // 确定行号 + let target_row = if row < 0 { + let pos = total_rows as i32 + row; + if pos < 0 || pos >= total_rows as i32 { + return Err(format!("行号无效: {}", row).into()); + } + pos as usize + } else { + let pos = row as usize - 1; + if pos >= total_rows { + return Err(format!("行号超出范围: {} (总行数: {})", row, total_rows).into()); + } + pos + }; + + // 显示确认信息 + if let Some(c) = cell { + eprintln!("⚠ 警告:即将删除单元格"); + eprintln!(" 文件: {}", file.display()); + eprintln!(" Sheet: {}", sheet); + eprintln!(" 位置: 第 {} 行, 第 {} 列", target_row + 1, c); + } else { + eprintln!("⚠ 警告:即将删除以下数据:"); + eprintln!(" 文件: {}", file.display()); + eprintln!(" Sheet: {}", sheet); + eprintln!(" 删除行: 第 {} 行(共 {} 行)", target_row + 1, total_rows); + + // 显示要删除的行的内容 + eprint!(" 内容: "); + for c in 0..range.width() { + if let Some(cell_data) = range.get((target_row, c)) { + eprint!("{}", format_cell(cell_data)); + if c < range.width() - 1 { + eprint!(" | "); + } + } + } + eprintln!(); + } + eprintln!(); + + // 请求用户确认 + eprint!("是否继续?(y/N): "); + use std::io::{self, Write}; + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let input = input.trim().to_lowercase(); + + if input != "y" && input != "yes" { + println!("✗ 操作已取消"); + return Ok(()); + } + + // 创建新工作簿 + let mut new_workbook = Workbook::new(); + let new_worksheet = new_workbook.add_worksheet(); + new_worksheet.set_name(&sheet)?; + + if let Some(c) = cell { + // 删除单元格:将该单元格及其右侧的单元格左移 + let target_col = (c - 1) as usize; + let mut new_row = 0u32; + + for r in 0..total_rows { + let mut new_col = 0u16; + for col in 0..total_cols { + if r == target_row && col == target_col { + continue; // 跳过要删除的单元格 + } + if let Some(cell_data) = range.get((r, col)) { + write_cell(new_worksheet, new_row, new_col, cell_data)?; + new_col += 1; + } + } + new_row += 1; + } + + println!("✓ 操作成功:已删除第 {} 行第 {} 列的单元格", target_row + 1, c); + } else { + // 删除整行 + let mut new_row = 0u32; + for r in 0..total_rows { + if r == target_row { + continue; // 跳过要删除的行 + } + for c in 0..range.width() { + if let Some(cell_data) = range.get((r, c)) { + write_cell(new_worksheet, new_row, c as u16, cell_data)?; + } + } + new_row += 1; + } + + println!("✓ 操作成功:已删除第 {} 行", target_row + 1); + eprintln!(" 剩余行数: {}", total_rows - 1); + } + + // 保存文件 + new_workbook.save(file.as_path())?; + eprintln!(" 文件: {}, Sheet: {}", file.display(), sheet); + + Ok(()) +} + +// 辅助函数:写入单元格 +fn write_cell( + worksheet: &mut rust_xlsxwriter::Worksheet, + row: u32, + col: u16, + data: &Data, +) -> Result<(), rust_xlsxwriter::XlsxError> { + match data { + Data::String(s) => worksheet.write_string(row, col, s.as_str()).map(|_| ()), + Data::Int(v) => worksheet.write_number(row, col, *v as f64).map(|_| ()), + Data::Float(v) => worksheet.write_number(row, col, *v).map(|_| ()), + Data::Bool(b) => worksheet.write_boolean(row, col, *b).map(|_| ()), + _ => Ok(()), + } +} diff --git a/zip/.gitignore b/zip/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/zip/.gitignore @@ -0,0 +1 @@ +/target diff --git a/zip/Cargo.toml b/zip/Cargo.toml new file mode 100644 index 0000000..bdec47c --- /dev/null +++ b/zip/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "zip" +version = "0.1.1" +edition = "2024" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +zip = "2.2" +walkdir = "2.5" +arboard = "3.4" +clipboard-win = "5.4" +winapi = { version = "0.3", features = ["winuser"] } diff --git a/zip/README.md b/zip/README.md new file mode 100644 index 0000000..4f8d8fb --- /dev/null +++ b/zip/README.md @@ -0,0 +1,422 @@ +# zip - ZIP 打包工具 📦 + +一个快速、易用的命令行 ZIP 打包工具,支持将文件或文件夹打包成 zip 压缩文件。 + +## ✨ 功能特性 + +- 📁 **灵活输入**: 支持单个文件、多个文件或整个文件夹 +- 🗜️ **高效压缩**: 使用 Deflate 压缩算法 +- 📋 **剪贴板集成**: 可选择将生成的 zip 文件复制到剪贴板(Windows) +- 🎯 **智能命名**: 自动生成有意义的文件名 +- 📊 **详细反馈**: 显示打包进度和统计信息 +- ⚡ **高性能**: 基于 Rust 开发,速度快,资源占用低 +- 🔒 **跨平台**: 支持 Windows、macOS、Linux + +## 📦 安装 + +### 从源码编译 + +```bash +cd zip +cargo build --release +``` + +编译后的可执行文件位于 `target/release/zip.exe` (Windows) 或 `target/release/zip` (Unix) + +### 添加到系统路径(可选) + +将可执行文件复制到系统 PATH 目录,或将其所在目录添加到 PATH 环境变量中。 + +## 🚀 快速开始 + +### 基本用法 + +```bash +# 打包单个文件 +zip myfile.txt + +# 打包文件夹 +zip myfolder + +# 打包多个文件 +zip file1.txt file2.txt file3.txt +``` + +### 指定输出文件名 + +```bash +# 自定义输出的 zip 文件名 +zip myfile.txt -n archive.zip + +# 打包文件夹并指定名称 +zip myfolder -n backup.zip +``` + +### 复制到剪贴板 + +```bash +# 打包后自动复制文件到剪贴板(Windows) +zip myfile.txt -v + +# 打包文件夹并复制到剪贴板 +zip myfolder -v -n backup.zip +``` + +## 📖 命令参数 + +```bash +zip [OPTIONS] + +参数: + 📁 要打包的文件或文件夹路径(可以指定多个,必需) + -n, --name 📝 输出的 zip 文件名(默认自动生成) + -v, --clipboard 📋 复制 zip 文件路径到剪贴板 + -h, --help 显示帮助信息 + -V, --version 显示版本信息 +``` + +## 💡 使用示例 + +### 示例 1: 打包单个文件 + +```bash +zip document.pdf +``` + +**输出:** +- 生成 `document.zip` +- 位置: 与源文件相同的目录 + +### 示例 2: 打包整个文件夹 + +```bash +zip project-folder +``` + +**输出:** +- 生成 `project-folder.zip` +- 包含文件夹内的所有文件和子文件夹 +- 保留目录结构 + +### 示例 3: 打包多个文件 + +```bash +zip readme.md config.json main.py +``` + +**输出:** +- 生成 `readme.zip`(以第一个文件命名) +- 包含所有指定的文件 + +### 示例 4: 自定义输出文件名 + +```bash +zip important-docs -n docs-backup-2024.zip +``` + +**输出:** +- 生成 `docs-backup-2024.zip` +- 在当前目录创建 + +### 示例 5: 打包并复制到剪贴板(Windows) + +```bash +zip my-project -v -n project-archive.zip +``` + +**输出:** +- 生成 `project-archive.zip` +- 文件路径自动复制到剪贴板 +- 可以直接在文件资源管理器中粘贴 + +### 示例 6: 批量备份多个文件夹 + +```bash +zip folder1 folder2 folder3 -n multi-backup.zip +``` + +**输出:** +- 生成 `multi-backup.zip` +- 包含所有三个文件夹的内容 + +## 📊 输出说明 + +### 控制台输出示例 + +``` +📦 开始打包... +📂 源文件/文件夹: 3 个 + 1. "file1.txt" + 2. "file2.txt" + 3. "myfolder" +📄 输出文件: "archive.zip" + + ✓ file1.txt + ✓ file2.txt + ✓ myfolder/subfolder/file3.txt + ✓ myfolder/image.png +✅ 打包完成! +📊 共打包 4 个文件 +💾 文件大小: 1.25 MB +📍 保存位置: C:\Users\username\Documents\archive.zip +📋 已复制文件到剪切板: C:\Users\username\Documents\archive.zip +``` + +### 文件命名规则 + +如果不指定 `-n` 参数: +- **单个文件**: 使用文件的基本名 + `.zip` + - `document.pdf` → `document.zip` +- **多个文件**: 使用第一个文件的基本名 + `.zip` + - `file1.txt file2.txt` → `file1.zip` +- **文件夹**: 使用文件夹名 + `.zip` + - `myfolder` → `myfolder.zip` + +如果指定了 `-n` 参数: +- 使用指定的完整路径和文件名 + +## 🎯 应用场景 + +### 1. 项目备份 + +```bash +# 快速备份当前项目 +zip . -n project-backup-$(date +%Y%m%d).zip + +# Windows PowerShell +zip . -n "project-backup-$(Get-Date -Format 'yyyyMMdd').zip" +``` + +### 2. 文件分享 + +```bash +# 打包多个文档准备分享 +zip report.docx data.xlsx charts.png -n weekly-report.zip + +# 复制路径到剪贴板,方便通过聊天软件发送 +zip weekly-report.zip -v +``` + +### 3. 代码归档 + +```bash +# 打包源代码(排除 node_modules 等需要在 .gitignore 中配置) +zip src tests package.json -n source-code.zip +``` + +### 4. 日志收集 + +```bash +# 打包日志文件用于问题排查 +zip logs/ -n debug-logs.zip +``` + +### 5. 资源打包 + +```bash +# 打包游戏或应用资源 +zip assets/ configs/ -n game-resources.zip +``` + +## 📁 目录结构处理 + +### 打包文件夹时的结构保留 + +假设你有以下目录结构: + +``` +myproject/ +├── src/ +│ ├── main.rs +│ └── utils.rs +├── Cargo.toml +└── README.md +``` + +执行 `zip myproject` 后,zip 文件内部结构为: + +``` +myproject/ +├── src/ +│ ├── main.rs +│ └── utils.rs +├── Cargo.toml +└── README.md +``` + +解压时会完整保留目录结构。 + +### 打包多个独立文件 + +执行 `zip file1.txt file2.txt` 后,zip 文件内部: + +``` +file1.txt +file2.txt +``` + +文件直接放在根目录,不包含路径前缀。 + +## ⚙️ 技术细节 + +### 压缩算法 + +- **方法**: Deflate +- **压缩级别**: 默认级别(平衡速度和压缩率) +- **权限保留**: Unix 系统保留文件权限(0o755) + +### 依赖库 + +- **clap**: 命令行参数解析 +- **zip**: ZIP 文件读写 +- **walkdir**: 递归遍历目录 +- **clipboard-win**: Windows 剪贴板操作(仅 Windows) +- **arboard**: 跨平台剪贴板操作(非 Windows) +- **winapi**: Windows API 调用(仅 Windows) + +### 性能特点 + +- **流式写入**: 边读取边压缩,内存占用低 +- **并行处理**: 文件遍历使用 walkdir 优化 +- **增量打包**: 逐个添加文件,支持大文件 + +### 平台差异 + +#### Windows +- 使用 `clipboard-win` 和 `winapi` 实现文件复制到剪贴板 +- 支持直接复制文件对象(可在资源管理器中粘贴) +- 自动重试机制处理剪贴板占用问题 + +#### macOS / Linux +- 使用 `arboard` 实现文本复制到剪贴板 +- 复制的是文件路径字符串 +- 需要手动在文件管理器中打开路径 + +## ⚠️ 注意事项 + +1. **隐藏文件**: 默认会打包隐藏文件(以 `.` 开头的文件) +2. **符号链接**: 符号链接会被跟随,打包实际文件 +3. **空目录**: 空目录可能不会被包含在 zip 中 +4. **文件锁定**: 确保要打包的文件没有被其他程序独占锁定 +5. **磁盘空间**: 确保有足够的磁盘空间存放生成的 zip 文件 +6. **特殊字符**: 文件名中的特殊字符会被保留,但某些系统可能不兼容 + +## 🔧 常见问题 + +### Q1: 如何排除某些文件或文件夹? + +目前版本不支持排除功能。建议: +- 先将要打包的文件复制到临时目录 +- 或使用其他工具如 7-Zip、WinRAR + +未来版本可能会添加 `--exclude` 参数。 + +### Q2: 压缩包太大怎么办? + +- 检查是否包含了不必要的大文件(如视频、数据库) +- 考虑先压缩大文件再打包 +- 使用专业的压缩工具进行二次压缩 + +### Q3: 如何在 Linux/macOS 上使用剪贴板功能? + +在非 Windows 系统上,`-v` 参数会复制文件路径文本。你可以: +- 在终端中使用 `xdg-open` (Linux) 或 `open` (macOS) 打开路径 +- 或在文件管理器中使用"前往文件夹"功能粘贴路径 + +### Q4: 打包速度慢? + +- 检查是否有大量小文件(会增加 overhead) +- 确认磁盘读写速度 +- 考虑使用 SSD 而非 HDD + +### Q5: 如何处理中文文件名? + +工具完全支持 UTF-8 编码的中文文件名,在 Windows、macOS、Linux 上都能正常工作。 + +## 📝 完整工作流示例 + +### 工作流 1: 日常备份脚本 + +创建一个批处理文件 `backup.bat` (Windows): + +```batch +@echo off +set TIMESTAMP=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2% +set TIMESTAMP=%TIMESTAMP: =0% + +echo 正在备份项目... +zip my-project -n "backup-%TIMESTAMP%.zip" -v + +if %errorlevel% equ 0 ( + echo ✅ 备份成功! +) else ( + echo ❌ 备份失败! +) +``` + +### 工作流 2: 项目发布准备 + +```bash +# 1. 清理构建产物 +cargo clean + +# 2. 打包源代码 +zip src/ Cargo.toml README.md LICENSE -n myproject-source.zip + +# 3. 构建发布版本 +cargo build --release + +# 4. 打包可执行文件 +zip target/release/myapp -n myproject-v1.0.0.zip + +# 5. 复制路径到剪贴板 +zip myproject-v1.0.0.zip -v +``` + +### 工作流 3: 日志收集和问题报告 + +```bash +# 1. 创建临时目录 +mkdir temp-logs + +# 2. 复制相关日志文件 +cp logs/*.log temp-logs/ +cp config.yaml temp-logs/ + +# 3. 打包 +zip temp-logs -n bug-report-logs.zip + +# 4. 清理临时目录 +rm -rf temp-logs + +# 5. 复制到剪贴板方便上传 +zip bug-report-logs.zip -v +``` + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +可能的改进方向: +- 添加 `--exclude` 参数支持排除文件 +- 支持压缩级别选择 +- 添加密码保护功能 +- 支持分卷压缩 +- 显示压缩进度条 + +## 📄 许可证 + +本项目采用 MIT 许可证。 + +## 🙏 致谢 + +感谢以下开源项目: +- [clap](https://github.com/clap-rs/clap) - 优秀的命令行参数解析库 +- [zip](https://github.com/zip-rs/zip) - ZIP 文件格式支持 +- [walkdir](https://github.com/BurntSushi/walkdir) - 高效的目录遍历 +- [clipboard-win](https://github.com/DoumanAsh/clipboard-win) - Windows 剪贴板支持 +- [arboard](https://github.com/1Password/arboard) - 跨平台剪贴板支持 + +--- + +**Made with ❤️ using Rust** diff --git a/zip/src/main.rs b/zip/src/main.rs new file mode 100644 index 0000000..57625e9 --- /dev/null +++ b/zip/src/main.rs @@ -0,0 +1,257 @@ +use clap::Parser; +use std::path::PathBuf; +use std::fs::File; +use std::io::{Read, Write}; +use walkdir::WalkDir; +use zip::write::SimpleFileOptions; + +/// ZIP 打包工具 - 将文件或文件夹打包成 zip 格式 +#[derive(Parser, Debug)] +#[command(name = "zip")] +#[command(author, version, about, long_about = None)] +#[command( + about = "📦 ZIP 打包工具 - 将文件或文件夹快速打包成 zip 格式", + long_about = "📦 ZIP 打包工具\n\n将指定的文件或文件夹打包成 zip 压缩文件。\n支持单个文件、多个文件或整个文件夹的打包。\n\n示例:\n zip src\n zip file1.txt file2.txt -n archive.zip\n zip myfolder -v -n backup.zip" +)] +struct Args { + /// 📁 要打包的文件或文件夹路径(可以指定多个) + #[arg(required = true, value_name = "PATH")] + sources: Vec, + + /// 📝 输出的 zip 文件名(默认自动生成) + #[arg(short = 'n', long = "name", value_name = "FILE")] + name: Option, + + /// 📋 复制 zip 文件路径到剪切板 + #[arg(short = 'v', long = "clipboard")] + clipboard: bool, +} + +fn main() -> Result<(), Box> { + let args = Args::parse(); + + // 验证输入路径 + for source in &args.sources { + if !source.exists() { + eprintln!("❌ 错误: 路径不存在: {:?}", source); + std::process::exit(1); + } + } + + // 生成输出文件名 + let output_path = if let Some(name) = &args.name { + name.clone() + } else { + // 根据第一个源文件/文件夹生成默认名称 + let first_source = &args.sources[0]; + let stem = first_source + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("archive"); + + let mut path = if args.sources.len() == 1 { + first_source.parent().unwrap_or(std::path::Path::new(".")).to_path_buf() + } else { + std::env::current_dir()? + }; + path.push(format!("{}.zip", stem)); + path + }; + + println!("\n📦 开始打包..."); + println!("📂 源文件/文件夹: {} 个", args.sources.len()); + for (i, source) in args.sources.iter().enumerate() { + println!(" {}. {:?}", i + 1, source); + } + println!("📄 输出文件: {:?}\n", output_path); + + // 创建 zip 文件 + let zip_file = File::create(&output_path)?; + let mut zip_writer = zip::ZipWriter::new(zip_file); + + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o755); + + let mut file_count = 0; + let mut total_size = 0u64; + + // 遍历所有源路径 + for source in &args.sources { + if source.is_file() { + // 处理单个文件 + add_file_to_zip(&mut zip_writer, source, "", options, &mut file_count, &mut total_size)?; + } else if source.is_dir() { + // 处理文件夹 + add_dir_to_zip(&mut zip_writer, source, options, &mut file_count, &mut total_size)?; + } + } + + zip_writer.finish()?; + + println!("✅ 打包完成!"); + println!("📊 共打包 {} 个文件", file_count); + println!("💾 文件大小: {}", format_bytes(total_size)); + println!("📍 保存位置: {:?}", output_path.canonicalize()?); + + // 如果需要复制到剪切板 + if args.clipboard { + #[cfg(target_os = "windows")] + { + // 在 Windows 上复制文件到剪切板 + use clipboard_win::{formats::FileList, Setter}; + use winapi::um::winuser::{OpenClipboard, CloseClipboard, EmptyClipboard}; + use winapi::shared::windef::HWND; + use std::thread; + use std::time::Duration; + + // 获取绝对路径(不带 \\?\ 前缀) + let abs_path = if output_path.is_absolute() { + output_path.clone() + } else { + std::env::current_dir()?.join(&output_path) + }; + + // 转换为标准字符串格式 + let path_str = abs_path.to_string_lossy().to_string(); + let paths: Vec<&str> = vec![path_str.as_str()]; + + // 尝试多次打开剪贴板并复制文件 + for attempt in 0..5 { + unsafe { + // 尝试打开剪贴板 + if OpenClipboard(std::ptr::null_mut() as HWND) != 0 { + // 清空剪贴板 + EmptyClipboard(); + + // 尝试写入文件列表 + match FileList.write_clipboard(&paths) { + Ok(_) => { + println!("📋 已复制文件到剪切板: {}", path_str); + CloseClipboard(); + break; + } + Err(e) => { + CloseClipboard(); + if attempt < 4 { + thread::sleep(Duration::from_millis(200)); + } else { + eprintln!("⚠️ 复制文件失败: {}", e); + eprintln!("💡 提示: 请手动复制文件或使用资源管理器打开该位置"); + } + } + } + } else { + if attempt < 4 { + thread::sleep(Duration::from_millis(200)); + } else { + eprintln!("⚠️ 无法打开剪贴板,可能被其他程序占用"); + eprintln!("💡 提示: 请关闭其他使用剪贴板的程序后重试"); + } + } + } + } + } + + #[cfg(not(target_os = "windows"))] + { + let canonical_path = output_path.canonicalize()?; + let path_str = canonical_path.to_string_lossy().to_string(); + use arboard::Clipboard; + match Clipboard::new() { + Ok(mut clipboard) => { + match clipboard.set_text(path_str.clone()) { + Ok(_) => println!("📋 已复制路径到剪切板: {}", path_str), + Err(e) => eprintln!("⚠️ 复制到剪切板失败: {}", e), + } + } + Err(e) => eprintln!("⚠️ 无法访问剪切板: {}", e), + } + } + } + + println!(); + Ok(()) +} + +/// 将单个文件添加到 zip +fn add_file_to_zip( + writer: &mut zip::ZipWriter, + file_path: &std::path::Path, + base_path: &str, + options: SimpleFileOptions, + file_count: &mut usize, + total_size: &mut u64, +) -> Result<(), Box> { + let file_name = if base_path.is_empty() { + file_path.file_name().unwrap().to_string_lossy().to_string() + } else { + format!("{}/{}", base_path, file_path.file_name().unwrap().to_string_lossy()) + }; + + writer.start_file(file_name, options)?; + + let mut file = File::open(file_path)?; + let file_size = file.metadata()?.len(); + *total_size += file_size; + + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + writer.write_all(&buffer)?; + + *file_count += 1; + println!(" ✓ {}", file_path.display()); + + Ok(()) +} + +/// 将整个文件夹添加到 zip +fn add_dir_to_zip( + writer: &mut zip::ZipWriter, + dir_path: &std::path::Path, + options: SimpleFileOptions, + file_count: &mut usize, + total_size: &mut u64, +) -> Result<(), Box> { + for entry in WalkDir::new(dir_path).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + + if path.is_file() { + // 计算相对路径 + let relative_path = path.strip_prefix(dir_path.parent().unwrap_or(dir_path))?; + let file_name = relative_path.to_string_lossy().replace('\\', "/"); + + writer.start_file(file_name.clone(), options)?; + + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + *total_size += file_size; + + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + writer.write_all(&buffer)?; + + *file_count += 1; + println!(" ✓ {}", path.display()); + } + } + + Ok(()) +} + +/// 格式化字节大小 +fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +}