commit 227f92c45b5c59501866a878c9f471dedb57e91b Author: chen qiang <343005560@qq.com> Date: Tue Aug 25 16:21:51 2026 +0800 Initial import of WPF migration tool diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9708f0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Explicitly excluded legacy/deployment directories +/henlo_migration/ +/henlo_migration_core10/ +/产品部署/ + +# IDE and build outputs +**/.vs/ +**/bin/ +**/obj/ +/henlo_migration_wpf10/publish/ +/henlo_migration_wpf10/releases/ + +# Local database configuration and runtime data +/henlo_migration_wpf10/config.ini +/henlo_migration_wpf10/data/ +*.log + +# User-specific project settings +*.user +*.suo + +# Historical one-off helpers and backup files +/henlo_migration_wpf10/_build.bat +/henlo_migration_wpf10/_build.ps1 +/henlo_migration_wpf10/_debug_encoding.ps1 +/henlo_migration_wpf10/_make_pack.bat +/henlo_migration_wpf10/_write_pack.ps1 +/henlo_migration_wpf10/*.csproj.Backup*.tmp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..89b7d36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,166 @@ +# AGENTS.md + +## 项目定位 + +恒诺迁移工具是一个仅运行于 Windows 的 Oracle 数据库迁移桌面工具。当前维护目标是 `henlo_migration_wpf10/`:WPF + .NET 10,程序集名为“恒诺迁移工具”。工具从源库读取表结构、BOS 配置、存储过程/函数和表数据,将 SQL 保存为本地文本;经用户确认后,再把本地 SQL 执行到目标库。 + +本文是仓库级开发约定。分析、修改和验证默认都以当前 WPF 项目为准,除非任务明确点名旧项目。 + +## 目录边界 + +- `henlo_migration_wpf10/`:当前唯一主程序,优先分析和修改。 +- `henlo_migration/`:旧 .NET Framework 4.8 / WinForm 版本,已废弃;不要把旧版的窗体、AntdUI 或 AutoUpdater.NET 设计套到主程序。 +- `henlo_migration_core10/`:未投入使用的 .NET Core 版本,默认不修改。 +- `hc_preview/`:界面预览或辅助内容,不是主程序源码。 +- `产品部署/`:部署相关资料或产物,只有发布任务才处理。 +- `VELOPACK_MIGRATION.md`、`VELOPACK操作手册.md`:更新与发布背景资料;若与代码冲突,以当前代码和 `pack.bat` 为准。 + +## Git 仓库 + +- 项目远程仓库:`https://gitea.momozhua.site/qiangayi/henlo_migration_wpf10.git` +- 当前根目录已经初始化为 Git 工作区,默认分支为 `main`,远程名为 `origin`。 +- 可以使用 `git status`、`git diff` 和提交历史检查改动。不要使用 `git reset --hard`、强制推送或其他会丢失本地/远程改动的命令。 +- fetch、pull、commit、push 等会改变工作区、历史或远程状态的操作必须符合用户当前任务;上传远程仓库需要明确授权。 + +## 当前技术基线 + +- 项目文件:`henlo_migration_wpf10/henlo_migration_wpf10.csproj` +- 目标框架:`net10.0-windows` +- UI:WPF + HandyControl 3.5.1 +- MVVM:手写 `INotifyPropertyChanged`/命令实现,引用 CommunityToolkit.Mvvm 8.3.2,但当前主 ViewModel 未使用其源生成器 +- Oracle 访问:SqlSugar 5.1.4.157 + Oracle.ManagedDataAccess.Core 23.5.0 +- 配置解析:ini-parser 2.5.2 +- JSON:Newtonsoft.Json 13.0.3 +- 自动更新:Velopack 0.0.1298 +- 当前版本号:以 `.csproj` 的 `` 为唯一事实来源,不在本文硬编码版本 +- Nullable:项目设置为 `disable`,源码中仍存在 `?` 注解,因此构建有既有 `CS8632` 警告 + +## 主程序结构 + +### 启动与界面 + +- `App.xaml`:应用资源、HandyControl 主题和 `MainViewModel` 资源注册;启动页是 `Views/HomeWindow.xaml`。 +- `App.xaml.cs`:Velopack 初始化、后台/手动更新、更新前后 `data/` 与 `pre/` 备份恢复、全局异常日志。 +- `Views/HomeWindow.xaml`:单窗口主界面,包含四个迁移 Tab、连接状态、SQL 编辑区和批量操作。 +- `Views/HomeWindow.xaml.cs`:只处理窗口关闭和手动检查更新等窗口事件。 +- `Views/ConnectionDialog.*`:源库/目标库连接配置与测试。 +- `Views/ErrorDialog.*`:错误详情展示和复制。 +- `Behaviors/ClickToCopyBehavior.cs`、`Converters/StatusColorConverter.cs`:界面辅助行为与状态颜色转换。 + +### 状态与编排 + +- `ViewModels/MainViewModel.cs`:主状态容器和命令编排;初始化四个 `TabServices`,管理数据库连接、单行/批量读写、SQL 编辑与断点续写。 +- `Models/TableRowModel.cs`:每个迁移对象的名称、所属 Tab、读写状态。 +- `Enums/`:Tab、状态、工作类型和输出扩展名枚举。 + +### 业务与数据访问 + +- `Services/TabConfig.cs` / `ITabConfig.cs`:将 Tab、索引文件、输出后缀和读取委托组合成配置。 +- `Services/TabServices.cs`:索引列表加载/保存、单行读写、排序、删除和状态切换。 +- `Services/DbServices.cs`:Oracle SQL 的提取与执行核心: + - `GetTableSql`:读取表 DDL、注释、主外键、唯一键和索引; + - `GetTableBos`:读取 BOS 配置; + - `GetPrcSql` / `GetAllPrc`:读取存储过程或函数源码; + - `GetTableData`:生成表数据 INSERT SQL; + - `ExecuteCreate`:按 `\n/` 分隔 SQL,在目标库逐条执行并检查 `USER_ERRORS`; + - `PreProcess`:在源库执行 `pre/` 下全部脚本。 +- `Common/SqlSugarHelper.cs`:维护 `orig`(源库)和 `dest`(目标库)两个 SqlSugar 连接。 +- `Common/SysConfig.cs`:读取和写入运行目录下的 `config.ini`。 +- `Common/CommonHelper.cs`:运行目录、`data/`、`data/from/`、`pre/` 路径及文本读写。 + +## 真实数据流 + +四个 Tab 的映射由 `MainViewModel` 构造函数定义: + +| Tab | 索引文件 | 内容文件 | 源库读取方法 | +| --- | --- | --- | --- | +| 表配置 | `data/from/table.txt` | `data/{对象名}_creat.txt` | `DbServices.GetTableSql` | +| BOS 配置 | `data/from/bos.txt` | `data/{对象名}_bos.txt` | `DbServices.GetTableBos` | +| 存储过程 | `data/from/prc.txt` | `data/{对象名}_prc.txt` | `DbServices.GetPrcSql` | +| 表数据 | `data/from/data.txt` | `data/{对象名}_data.txt` | `DbServices.GetTableData` | + +读取流程:索引项 -> 调用源库读取委托 -> 生成 SQL 文本 -> 保存到 `data/` -> 更新读取状态并显示内容。 + +写入流程:索引项 -> 读取对应 `data/*_{后缀}.txt` -> `DbServices.ExecuteCreate` -> 在目标库执行 -> 更新写入状态。批量写入遇到第一条失败即停止,并在内存中记录断点;断点不会跨进程持久化。 + +删除列表项会同时删除对应内容文件。调整顺序会立即覆盖索引文件。新增对象名会被转为小写,数据库读取时再按需要转大写。 + +## 配置和运行时文件 + +- `config.ini` 保存源库和目标库的 IP/服务名、用户名、密码。当前实现是明文读写,不要把注释或 UI 文案误当成加密能力。 +- 不要在日志、回答、补丁或测试输出中展示真实连接串和密码。 +- 修改或发布时不要用样例配置覆盖用户现有 `config.ini`。 +- `data/` 和 `pre/` 是用户可编辑、更新时需要保留的运行数据;不要无故清空、批量改名或删除。 +- `error.log` 是全局异常日志,`execute_log.txt` 记录目标库执行的 SQL;两者都是运行时文件,排查时可能含敏感业务信息。 +- `bin/`、`obj/`、`publish/`、`releases/`、`.vs/` 是构建、IDE 或发布产物。不要手工修改其中的 DLL、EXE、PDB、NuGet 包或生成源码。 +- `*.csproj.Backup*.tmp`、`_build*`、`_make_pack.bat`、`_write_pack.ps1`、`_debug_encoding.ps1` 是历史/辅助文件;正常构建和发布不要以它们为权威入口。 + +## 开发约定 + +- 保持现有命名空间 `henlo_migration` 和现有目录分层。 +- UI 状态、命令和业务编排放在 `MainViewModel`;可绑定的属性变更必须触发 `OnPropertyChanged`,命令可用性变化时同步触发 `RaiseCanExecuteChanged`。 +- XAML 负责布局、样式和绑定;code-behind 仅保留必须依赖 Window/控件事件的逻辑。 +- 通用 Oracle 提取/执行逻辑放在 `DbServices`,不要在 View 或 ViewModel 中复制 SQL。 +- 新增 Tab 能力时同时检查 `TabsEnum`、`CodeExt`、`TabConfig`、`MainViewModel` 映射、XAML 绑定、索引文件复制规则和更新备份范围。 +- 耗时数据库调用不得阻塞 UI 线程;沿用现有异步命令和 `Task.Run` 边界,UI 更新通过 WPF Dispatcher 或正常 await 回到 UI 上下文。 +- 保持 SQL 文件的 Oracle 分隔约定:`DbServices.SplitStatements` 依赖换行后的 `/`(实际分隔符为 `\n/`)。修改生成 SQL 时必须验证普通 DDL、PL/SQL 块和末尾分隔符。 +- 数据库对象名和用户数据不能直接拼入任意 SQL。查询参数优先使用 `SugarParameter`;确需生成 DDL 时必须处理标识符和字符串转义。 +- 不要静默吞掉新异常。UI 可展示友好提示,但排障信息应保留在错误对话框或日志中;修改现有空 `catch` 时要评估用户体验。 +- 不做与任务无关的大规模格式化、Nullable 改造、依赖升级或旧项目同步。 + +## 数据库安全边界 + +源库和目标库不是等价环境: + +- `Get*` 方法通常查询源库;`ExecuteCreate` 明确写目标库。 +- `PreProcess` 会在源库执行 `pre/` 下的 SQL/PLSQL,虽然名为预处理,但不是只读操作。 +- 连接按钮会把界面中的连接信息写回 `config.ini`,并重建同时包含源库和目标库的 SqlSugarScope。 +- 界面的“断开”当前只更新连接状态,并未释放或关闭底层连接对象。 + +除非用户明确要求并确认数据库、对象和影响范围,否则不要连接真实数据库、执行预处理、执行写入、清理数据或测试破坏性 SQL。需要验证数据库写入逻辑时优先使用代码审查、可控测试库或事务化的最小复现。真实目标库写入前必须提醒备份并确认权限。 + +## 构建与验证 + +在仓库根目录运行: + +```powershell +dotnet restore .\henlo_migration_wpf10\henlo_migration_wpf10.csproj +dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug +``` + +仅在依赖已经恢复且不希望访问包源时可用: + +```powershell +dotnet build .\henlo_migration_wpf10\henlo_migration_wpf10.csproj -c Debug --no-restore +``` + +当前没有测试项目。最低验证要求: + +1. 所有 C#/XAML 修改至少执行一次主项目构建。 +2. 仅文档修改可不构建,但要重新读取文件检查 Markdown、路径和命令。 +3. XAML/绑定修改除构建外,应在 Windows 上启动应用,检查四个 Tab、连接弹窗、按钮可用性和错误弹窗;不要因此连接生产库。 +4. SQL 生成/拆分修改需用不含真实数据的代表性 SQL 验证表 DDL、注释、约束、PL/SQL 和 INSERT。 +5. 更新逻辑修改需分别考虑普通 `dotnet run`(非 Velopack 安装)和 Velopack 安装态。 + +既有 `CS8632` 警告不是本次修改自动引入的错误,但不得新增编译错误。若任务专门处理 Nullable,应统一决定启用 Nullable 还是移除注解,不要零散压制警告。 + +本地调试可运行: + +```powershell +dotnet run --project .\henlo_migration_wpf10\henlo_migration_wpf10.csproj +``` + +启动会读取或创建运行目录下的配置,并可能尝试初始化更新检查;不适合无界面的 CI 验证。 + +## 版本与发布 + +- 版本号只修改 `.csproj` 中的 ``,必须单调递增;显示版本来自程序集元数据。 +- 正式发布入口是 `henlo_migration_wpf10/pack.bat`。脚本会清理 `publish/`、执行 `dotnet publish -c Release --self-contained false -r win-x64`,再调用 `vpk pack --noInst` 输出到 `releases/`。 +- `pack.bat` 依赖本机 `dotnet`、`vpk` 和 Windows 批处理环境;不要把“构建通过”等同于“打包通过”。 +- 发布是显式任务。未经要求不要运行打包、删除发布目录、修改版本号或上传更新服务器。 +- 打包后核对便携 ZIP、full/delta nupkg、`RELEASES`/JSON 元数据以及实际版本号。上传服务器属于外部写操作,必须另行获得明确授权。 +- 若修改运行时必需文件,检查 `.csproj` 的 `CopyToOutputDirectory` 规则和 `App.xaml.cs` 的更新备份/恢复范围。 + +## 完成任务时的交付说明 + +简要说明:修改了什么、影响哪一层、执行了哪些验证、构建是否有错误/警告、是否因缺少 Oracle 测试库或 Velopack 安装态而存在未验证项。不要声称已验证真实数据库迁移,除非确实在明确授权的测试环境完成。 diff --git a/docs/history/VELOPACK_MIGRATION.md b/docs/history/VELOPACK_MIGRATION.md new file mode 100644 index 0000000..703d720 --- /dev/null +++ b/docs/history/VELOPACK_MIGRATION.md @@ -0,0 +1,99 @@ +# Velopack 迁移总结 + +## 目标 +将自动更新从 AutoUpdater.NET 迁移到 Velopack,解决 .NET 8 ZipExtractor 依赖问题。 + +## 根本原因 +AutoUpdater.NET 1.9.0 内部嵌入了 ZipExtractor.exe(.NET 8 WinForms 程序),用户机器只有 .NET 10 时无法运行。 + +## 修改文件 + +### 1. `henlo_migration_wpf10.csproj` +- 移除: `Autoupdater.NET.Official` 1.9.0 +- 添加: `Velopack` 0.0.1298 + +### 2. `App.xaml.cs` +- 移除所有 AutoUpdater 相关代码 +- 添加 Velopack 初始化: `VelopackApp.Build().Run()` +- 添加 `UpdateManager` 用于检查/下载/应用更新 +- 后台静默检查更新 +- 手动检查更新支持弹窗确认 + +### 3. `Views/HomeWindow.xaml.cs` +- `CheckUpdate_Click` 改为 async void,调用 `CheckUpdateManualAsync` + +### 4. `pack.bat` (新增) +Velopack 打包脚本,用法: +```batch +pack.bat 1.0.002 +``` + +## Velopack 工作流程 + +``` +开发机: + dotnet publish → vpk pack → releases/ + ├── setup.exe (安装程序) + ├── update.nupkg (更新包) + └── RELEASES (更新索引) + +服务器: + 上传 releases/ 到 http://zjhenlo.henlo.net:8878/henlo_migration/auto_update/ + +客户端: + 启动 → VelopackApp.Build().Run() + → UpdateManager.CheckForUpdatesAsync() + → 有新版本 → DownloadUpdatesAsync() + → 提示用户重启 → ApplyUpdatesAndRestart() +``` + +## 关键 API + +```csharp +// 初始化(必须在启动时调用) +VelopackApp.Build().Run(); + +// 检查更新 +var mgr = new UpdateManager("https://.../updates"); +var newVersion = await mgr.CheckForUpdatesAsync(); + +// 下载更新 +await mgr.DownloadUpdatesAsync(newVersion); + +// 应用并重启 +mgr.ApplyUpdatesAndRestart(newVersion); +``` + +## 打包命令 + +```bash +# 1. 发布应用 +dotnet publish -c Release -o ./publish --self-contained false -r win-x64 + +# 2. Velopack 打包 +vpk pack -u "henlo_migration" -v "1.0.002" -p "./publish" -e "恒诺迁移工具.exe" -o "./releases" +``` + +## 服务器部署 + +将 `releases/` 目录下的文件上传到: +``` +http://zjhenlo.henlo.net:8878/henlo_migration/auto_update/ +``` + +Velopack 会自动读取该目录下的 `RELEASES` 文件检查更新。 + +## 优势 + +1. **Rust 核心** → Updater 完全不需要 .NET Runtime +2. **Delta 更新** → 只下载变更文件 +3. **自动回滚** → 更新失败自动恢复旧版本 +4. **后台静默更新** → 下次启动时自动应用 +5. **经过大规模验证** → osu!、STranslate 等大牌在用 + +## 待办 + +- [ ] 测试打包流程 +- [ ] 配置服务器更新目录 +- [ ] 测试完整更新流程 +- [ ] 更新 CI/CD 脚本(如有) diff --git a/docs/history/VELOPACK操作手册.md b/docs/history/VELOPACK操作手册.md new file mode 100644 index 0000000..aa3c2a5 --- /dev/null +++ b/docs/history/VELOPACK操作手册.md @@ -0,0 +1,432 @@ +# Velopack 操作手册 — 恒诺迁移工具(便携模式) + +## 一、Velopack 是什么 + +Velopack 是一个**自动更新框架**,核心功能: +1. **打包** — 把程序编译输出打包成更新包(.nupkg) +2. **自动更新** — 程序启动时自动检查服务器,发现新版本就下载并提示用户重启 + +**核心优势**:更新器用 Rust 编写,不需要 .NET Runtime,彻底解决 AutoUpdater.NET 的 .NET 8 依赖问题。 + +**本模式**:便携模式(`--noInst`),不生成安装包,用户直接解压 ZIP 使用。 + +--- + +## 二、项目结构(打包相关) + +``` +henlo_migration_wpf10/ +├── henlo_migration_wpf10.csproj # 项目文件(已添加 Velopack 包) +│ └── 1.0.2 # 【版本号唯一来源】 +├── App.xaml.cs # 启动时初始化 Velopack +├── Views/HomeWindow.xaml.cs # "检查更新"按钮逻辑 +├── publish/ # 【发布输出目录】dotnet publish 生成 +├── releases/ # 【打包输出目录】vpk pack 生成 +│ ├── henlo_migration-1.0.2-full.nupkg # 更新包(上传到服务器) +│ ├── henlo_migration-win-Portable.zip # 便携版(给用户第一次使用) +│ ├── RELEASES # 更新索引文件(上传到服务器) +│ ├── assets.win.json # 资源清单 +│ └── releases.win.json # 发布配置 +└── pack.bat # 一键打包脚本(已创建) +``` + +--- + +## 三、版本号管理(重要) + +### 唯一来源:csproj 文件 + +**版本号只存在于一个地方**:`henlo_migration_wpf10.csproj` 中的 `` 标签 + +```xml + + 1.0.2 + +``` + +### 版本号格式(SemVer2) + +``` +主版本.次版本.补丁 +``` + +| 格式 | 示例 | 说明 | +|------|------|------| +| ✅ 正确 | `1.0.2`、`1.1.0`、`2.0.0` | 三段数字,无前导零 | +| ❌ 错误 | `1.0.02`、`1.0.002` | 不能前导零 | +| ❌ 错误 | `1.0` | 必须是三段 | + +### 版本号递增规则 + +| 位置 | 什么时候改 | 示例 | +|------|-----------|------| +| 补丁(第3位) | Bug 修复 | `1.0.1` → `1.0.2` | +| 次版本(第2位) | 新增功能 | `1.0.2` → `1.1.0` | +| 主版本(第1位) | 重大更新/不兼容 | `1.1.0` → `2.0.0` | + +**重要**:版本号必须递增,不能回退(Velopack 不允许降级)。 + +### 版本号同步机制 + +``` +csproj 标签 + │ + ├──→ 程序集版本(编译时自动嵌入 DLL) + ├──→ 程序运行时显示(SysConfig.version 从程序集读取) + └──→ Velopack 打包版本(pack.bat 自动读取) +``` + +**只需要改一处**,其他地方全部自动同步。 + +--- + +## 四、发布流程(完整步骤) + +### 步骤 1:修改版本号 + +打开 `henlo_migration_wpf10.csproj`,修改 ``: + +```xml + +1.0.1 + + +1.0.2 +``` + +**注意**: +- 只改这个数字,不要改其他地方 +- 确保新版本号比旧版本大 +- 保存文件 + +--- + +### 步骤 2:编译发布 + +```bash +# 进入项目目录 +cd D:\work\恒诺\长期支持\恒诺迁移工具\henlo_migration_wpf10 + +# 发布(Release 模式,输出到 publish/ 目录) +dotnet publish henlo_migration_wpf10.csproj -c Release -o publish --self-contained false -r win-x64 +``` + +**参数说明**: +| 参数 | 含义 | +|------|------| +| `-c Release` | Release 模式(优化过的,体积小) | +| `-o publish` | 输出到 `publish/` 目录 | +| `--self-contained false` | 不包含 .NET Runtime(假设用户已装 .NET 10) | +| `-r win-x64` | 目标平台:Windows x64 | + +**输出**:`publish/` 目录下生成完整的程序文件(.exe、.dll 等) + +--- + +### 步骤 3:Velopack 打包(便携模式) + +```bash +# 打包(使用 vpk 命令行工具,--noInst 表示不生成安装包) +vpk pack -u henlo_migration -v 1.0.2 -p publish -e 恒诺迁移工具.exe -o releases --noInst +``` + +**参数说明**: +| 参数 | 含义 | +|------|------| +| `-u henlo_migration` | 应用唯一标识符(URL 友好的名字) | +| `-v 1.0.2` | 版本号(必须是 SemVer2 格式) | +| `-p publish` | 输入目录(步骤 2 的输出) | +| `-e 恒诺迁移工具.exe` | 程序入口 EXE 文件名 | +| `-o releases` | 输出目录(打包结果放这里) | +| `--noInst` | **不生成安装包**(便携模式) | + +**输出**:`releases/` 目录下生成 5 个文件(无 Setup.exe) + +--- + +### 步骤 4:上传到服务器 + +把以下文件上传到更新服务器: + +``` +http://zjhenlo.henlo.net:8878/henlo_migration/auto_update/ +├── henlo_migration-1.0.2-full.nupkg ← 必须(更新包) +└── RELEASES ← 必须(更新索引) +``` + +**注意**: +- 每次发布新版本,上传新的 `.nupkg` 文件即可 +- `RELEASES` 文件会被 Velopack 自动更新(包含所有版本列表) +- 不需要上传 `Portable.zip` 到服务器(那是给用户第一次下载用的) + +--- + +## 五、一键打包脚本 + +已创建 `pack.bat`,用法: + +```batch +# 直接运行,版本号自动从 csproj 读取 +pack.bat +``` + +**脚本内容**: +```batch +@echo off +setlocal enabledelayedexpansion + +REM Velopack pack script (portable mode, no installer) +REM Reads from csproj automatically + +set "PROJECT_DIR=%~dp0" +set "PUBLISH_DIR=%PROJECT_DIR%publish" +set "RELEASE_DIR=%PROJECT_DIR%releases" +set "CSPROJ=%PROJECT_DIR%henlo_migration_wpf10.csproj" + +REM Read from csproj via PowerShell (handles UTF-8) +for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "[regex]::Match(([System.IO.File]::ReadAllText('%CSPROJ%', [System.Text.Encoding]::UTF8)), '(.+?)').Groups[1].Value"`) do set "VERSION=%%a" + +if "!VERSION!"=="" ( + echo ERROR: Cannot read version from csproj + exit /b 1 +) + +echo ======================================== +echo Henlo Migration Tool - Velopack Pack +echo Version: !VERSION! +echo ======================================== + +echo [1/3] Cleanup... +if exist "!PUBLISH_DIR!" rmdir /s /q "!PUBLISH_DIR!" +if exist "!RELEASE_DIR!" rmdir /s /q "!RELEASE_DIR!" + +echo [2/3] dotnet publish... +dotnet publish "%CSPROJ%" -c Release -o "!PUBLISH_DIR!" --self-contained false -r win-x64 +if errorlevel 1 ( + echo ERROR: dotnet publish failed! + exit /b 1 +) + +echo [3/3] vpk pack (--noInst)... +vpk pack -u "henlo_migration" -v "!VERSION!" -p "!PUBLISH_DIR!" -e "恒诺迁移工具.exe" -o "!RELEASE_DIR!" --noInst +if errorlevel 1 ( + echo ERROR: vpk pack failed! + exit /b 1 +) + +echo ======================================== +echo Pack complete! +echo Output: +echo henlo_migration-win-Portable.zip +echo henlo_migration-!VERSION!-full.nupkg +echo RELEASES +echo Server deploy to: +echo http://zjhenlo.henlo.net:8878/henlo_migration/auto_update/ +echo ======================================== + +endlocal +``` + +--- + +## 五点五、重要:中文路径编码问题 + +### 问题描述 + +项目路径包含中文字符(如 `D:\work\恒诺\...`),在某些场景下会导致编码问题: + +| 工具/场景 | 是否受影响 | 说明 | +|----------|----------|------| +| **pack.bat 双击运行** | ❌ 正常 | CMD 直接执行,编码正确 | +| **手动执行 PowerShell** | ❌ 正常 | PowerShell 能正确处理 UTF-8 | +| **通过 AI Agent exec 工具** | ⚠️ **受影响** | 中文路径可能被损坏,导致命令执行失败 | + +### 问题表现 + +当 exec 工具执行包含中文路径的命令时,可能出现: +``` +# 预期命令 +dir /b "D:\work\恒诺\长期支持\..." + +# 实际执行的命令(路径被截断) +dir /b "D:\b" +``` + +### 解决方案 + +如果需要通过 AI Agent 执行涉及中文路径的操作,使用以下策略: + +1. **写入脚本文件再执行** + ``` + # 不要直接执行带中文路径的命令 + ❌ exec: dir "D:\work\恒诺\..." + + # 应先写脚本文件,再执行简单路径 + ✅ write: 将 Node.js/Python 脚本写入 _helper.js/_helper.py + ✅ exec: node "D:\work\恒诺\...\_helper.js" # 路径简单时可行 + # 或使用英文路径的项目目录 + ``` + +2. **使用相对路径**(如果在项目目录内操作) + ``` + cd /d "D:\work\恒诺\长期支持\恒诺迁移工具\henlo_migration_wpf10" + dir /b publish # 使用相对路径避免中文 + ``` + +3. **创建符号链接**(临时方案) + ```cmd + mklink /D C:\henlo "D:\work\恒诺\长期支持\恒诺迁移工具" + # 之后操作使用 C:\henlo\... 路径 + ``` + +### 最佳实践 + +- **打包操作**:直接在项目目录双击 `pack.bat` 运行,不要通过 AI Agent 执行 +- **文件操作**:AI Agent 使用 `write`/`read` 工具(这些工具能正确处理中文路径) +- **命令执行**:尽量使用不含中文字符的路径,或先写脚本再执行 + +--- + +## 六、打包目录说明 + +### 输入目录(`-p` 参数) + +``` +publish/ ← 你指定的输入目录 +├── 恒诺迁移工具.exe ← 入口程序(-e 参数指定) +├── 恒诺迁移工具.dll +├── 各种依赖 DLL +├── HandyControl.dll +├── Velopack.dll +└── ...其他文件 +``` + +**来源**:`dotnet publish` 的输出 + +### 输出目录(`-o` 参数) + +``` +releases/ ← 打包结果 +├── henlo_migration-1.0.2-full.nupkg # 完整更新包(上传服务器) +├── henlo_migration-win-Portable.zip # 便携版(给用户第一次用) +├── RELEASES # 版本索引(上传服务器) +├── assets.win.json # 资源清单 +└── releases.win.json # 发布配置 +``` + +**注意**:没有 `Setup.exe`(因为用了 `--noInst`) + +--- + +## 七、用户侧体验 + +### 第一次使用(新用户) + +1. 从服务器下载 `henlo_migration-win-Portable.zip` +2. 解压到任意目录(如 `D:\恒诺迁移工具\`) +3. 双击 `恒诺迁移工具.exe` 运行 + +### 以后更新(自动) + +1. 打开程序(旧版本) +2. Velopack 自动检查服务器 `RELEASES` 文件 +3. 发现新版本 → 后台下载 `.nupkg` 更新包 +4. 弹出提示:"发现新版本 1.0.2,是否重启更新?" +5. 用户点击"是" → 程序关闭 → 自动安装更新 → 重启程序 +6. 打开后就是新版本 + +### 手动检查更新 + +用户也可以点击菜单「帮助」→「检查更新」手动触发。 + +--- + +## 八、服务器配置 + +### 目录结构 + +``` +/var/www/henlo_migration/auto_update/ # Nginx 根目录 +├── henlo_migration-1.0.0-full.nupkg # 旧版本(保留) +├── henlo_migration-1.0.1-full.nupkg # 旧版本(保留) +├── henlo_migration-1.0.2-full.nupkg # 当前版本 +└── RELEASES # 版本索引(自动维护) +``` + +**注意**:不需要放 `Setup.exe` 和 `Portable.zip` 在服务器上。 + +### RELEASES 文件示例 + +``` +9467F3A6D8E1B2C henlo_migration-1.0.0-full.nupkg 5915018 +A7B8C9D0E1F2A3B henlo_migration-1.0.1-full.nupkg 5915018 +C3D4E5F6A7B8C9D henlo_migration-1.0.2-full.nupkg 5915018 +``` + +**注意**:不要手动修改 `RELEASES` 文件,Velopack 会自动维护。 + +--- + +## 九、常见问题 + +### Q1:打包时提示 "VelopackApp.Run() was found in OnStartup" + +**原因**:Velopack 建议把初始化代码放在 `Main()` 方法开头,而不是 WPF 的 `OnStartup`。 + +**解决**:可以忽略(当前位置也能工作),或者按提示移到 `Main()` 里。 + +### Q2:用户无法自动更新 + +**检查清单**: +1. 服务器 URL 是否正确?(`App.xaml.cs` 中的 `UpdateUrl`) +2. `RELEASES` 文件是否在服务器上? +3. `.nupkg` 文件是否上传到服务器? +4. 防火墙是否允许访问服务器? +5. 版本号是否递增?(Velopack 不允许降级) + +### Q3:如何回滚版本? + +Velopack **不支持回滚**。如果新版本有问题: +1. 修复 bug +2. 发布更高版本号(如 1.0.3) +3. 用户更新到 1.0.3 + +### Q4:用户第一次如何获取程序? + +把 `henlo_migration-win-Portable.zip` 发给用户: +- 通过 QQ、微信、邮件发送 +- 或放在网盘/服务器上下载 +- 用户下载后解压即可使用 + +--- + +## 十、发布检查清单 + +每次发布前检查: + +- [ ] 代码已提交 Git +- [ ] **版本号已更新**(只改 `csproj` 里的 ``) +- [ ] `dotnet publish` 成功 +- [ ] `vpk pack --noInst` 成功(或 `pack.bat` 成功) +- [ ] 测试程序能正常启动 +- [ ] 上传 `.nupkg` 和 `RELEASES` 到服务器 +- [ ] 测试自动更新流程 +- [ ] 把 `Portable.zip` 发给需要的新用户 + +--- + +## 十一、命令速查表 + +| 操作 | 命令 | +|------|------| +| 修改版本号 | 编辑 `henlo_migration_wpf10.csproj` 中的 `` | +| 编译发布 | `dotnet publish -c Release -o publish --self-contained false -r win-x64` | +| Velopack 打包(便携模式) | `vpk pack -u henlo_migration -v 1.0.2 -p publish -e 恒诺迁移工具.exe -o releases --noInst` | +| 一键打包 | `pack.bat`(版本号自动从 csproj 读取) | +| 安装 vpk 工具 | `dotnet tool install -g vpk` | +| 更新 vpk 工具 | `dotnet tool update -g vpk` | + +--- + +*文档版本:2026-04-22(便携模式)* +*适用项目:恒诺迁移工具 WPF* diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..523fc50 Binary files /dev/null and b/favicon.ico differ diff --git a/henlo_migration_wpf10/ARTIFACT_20260422.md b/henlo_migration_wpf10/ARTIFACT_20260422.md new file mode 100644 index 0000000..279549f --- /dev/null +++ b/henlo_migration_wpf10/ARTIFACT_20260422.md @@ -0,0 +1,42 @@ +# 恒诺迁移工具 - Velopack 自动更新修复 (2026-04-22) + +## 问题 + +error.log 报错: +``` +[AutoUpdate] This operation can not be performed in an application that is not installed. + at Velopack.UpdateManager.EnsureInstalled() + at Velopack.UpdateManager.CheckForUpdatesAsync() +``` + +**根本原因**:`UpdateManager` 内部调用 `EnsureInstalled()` 检查应用是否通过 Velopack 安装, +直接双击 exe 运行(未安装)时抛出异常导致启动失败。 + +## 修复内容 + +修改 `App.xaml.cs`: + +1. **Velopack 初始化兜底**:用 `try/catch` 包裹 `WindowsVelopackLocator` 构造, + 捕获 "not installed" 相关异常后将 `_updateManager = null` +2. **`_updateManager == null` 时跳过后台检查**:`CheckUpdateInBackgroundAsync()` 开头加 null 检查 +3. **手动检查时友好提示**:`CheckUpdateManualAsync()` 检测到 null 时弹窗提示"不支持自动更新" +4. **修复 API 调用**:补全 `WindowsVelopackLocator` 缺少的第3个参数(`IVelopackLogger?` 传 null) +5. **添加 `using System.Diagnostics`**:修复 `Process.GetCurrentProcess()` 找不到的问题 + +## 编译结果 + +✅ Release 编译成功,0 错误,80 个警告(均为 CS8632 nullable / NU1701 framework 兼容警告,不影响运行) + +## 修复后行为 + +| 场景 | 行为 | +|------|------| +| 直接双击 exe(未安装) | `_updateManager = null`,跳过更新,静默启动 | +| 手动点击"检查更新" | 弹窗提示"不支持自动更新" | +| 通过 Velopack 安装 | 正常检查更新 | + +## 待验证 + +- [ ] 重新 `dotnet publish` 输出到 publish/ 目录 +- [ ] 在目标机器(无 .NET Runtime)上验证能否正常启动 +- [ ] 测试 Velopack 正式安装后的自动更新流程 diff --git a/henlo_migration_wpf10/App.xaml b/henlo_migration_wpf10/App.xaml new file mode 100644 index 0000000..3afc8ec --- /dev/null +++ b/henlo_migration_wpf10/App.xaml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/henlo_migration_wpf10/App.xaml.cs b/henlo_migration_wpf10/App.xaml.cs new file mode 100644 index 0000000..578c0e8 --- /dev/null +++ b/henlo_migration_wpf10/App.xaml.cs @@ -0,0 +1,230 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using System.Windows; +using Velopack; +using Velopack.Locators; +using Velopack.Sources; + +namespace henlo_migration +{ + public partial class App : Application + { + private static string LogPath => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "error.log"); + private static string BackupDir => Path.Combine(Path.GetTempPath(), "henlo_migration_data_backup"); + private const string UpdateFeedUrl = "http://zjhenlo.henlo.net:8878/henlo_migration/auto_update/"; + + // Velopack 更新管理器 + private UpdateManager _updateManager; + + protected override void OnStartup(StartupEventArgs e) + { + try + { + // Velopack 初始化:必须在所有 UI 之前调用 + VelopackApp.Build().Run(); + + // 更新后恢复用户数据(如果存在备份) + RestoreUserData(); + + // 初始化更新管理器 + try + { + _updateManager = new UpdateManager(UpdateFeedUrl); + } + catch (Exception ex) when (ex.GetType().Name.Contains("Velopack") || ex.Message.Contains("not installed") || ex.Message.Contains("not find")) + { + // 应用未通过 Velopack 安装,跳过自动更新 + _updateManager = null; + } + + // 静默检查更新(后台线程,不阻塞启动) + if (_updateManager != null) + _ = CheckUpdateInBackgroundAsync(); + + base.OnStartup(e); + } + catch (Exception ex) + { + LogException("Startup", ex); + MessageBox.Show($"启动失败:{ex.Message}\n\n{ex.StackTrace}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + throw; + } + } + + private async Task CheckUpdateInBackgroundAsync() + { + if (_updateManager == null) return; + try + { + var newVersion = await _updateManager.CheckForUpdatesAsync(); + if (newVersion == null) return; // 无更新 + + // 后台下载更新包 + await _updateManager.DownloadUpdatesAsync(newVersion); + + // 下载完成,提示用户重启 + Application.Current.Dispatcher.Invoke(() => + { + var result = MessageBox.Show( + $"发现新版本 {newVersion.TargetFullRelease.Version} 并已下载完成,是否立即重启应用以应用更新?", + "更新就绪", + MessageBoxButton.YesNo, + MessageBoxImage.Information); + + if (result == MessageBoxResult.Yes) + { + BackupUserData(); + _updateManager.ApplyUpdatesAndRestart(newVersion); + } + }); + } + catch (Exception ex) + { + LogException("AutoUpdate", ex); + } + } + + /// 手动检查更新(带提示) + public async Task CheckUpdateManualAsync(Window owner) + { + if (_updateManager == null) + { + MessageBox.Show("当前版本不支持自动更新,请前往官网下载新版本。", "提示", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + try + { + var newVersion = await _updateManager.CheckForUpdatesAsync(); + if (newVersion == null) + { + MessageBox.Show("当前已是最新版本", "检查更新", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var result = MessageBox.Show( + $"发现新版本:{newVersion.TargetFullRelease.Version}\n\n是否立即下载并安装?", + "发现更新", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + + if (result == MessageBoxResult.Yes) + { + await _updateManager.DownloadUpdatesAsync(newVersion); + + var restartResult = MessageBox.Show( + "更新已下载完成,是否立即重启应用?", + "更新完成", + MessageBoxButton.YesNo, + MessageBoxImage.Information); + + if (restartResult == MessageBoxResult.Yes) + { + BackupUserData(); + _updateManager.ApplyUpdatesAndRestart(newVersion); + } + } + } + catch (Exception ex) + { + LogException("AutoUpdateManual", ex); + MessageBox.Show("检查更新失败:" + ex.Message, "提示", MessageBoxButton.OK, MessageBoxImage.Warning); + } + } + + public App() + { + AppDomain.CurrentDomain.UnhandledException += (s, args) => + { + var ex = args.ExceptionObject as Exception; + LogException("Unhandled", ex); + MessageBox.Show($"未处理异常:{ex?.Message}\n\n{ex?.StackTrace}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + }; + + DispatcherUnhandledException += (s, args) => + { + LogException("Dispatcher", args.Exception); + MessageBox.Show($"UI异常:{args.Exception.Message}\n\n{args.Exception.StackTrace}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); + args.Handled = true; + }; + + TaskScheduler.UnobservedTaskException += (s, args) => + { + LogException("Task", args.Exception); + args.SetObserved(); + }; + } + + /// 备份用户数据到临时目录(更新前调用) + private void BackupUserData() + { + try + { + var rootDir = AppDomain.CurrentDomain.BaseDirectory; + var dataDir = Path.Combine(rootDir, "data"); + var preDir = Path.Combine(rootDir, "pre"); + + if (!Directory.Exists(dataDir) && !Directory.Exists(preDir)) return; + + if (Directory.Exists(BackupDir)) + Directory.Delete(BackupDir, true); + Directory.CreateDirectory(BackupDir); + + if (Directory.Exists(dataDir)) + CopyDirectory(dataDir, Path.Combine(BackupDir, "data")); + + if (Directory.Exists(preDir)) + CopyDirectory(preDir, Path.Combine(BackupDir, "pre")); + } + catch (Exception ex) + { + LogException("BackupUserData", ex); + } + } + + /// 从临时目录恢复用户数据(更新后首次启动调用) + private void RestoreUserData() + { + try + { + if (!Directory.Exists(BackupDir)) return; + + var rootDir = AppDomain.CurrentDomain.BaseDirectory; + + var backupData = Path.Combine(BackupDir, "data"); + if (Directory.Exists(backupData)) + CopyDirectory(backupData, Path.Combine(rootDir, "data")); + + var backupPre = Path.Combine(BackupDir, "pre"); + if (Directory.Exists(backupPre)) + CopyDirectory(backupPre, Path.Combine(rootDir, "pre")); + + Directory.Delete(BackupDir, true); + } + catch (Exception ex) + { + LogException("RestoreUserData", ex); + } + } + + private static void CopyDirectory(string sourceDir, string targetDir) + { + Directory.CreateDirectory(targetDir); + foreach (var file in Directory.GetFiles(sourceDir)) + File.Copy(file, Path.Combine(targetDir, Path.GetFileName(file)), true); + foreach (var dir in Directory.GetDirectories(sourceDir)) + CopyDirectory(dir, Path.Combine(targetDir, Path.GetFileName(dir))); + } + + private void LogException(string type, Exception ex) + { + try + { + var msg = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{type}] {ex?.Message}\n{ex?.StackTrace}\n\n"; + File.AppendAllText(LogPath, msg); + } + catch { } + } + } +} \ No newline at end of file diff --git a/henlo_migration_wpf10/Behaviors/ClickToCopyBehavior.cs b/henlo_migration_wpf10/Behaviors/ClickToCopyBehavior.cs new file mode 100644 index 0000000..1d6ce2c --- /dev/null +++ b/henlo_migration_wpf10/Behaviors/ClickToCopyBehavior.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace henlo_migration.Behaviors +{ + /// + /// 附加行为:单击 TextBlock 将其 Text 复制到剪贴板。 + /// 同时通过 DataGridSelectionChanged 更新选中行名称的前景色:白色/默认蓝。 + /// 用法: + /// + public static class ClickToCopy + { + // 默认蓝色(GitHub 链接色) + private static readonly Brush DefaultBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0969da")); + // 选中时白色 + private static readonly Brush WhiteBrush = Brushes.White; + + public static readonly DependencyProperty SelectableProperty = + DependencyProperty.RegisterAttached( + "Selectable", typeof(bool), typeof(ClickToCopy), + new PropertyMetadata(false, OnSelectableChanged)); + + public static bool GetSelectable(DependencyObject obj) + => (bool)obj.GetValue(SelectableProperty); + + public static void SetSelectable(DependencyObject obj, bool value) + => obj.SetValue(SelectableProperty, value); + + private static void OnSelectableChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not TextBlock tb) return; + + if ((bool)e.NewValue) + { + tb.Cursor = Cursors.Hand; + tb.MouseLeftButtonDown += Tb_MouseLeftButtonDown; + } + else + { + tb.MouseLeftButtonDown -= Tb_MouseLeftButtonDown; + } + } + + private static void Tb_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + // 严格检查:1) OriginalSource 必须是这个 TextBlock 本身 + // 2) 鼠标位置必须在 TextBlock 的边界内(防止按钮点击冒泡) + if (sender is not TextBlock tb || string.IsNullOrEmpty(tb.Text)) + return; + + if (!ReferenceEquals(e.OriginalSource, tb)) + return; + + // 获取 TextBlock 在屏幕上的位置 + var rect = new Rect(tb.PointToScreen(new Point(0, 0)), tb.PointToScreen(new Point(tb.ActualWidth, tb.ActualHeight))); + var mousePos = e.GetPosition(tb); + var tbBounds = new Rect(0, 0, tb.ActualWidth, tb.ActualHeight); + + // 检查鼠标是否在 TextBlock 范围内 + if (!tbBounds.Contains(mousePos)) + return; + + Clipboard.SetText(tb.Text); + HandyControl.Controls.Growl.Success($"已复制:{tb.Text}"); + e.Handled = true; + } + + // DataGrid SelectionChanged 时调用:设置已选项的名称列前景色为白色 + public static void RefreshSelectionBrush(DataGrid grid) + { + foreach (var item in grid.SelectedItems) + { + var row = grid.ItemContainerGenerator.ContainerFromItem(item) as DataGridRow; + if (row == null) continue; + + for (int i = 0; i < grid.Columns.Count; i++) + { + var cellContent = grid.Columns[i].GetCellContent(row); + if (cellContent is TextBlock tb && GetSelectable(tb)) + { + tb.Foreground = WhiteBrush; + break; + } + } + } + } + + // 清除已选中项的名称列前景色(恢复到默认) + public static void ClearSelectionBrush(DataGrid grid) + { + for (int r = 0; r < grid.Items.Count; r++) + { + var row = grid.ItemContainerGenerator.ContainerFromIndex(r) as DataGridRow; + if (row == null || row.IsSelected) continue; + + for (int i = 0; i < grid.Columns.Count; i++) + { + var cellContent = grid.Columns[i].GetCellContent(row); + if (cellContent is TextBlock tb && GetSelectable(tb)) + { + tb.Foreground = DefaultBrush; + break; + } + } + } + } + } +} diff --git a/henlo_migration_wpf10/Common/CommonHelper.cs b/henlo_migration_wpf10/Common/CommonHelper.cs new file mode 100644 index 0000000..d60a0a1 --- /dev/null +++ b/henlo_migration_wpf10/Common/CommonHelper.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using henlo_migration.Models; + +namespace henlo_migration.Common +{ + public static class CommonHelper + { + /// 程序运行根目录 + public static string RootPath => AppDomain.CurrentDomain.BaseDirectory; + + /// 源数据文件夹(存放 table.txt、bos.txt、prc.txt、data.txt 及各表的 .txt 文件) + public static string DataPath + { + get + { + var path = Path.Combine(RootPath, "data"); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + return path; + } + } + + /// 索引文件目录(存放 table.txt、bos.txt、prc.txt、data.txt) + public static string DataFromPath + { + get + { + var path = Path.Combine(DataPath, "from"); + if (!Directory.Exists(path)) Directory.CreateDirectory(path); + return path; + } + } + + /// 预处理文件夹(存放 .prc/.fnc 存储过程定义文件) + public static string PrePath => Path.Combine(RootPath, "pre"); + + /// 读取文本文件,每行作为一个元素返回 + public static List ReadText(string filePath) + { + var result = new List(); + if (!File.Exists(filePath)) return result; + var lines = File.ReadAllLines(filePath); + foreach (var line in lines) + { + if (!string.IsNullOrWhiteSpace(line)) + result.Add(new Models.TableRowModel { Name = line.Trim() }); + } + return result; + } + + /// 写入文本内容到文件 + public static void WriteToFile(string filePath, string content) + { + string? dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(filePath, content); + } + } +} diff --git a/henlo_migration_wpf10/Common/SqlSugarHelper.cs b/henlo_migration_wpf10/Common/SqlSugarHelper.cs new file mode 100644 index 0000000..d73d8da --- /dev/null +++ b/henlo_migration_wpf10/Common/SqlSugarHelper.cs @@ -0,0 +1,90 @@ +using SqlSugar; +using System; +using System.Collections.Generic; + +namespace henlo_migration.Common +{ + /// + /// SqlSugar 多库连接管理(源库 orig + 目标库 dest,均为 Oracle) + /// + public class SqlSugarHelper + { + public static SqlSugarScope? Dbs { get; private set; } + + public static SqlSugarScopeProvider? DbOrig + { + get + { + if (Dbs == null) return null; + return Dbs.GetConnectionScope("orig"); + } + } + + public static SqlSugarScopeProvider? DbDest + { + get + { + if (Dbs == null) return null; + return Dbs.GetConnectionScope("dest"); + } + } + + /// + /// 连接两个 Oracle 数据库(源库 + 目标库) + /// + public static void Connect() + { + Dbs = new SqlSugarScope(new List() + { + new ConnectionConfig(){ + ConfigId = "orig", + ConnectionString = SysConfig.origConnStr, + DbType = DbType.Oracle, + IsAutoCloseConnection = true + }, + new ConnectionConfig(){ + ConfigId = "dest", + ConnectionString = SysConfig.destConnStr, + DbType = DbType.Oracle, + IsAutoCloseConnection = true + } + }, + db => + { + db.Ado.CommandTimeOut = 30; + }); + + Dbs.Ado.CommandTimeOut = 30; + } + + /// + /// 检查源库连接是否有效 + /// + public static bool CheckOrigConnect() + { + try + { + return DbOrig != null && DbOrig.Ado.IsValidConnection(); + } + catch + { + return false; + } + } + + /// + /// 检查目标库连接是否有效 + /// + public static bool CheckDescConnect() + { + try + { + return DbDest != null && DbDest.Ado.IsValidConnection(); + } + catch + { + return false; + } + } + } +} diff --git a/henlo_migration_wpf10/Common/SysConfig.cs b/henlo_migration_wpf10/Common/SysConfig.cs new file mode 100644 index 0000000..91140df --- /dev/null +++ b/henlo_migration_wpf10/Common/SysConfig.cs @@ -0,0 +1,81 @@ +using System; +using IniParser; +using IniParser.Model; + +namespace henlo_migration.Common +{ + public static class SysConfig + { + private static IniData _init; + + private static string ConfigPath => System.IO.Path.Combine(CommonHelper.RootPath, "config.ini"); + static SysConfig() + { + var parser = new FileIniDataParser(); + + // 确保 config.ini 存在 + if (!System.IO.File.Exists(ConfigPath)) + { + System.IO.File.WriteAllText(ConfigPath, @"[oracle] +orig_ip=127.0.0.1 +orig_exam=orcl +orig_user=system +orig_password=system +desc_ip=127.0.0.1 +desc_exam=orcl +desc_user=system +desc_password=system +"); + } + _init = parser.ReadFile(ConfigPath); + } + + /// 源 Oracle 连接字符串 + public static string origConnStr => + $"Data Source={_init["oracle"]["orig_ip"]}/{_init["oracle"]["orig_exam"]};User ID={_init["oracle"]["orig_user"]};Password={_init["oracle"]["orig_password"]}"; + + /// 目标 Oracle 连接字符串 + public static string destConnStr => + $"Data Source={_init["oracle"]["desc_ip"]}/{_init["oracle"]["desc_exam"]};User ID={_init["oracle"]["desc_user"]};Password={_init["oracle"]["desc_password"]}"; + + /// 版本号(从程序集读取) + public static string version => + System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0"; + + private static void Set(string section, string key, string value) + { + _init[section][key] = value; + var parser = new FileIniDataParser(); + parser.WriteFile(ConfigPath, _init); + } + + /// 保存源库连接信息 + public static void SetOrig(string ip, string exam, string user, string pwd) + { + Set("oracle", "orig_ip", ip); + Set("oracle", "orig_exam", exam); + Set("oracle", "orig_user", user); + Set("oracle", "orig_password", pwd); + } + + /// 保存目标库连接信息 + public static void SetDest(string ip, string exam, string user, string pwd) + { + Set("oracle", "desc_ip", ip); + Set("oracle", "desc_exam", exam); + Set("oracle", "desc_user", user); + Set("oracle", "desc_password", pwd); + } + + // 各字段独立读取(供 ViewModel 初始化填充界面用) + public static string GetOrigIp() => _init["oracle"]["orig_ip"]; + public static string GetOrigExam() => _init["oracle"]["orig_exam"]; + public static string GetOrigUser() => _init["oracle"]["orig_user"]; + public static string GetOrigPwd() => _init["oracle"]["orig_password"]; + public static string GetDestIp() => _init["oracle"]["desc_ip"]; + public static string GetDestExam() => _init["oracle"]["desc_exam"]; + public static string GetDestUser() => _init["oracle"]["desc_user"]; + public static string GetDestPwd() => _init["oracle"]["desc_password"]; + public static string GetVersion() => version; + } +} diff --git a/henlo_migration_wpf10/Converters/StatusColorConverter.cs b/henlo_migration_wpf10/Converters/StatusColorConverter.cs new file mode 100644 index 0000000..8201e11 --- /dev/null +++ b/henlo_migration_wpf10/Converters/StatusColorConverter.cs @@ -0,0 +1,62 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using henlo_migration.Enums; + +namespace henlo_migration.Converters +{ + /// 读取状态 -> 背景色:未读取=灰、读取中=蓝、已读取=绿、读取失败=红 + public class ReadStatusColorConverter : IMultiValueConverter + { + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length == 0 || values[0] == null || values[0] == System.Windows.DependencyProperty.UnsetValue) + return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6c757d")); + + if (values[0] is StatusEnum status) + { + return status switch + { + StatusEnum.读取中 => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0969da")), + StatusEnum.不存在 => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9e9e9e")), + StatusEnum.已存在 => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#d29922")), + StatusEnum.已读取 => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#3fb950")), + StatusEnum.读取失败 => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#f85149")), + _ => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6c757d")) + }; + } + + return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6c757d")); + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => throw new NotImplementedException(); + } + + /// 写入状态 -> 背景色:未写入=灰、写入中=蓝、已写入=绿、写入失败=红 + public class WriteStatusColorConverter : IMultiValueConverter + { + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + if (values.Length == 0 || values[0] == null || values[0] == System.Windows.DependencyProperty.UnsetValue) + return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6c757d")); + + if (values[0] is StatusEnum status) + { + return status switch + { + StatusEnum.写入中 => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#0969da")), + StatusEnum.已写入 => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#3fb950")), + StatusEnum.写入失败 => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#f85149")), + _ => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6c757d")) + }; + } + + return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6c757d")); + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + => throw new NotImplementedException(); + } +} diff --git a/henlo_migration_wpf10/Enums/BtnsEnum.cs b/henlo_migration_wpf10/Enums/BtnsEnum.cs new file mode 100644 index 0000000..e7e5bf4 --- /dev/null +++ b/henlo_migration_wpf10/Enums/BtnsEnum.cs @@ -0,0 +1,12 @@ +namespace henlo_migration.Enums +{ + public enum BtnsEnum + { + 读取, + 写入, + 删除, + 上, + 下, + 置顶 + } +} diff --git a/henlo_migration_wpf10/Enums/CodeExt.cs b/henlo_migration_wpf10/Enums/CodeExt.cs new file mode 100644 index 0000000..9f71ec7 --- /dev/null +++ b/henlo_migration_wpf10/Enums/CodeExt.cs @@ -0,0 +1,10 @@ +namespace henlo_migration.Enums +{ + public enum CodeExt + { + bos, + creat, + data, + prc + } +} diff --git a/henlo_migration_wpf10/Enums/StatusEnum.cs b/henlo_migration_wpf10/Enums/StatusEnum.cs new file mode 100644 index 0000000..48f029c --- /dev/null +++ b/henlo_migration_wpf10/Enums/StatusEnum.cs @@ -0,0 +1,17 @@ +namespace henlo_migration.Enums +{ + public enum StatusEnum + { + 未读取, + 不存在, + 读取中, + 已存在, + 已读取, + 读取失败, + 未写入, + 写入中, + 已写入, + 写入失败, + 编译失败 + } +} diff --git a/henlo_migration_wpf10/Enums/TabsEnum.cs b/henlo_migration_wpf10/Enums/TabsEnum.cs new file mode 100644 index 0000000..118ed7c --- /dev/null +++ b/henlo_migration_wpf10/Enums/TabsEnum.cs @@ -0,0 +1,10 @@ +namespace henlo_migration.Enums +{ + public enum TabsEnum + { + 表 = 0, + BOS表配置 = 1, + 存储过程 = 2, + 表数据 = 3 + } +} diff --git a/henlo_migration_wpf10/Enums/WorkTypeEnum.cs b/henlo_migration_wpf10/Enums/WorkTypeEnum.cs new file mode 100644 index 0000000..2b94e28 --- /dev/null +++ b/henlo_migration_wpf10/Enums/WorkTypeEnum.cs @@ -0,0 +1,8 @@ +namespace henlo_migration.Enums +{ + public enum WorkTypeEnum + { + 读, + 写 + } +} diff --git a/henlo_migration_wpf10/Models/TableRowModel.cs b/henlo_migration_wpf10/Models/TableRowModel.cs new file mode 100644 index 0000000..4ea856d --- /dev/null +++ b/henlo_migration_wpf10/Models/TableRowModel.cs @@ -0,0 +1,59 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using henlo_migration.Enums; + +namespace henlo_migration.Models +{ + public class TableRowModel : INotifyPropertyChanged + { + public TabsEnum Tab { get; set; } + + private string _name = ""; + public string Name + { + get => _name; + set { _name = value; OnPropertyChanged(); } + } + + private StatusEnum _rStatus = StatusEnum.未读取; + public StatusEnum RStatus + { + get => _rStatus; + set { _rStatus = value; OnPropertyChanged(); OnPropertyChanged(nameof(ReadStatusText)); } + } + + private StatusEnum _wStatus = StatusEnum.未写入; + public StatusEnum WStatus + { + get => _wStatus; + set { _wStatus = value; OnPropertyChanged(); OnPropertyChanged(nameof(WriteStatusText)); } + } + + public string Error { get; set; } = ""; + + public string ReadStatusText => RStatus switch + { + StatusEnum.未读取 => "未读取", + StatusEnum.不存在 => "不存在", + StatusEnum.读取中 => "读取中", + StatusEnum.已存在 => "已存在", + StatusEnum.已读取 => "已读取", + StatusEnum.读取失败 => "读取失败", + _ => "" + }; + + public string WriteStatusText => WStatus switch + { + StatusEnum.未写入 => "未写入", + StatusEnum.写入中 => "写入中", + StatusEnum.已写入 => "已写入", + StatusEnum.写入失败 => "写入失败", + StatusEnum.编译失败 => "编译失败", + _ => "" + }; + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } +} diff --git a/henlo_migration_wpf10/Properties/PublishProfiles/FolderProfile.pubxml b/henlo_migration_wpf10/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..af47b6e --- /dev/null +++ b/henlo_migration_wpf10/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,16 @@ + + + + + Release + Any CPU + publish + FileSystem + <_TargetId>Folder + net10.0-windows + win-x64 + false + true + false + + \ No newline at end of file diff --git a/henlo_migration_wpf10/README.md b/henlo_migration_wpf10/README.md new file mode 100644 index 0000000..9626acd --- /dev/null +++ b/henlo_migration_wpf10/README.md @@ -0,0 +1,231 @@ +# 恒诺迁移工具 + +> Oracle 数据库迁移辅助工具,支持表结构、存储过程、BOS 配置的读取与写入 + +## 产品概述 + +恒诺迁移工具是一款基于 WPF + .NET 10 的 Windows 桌面应用,用于简化 Oracle 数据库对象(表、存储过程、BOS 配置)的跨库迁移工作。 + +## 主要功能 + +| 功能 | 说明 | +|------|------| +| **表结构迁移** | 读取源库表结构(DDL)、表注释、字段注释、主键/外键/索引,写入目标库 | +| **存储过程迁移** | 读取/写入存储过程和函数源码 | +| **BOS 配置迁移** | 读取 BOS 表配置写入目标库 | +| **表数据迁移** | 支持表数据的导出/导入 | +| **自动更新** | 支持通过私有服务器 Velopack 自动检查更新 | +| **断点续写** | 批量写入失败后,下次从失败行继续,无需从头开始 | + +## 技术架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ WPF (UI Layer) │ +│ HomeWindow.xaml ← MVVM → MainViewModel.cs │ +└─────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────▼───────────────────────────────────┐ +│ Services (业务层) │ +│ ├─ DbServices.cs - 核心 DDL 操作 │ +│ ├─ TabServices.cs - Tab 数据读写 │ +│ └─ TabConfig.cs - Tab 配置 │ +└─────────────────────┬───────────────────────────────────┘ + │ +┌─────────────────────▼───────────────────────────────────┐ +│ Data Access (数据层) │ +│ └─ SqlSugar ORM + Oracle.ManagedDataAccess │ +└─────────────────────────────────────────────────────────┘ +``` + +## 项目结构 + +``` +henlo_migration_wpf10/ +├── App.xaml / App.xaml.cs # WPF 入口,自动更新,全局异常 +├── Common/ # 公共工具 +│ ├── CommonHelper.cs # 文件读写、路径配置 +│ ├── SqlSugarHelper.cs # 数据库连接管理 +│ └── SysConfig.cs # INI 配置读写 +├── Services/ # 业务服务 +│ ├── DbServices.cs # 核心:Oracle DDL 读写 +│ ├── TabServices.cs # Tab 数据读写 +│ └── TabConfig.cs # Tab 配置 +├── ViewModels/ # MVVM +│ └── MainViewModel.cs # 主 ViewModel +├── Views/ # UI +│ ├── HomeWindow.xaml # 主窗口 +│ └── ConnectionDialog.xaml # 连接配置 +├── data/from/ # 索引文件(table.txt 等) +├── pre/ # 预处理脚本 +├── pack.bat # Velopack 打包脚本 +└── henlo_migration_wpf10.csproj +``` + +## 技术栈 + +| 类别 | 技术 | +|------|------| +| 框架 | WPF / .NET 10.0 | +| ORM | SqlSugar 5.1.4 | +| 数据库 | Oracle (ODP.NET) | +| UI 库 | HandyControl 3.5.1 | +| MVVM | CommunityToolkit.Mvvm 8.3 | +| 自动更新 | Velopack 0.0.1298 | + +## 运行环境 + +- **操作系统**:Windows 10/11 (x64) +- **.NET**:.NET 10.0 Runtime(或 Self-contained 发布) +- **数据库**:Oracle 11g R2 及以上 +- **.NET SDK**:10.0+(仅开发时需要) + +## 使用说明 + +### 1. 初始化配置 + +首次使用需配置源库和目标库的连接信息: + +- **源库**:读取数据的数据库 +- **目标库**:写入数据的数据库 + +连接信息保存在 `config.ini` 中。 + +### 2. 索引文件 + +索引文件位于 `data/from/` 目录: + +| 文件 | 用途 | +|------|------| +| `table.txt` | 表结构列表 | +| `bos.txt` | BOS 配置列表 | +| `prc.txt` | 存储过程/函数列表 | +| `data.txt` | 表数据列表 | + +每行一个对象名称,读取数据库时会自动更新内容。 + +### 3. 基本操作流程 + +``` +1. 配置源库/目标库连接 +2. 点击「读取」按钮 → 从源库读取对象定义,保存到文件 +3. 在右侧编辑区查看/修改代码 +4. 点击「写入」或「批量写入」→ 写入目标库 +``` + +### 4. 批量写入断点续写 + +当批量写入过程中某行失败: +- 记录失败行的索引(断点) +- 按钮显示「继续写入」 +- 修复问题后再次点击「继续写入」,从失败行继续 +- 全部成功后自动重置断点 + +### 5. 自动更新 + +支持通过私有服务器自动检查更新(基于 Velopack): + +- 应用启动时自动检查更新 +- 也可手动点击「检查更新」按钮 +- 检测到新版本后,下载并自动安装,重启后生效 +- 更新过程中用户数据(data/、pre/)会自动备份并恢复 + +## 版本管理 + +### 版本号来源 + +版本号**唯一数据源**是 `henlo_migration_wpf10.csproj` 中的 `` 标签: + +```xml + + 1.0.15 + 1.0.15 + +``` + +编译时 MSBuild 自动将版本号写入 `Version.ini`,程序启动时读取并显示在标题栏。 + +### 版本号命名规范 + +| 场景 | 规则 | 示例 | +|------|------|------| +| Bug 修复 | 递增 patch | 1.0.15 → 1.0.16 | +| 新增功能(向下兼容) | 递增 minor | 1.0.15 → 1.1.0 | +| 破坏性变更 | 递增 major | 1.0.15 → 2.0.0 | + +**建议**:日常维护使用 patch 版本,小功能用 minor 版本。 + +## 发布流程 + +### 第一步:确认版本号 + +1. 打开 `henlo_migration_wpf10.csproj` +2. 确认或修改 `` 标签为新版本号 +3. 建议同步修改 `Version.ini` 中的版本号(保持一致) + +> ⚠️ 版本号只能递增。Velopack 根据版本号判断是否需要更新。 + +### 第二步:本地打包 + +在项目根目录双击运行 `pack.bat`: + +```bash +# pack.bat 自动完成以下步骤: +# 1. 从 csproj 读取 +# 2. dotnet publish Release 版本 +# 3. 创建 data/ 目录结构(.gitkeep 占位) +# 4. vpk pack 打包为 nupkg +# 5. 输出到 releases/ 目录 +``` + +打包产物在 `releases/` 目录: + +| 文件 | 说明 | +|------|------| +| `henlo_migration-win-Portable.zip` | 便携版压缩包,直接分发给用户 | +| `henlo_migration-<版本>-full.nupkg` | Velopack 更新包,上传到服务器 | +| `RELEASES` | Velopack 更新元数据文件,随 nupkg 一起上传 | + +### 第三步:上传到更新服务器 + +将以下三个文件上传到服务器对应目录(当前配置:`http://zjhenlo.henlo.net:8878/henlo_migration/auto_update/`): + +1. `henlo_migration-<版本>-full.nupkg` +2. `RELEASES` +3. `henlo_migration-win-Portable.zip`(可选,用户可直接下载这个完整包) + +> ⚠️ 必须同时上传 `RELEASES` 文件,Velopack 依赖它判断版本信息。 + +### 第四步:通知用户更新 + +用户端应用会在启动时自动检测到新版本,弹出更新提示。 + +如需手动触发测试更新,可删除程序目录下的 `.velopack` 缓存文件夹后重启。 + +## 注意事项 + +- 写入前请确认目标库表是否存在,工具不会自动建表(表结构 DDL 除外) +- 批量写入失败时会停止,后续行不会被处理 +- 索引文件修改后需重新读取才能生效 +- 便携版数据(data/、pre/)在更新时自动备份恢复,无需手动迁移 + +## 常见问题 + +**Q: 写入时报 ORA-00922 错误?** +A: 可能是表名或列名使用了 Oracle 保留字,需要加双引号。工具已自动处理大部分情况。 + +**Q: 如何只更新部分行?** +A: 使用上下箭头调整行的顺序,或使用「移到顶部」功能调整优先级。 + +**Q: 索引文件在哪里?** +A: `data/from/` 目录下。 + +**Q: 更新后用户数据会丢失吗?** +A: 不会。Velopack 更新前会自动将 data/ 和 pre/ 备份到 %TEMP%,更新后恢复。 + +**Q: 如何强制重新检测更新?** +A: 删除程序目录下的 `.velopack` 文件夹,然后重启应用。 + +## 许可证 + +内部使用工具。 diff --git a/henlo_migration_wpf10/Resources/favicon.ico b/henlo_migration_wpf10/Resources/favicon.ico new file mode 100644 index 0000000..523fc50 Binary files /dev/null and b/henlo_migration_wpf10/Resources/favicon.ico differ diff --git a/henlo_migration_wpf10/Services/DbServices.cs b/henlo_migration_wpf10/Services/DbServices.cs new file mode 100644 index 0000000..e5a0033 --- /dev/null +++ b/henlo_migration_wpf10/Services/DbServices.cs @@ -0,0 +1,426 @@ +using SqlSugar; +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Text; +using henlo_migration.Common; +using henlo_migration.Enums; +using henlo_migration.Models; + +namespace henlo_migration.Services +{ + public class DbServices + { + /// 获取表建表SQL - 包含建表语句、表注释、字段注释、主键、唯一键、外键、索引,不依赖存储过程 + public static string GetTableSql(string tableName) + { + var db = SqlSugarHelper.DbOrig; + string t = tableName.Trim().ToUpperInvariant(); + var sb = new StringBuilder(); + + // 0. 关闭不需要的 DDL 附加属性 + db.Ado.ExecuteCommand(@" + BEGIN + DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'STORAGE', FALSE); + DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'TABLESPACE', FALSE); + DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'SEGMENT_ATTRIBUTES', FALSE); + DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'CONSTRAINTS', FALSE); + DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'REF_CONSTRAINTS', FALSE); + DBMS_METADATA.SET_TRANSFORM_PARAM(DBMS_METADATA.SESSION_TRANSFORM, 'CONSTRAINTS_AS_ALTER', TRUE); + END;"); + + // 1. 建表语句 + var ddlResult = db.Ado.GetDataTable( + "SELECT DBMS_METADATA.GET_DDL('TABLE', :tbl) AS SQL_TEXT FROM DUAL", + new SugarParameter("tbl", t)); + if (ddlResult.Rows.Count > 0) + { + sb.Append(ddlResult.Rows[0]["SQL_TEXT"]?.ToString() ?? ""); + sb.AppendLine(";\n/"); + } + + // 2. 表注释(每个语句末尾加 ;/\n,便于 SplitStatements 分割) + var tabCmt = db.Ado.GetDataTable( + "SELECT COMMENTS FROM USER_TAB_COMMENTS WHERE TABLE_NAME = :tbl AND COMMENTS IS NOT NULL", + new SugarParameter("tbl", t)); + foreach (DataRow r in tabCmt.Rows) + sb.AppendLine($"COMMENT ON TABLE \"{t}\" IS '{EscapeQuote(r["COMMENTS"].ToString())}';\n/"); + + // 3. 字段注释 + var colCmt = db.Ado.GetDataTable( + "SELECT COLUMN_NAME, COMMENTS FROM USER_COL_COMMENTS WHERE TABLE_NAME = :tbl AND COMMENTS IS NOT NULL ORDER BY COLUMN_NAME", + new SugarParameter("tbl", t)); + foreach (DataRow r in colCmt.Rows) + { + string cName = r["COLUMN_NAME"].ToString() ?? ""; + sb.AppendLine($"COMMENT ON COLUMN \"{t}\".\"{cName}\" IS '{EscapeQuote(r["COMMENTS"].ToString())}';\n/"); + } + + // 4. 主键 & 唯一键 + var pkUkCons = db.Ado.GetDataTable( + "SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE FROM USER_CONSTRAINTS WHERE TABLE_NAME = :tbl AND CONSTRAINT_TYPE IN ('P','U') ORDER BY CONSTRAINT_TYPE, CONSTRAINT_NAME", + new SugarParameter("tbl", t)); + foreach (DataRow cr in pkUkCons.Rows) + { + var cols = db.Ado.GetDataTable( + "SELECT COLUMN_NAME FROM USER_CONS_COLUMNS WHERE CONSTRAINT_NAME = :cn ORDER BY POSITION", + new SugarParameter("cn", cr["CONSTRAINT_NAME"].ToString())); + string colList = ""; + for (int i = 0; i < cols.Rows.Count; i++) + colList += (i > 0 ? ", " : "") + $"\"{cols.Rows[i]["COLUMN_NAME"]}\""; + string kind = cr["CONSTRAINT_TYPE"].ToString() == "P" ? "PRIMARY KEY" : "UNIQUE"; + sb.AppendLine($"ALTER TABLE \"{t}\" ADD CONSTRAINT \"{cr["CONSTRAINT_NAME"]}\" {kind} ({colList});\n/"); + } + + // 5. 外键 + var fkCons = db.Ado.GetDataTable( + "SELECT CONSTRAINT_NAME, R_CONSTRAINT_NAME, DELETE_RULE FROM USER_CONSTRAINTS WHERE TABLE_NAME = :tbl AND CONSTRAINT_TYPE = 'R' ORDER BY CONSTRAINT_NAME", + new SugarParameter("tbl", t)); + foreach (DataRow fr in fkCons.Rows) + { + var localCols = db.Ado.GetDataTable( + "SELECT COLUMN_NAME FROM USER_CONS_COLUMNS WHERE CONSTRAINT_NAME = :cn ORDER BY POSITION", + new SugarParameter("cn", fr["CONSTRAINT_NAME"].ToString())); + var refCons = db.Ado.GetDataTable( + "SELECT TABLE_NAME FROM USER_CONSTRAINTS WHERE CONSTRAINT_NAME = :cn", + new SugarParameter("cn", fr["R_CONSTRAINT_NAME"].ToString())); + if (refCons.Rows.Count > 0) + { + var refCols = db.Ado.GetDataTable( + "SELECT COLUMN_NAME FROM USER_CONS_COLUMNS WHERE CONSTRAINT_NAME = :cn ORDER BY POSITION", + new SugarParameter("cn", fr["R_CONSTRAINT_NAME"].ToString())); + string localList = ""; + for (int i = 0; i < localCols.Rows.Count; i++) + localList += (i > 0 ? ", " : "") + $"\"{localCols.Rows[i]["COLUMN_NAME"]}\""; + string refList = ""; + for (int i = 0; i < refCols.Rows.Count; i++) + refList += (i > 0 ? ", " : "") + $"\"{refCols.Rows[i]["COLUMN_NAME"]}\""; + string delRule = fr["DELETE_RULE"].ToString(); + string onDel = delRule == "CASCADE" ? " ON DELETE CASCADE" + : delRule == "SET NULL" ? " ON DELETE SET NULL" : ""; + sb.AppendLine($"ALTER TABLE \"{t}\" ADD CONSTRAINT \"{fr["CONSTRAINT_NAME"]}\" FOREIGN KEY ({localList}) REFERENCES \"{refCons.Rows[0]["TABLE_NAME"]}\" ({refList}){onDel};\n/"); + } + } + + // 6. 普通索引 + var pkUkNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var pkUkRaw = db.Ado.GetDataTable( + "SELECT CONSTRAINT_NAME FROM USER_CONSTRAINTS WHERE TABLE_NAME = :tbl AND CONSTRAINT_TYPE IN ('P','U')", + new SugarParameter("tbl", t)); + foreach (DataRow r in pkUkRaw.Rows) pkUkNames.Add(r["CONSTRAINT_NAME"].ToString()); + + var idxRaw = db.Ado.GetDataTable( + "SELECT INDEX_NAME, UNIQUENESS FROM USER_INDEXES WHERE TABLE_NAME = :tbl AND INDEX_TYPE IN ('NORMAL','FUNCTION-BASED NORMAL') ORDER BY INDEX_NAME", + new SugarParameter("tbl", t)); + foreach (DataRow ir in idxRaw.Rows) + { + if (pkUkNames.Contains(ir["INDEX_NAME"].ToString())) continue; + var idxCols = db.Ado.GetDataTable( + "SELECT COLUMN_NAME FROM USER_IND_COLUMNS WHERE INDEX_NAME = :idx_nm ORDER BY COLUMN_POSITION", + new SugarParameter("idx_nm", ir["INDEX_NAME"].ToString())); + string colList = ""; + for (int i = 0; i < idxCols.Rows.Count; i++) + colList += (i > 0 ? ", " : "") + $"\"{idxCols.Rows[i]["COLUMN_NAME"]}\""; + string unique = ir["UNIQUENESS"].ToString() == "UNIQUE" ? "UNIQUE " : ""; + sb.AppendLine($"CREATE {unique}INDEX \"{ir["INDEX_NAME"]}\" ON \"{t}\" ({colList});\n/"); + } + + return sb.ToString(); + } + + private static string EscapeQuote(string s) + => s?.Replace("'", "''") ?? ""; + + /// 获取BOS表配置 - 调用 hmig_get_table_bos_31 存储过程获取 r_table,再查 tabletext + public static string GetTableBos(string tableName) + { + var db = SqlSugarHelper.DbOrig; + string t = tableName.Trim().ToUpperInvariant(); + + // 1. 调用存储过程,获取 r_id 和 r_table + var pRId = new SugarParameter("r_id", null, true) { DbType = System.Data.DbType.Decimal }; + var pRTable = new SugarParameter("r_table", null, true) { DbType = System.Data.DbType.String, Size = 200 }; + db.Ado.ExecuteCommand( + "BEGIN hmig_get_table_bos_31(:p_name, :r_id, :r_table); END;", + new SugarParameter("p_name", t), + pRId, + pRTable); + + // 解析 OUT 参数 + string rTable = pRTable.Value?.ToString() ?? ""; + string rId = pRId.Value?.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(rTable) || string.IsNullOrWhiteSpace(rId)) return ""; + + // 2. 根据 r_table 确定字段名,用 r_id 查对应记录的文本 + string colName = rTable.ToUpperInvariant() == "T_TABLETEXT" + ? "tabletext" + : "table_text"; + var textResult = db.Ado.GetDataTable( + $"SELECT {colName} FROM {rTable} WHERE id = :rid", + new SugarParameter("rid", rId)); + return textResult.Rows.Count > 0 ? textResult.Rows[0][colName]?.ToString() ?? "" : ""; + } + + /// 获取所有存储过程和函数列表 - 查 USER_OBJECTS,返回纯名称(不带前缀),不依赖存储过程 + public static string GetAllPrc() + { + var db = SqlSugarHelper.DbOrig; + var result = db.Ado.GetDataTable( + "SELECT OBJECT_NAME FROM USER_OBJECTS WHERE OBJECT_TYPE IN ('PROCEDURE','FUNCTION') AND STATUS='VALID' ORDER BY OBJECT_NAME"); + var sb = new StringBuilder(); + foreach (DataRow row in result.Rows) + sb.AppendLine(row["OBJECT_NAME"].ToString()); + return sb.ToString().TrimEnd(); + } + + /// 获取单个存储过程/函数源码 - 查 USER_SOURCE,拼接完整 CREATE OR REPLACE 语句 + public static string GetPrcSql(string prcName) + { + var db = SqlSugarHelper.DbOrig; + string name = prcName.Trim().ToUpperInvariant(); + + // 1. 查类型(PROCEDURE 或 FUNCTION) + var typeResult = db.Ado.GetDataTable( + "SELECT OBJECT_TYPE FROM USER_OBJECTS WHERE OBJECT_NAME = :n AND OBJECT_TYPE IN ('PROCEDURE','FUNCTION', 'TRIGGER')", + new SugarParameter("n", name)); + if (typeResult.Rows.Count == 0) return ""; + string objType = typeResult.Rows[0]["OBJECT_TYPE"].ToString(); // PROCEDURE 或 FUNCTION + + // 2. 查源码 + var result = db.Ado.GetDataTable( + "SELECT TEXT FROM USER_SOURCE WHERE NAME = :n ORDER BY LINE", + new SugarParameter("n", name)); + var sb = new StringBuilder(); + foreach (DataRow row in result.Rows) + sb.Append(row["TEXT"]); + + // 3. 拼接完整 CREATE OR REPLACE 语句 + string code = sb.ToString(); + if (code.StartsWith(objType)) + code = "CREATE OR REPLACE " + code; + + // 4. 添加末尾的 /(Oracle PL/SQL 块结束符) + code = code.TrimEnd(); + if (!code.EndsWith("/")) + code += "\n/"; + + return code; + } + + /// 获取表数据(INSERT语句)- 直接 SELECT + C# 拼接,不依赖存储过程 + public static string GetTableData(string tableName) + { + var db = SqlSugarHelper.DbOrig; + string safeName = tableName.Trim().ToUpperInvariant(); + + // 1. 检查行数 + var count = db.Ado.GetInt($"SELECT COUNT(*) FROM {safeName}"); + if (count == 0) + return $"-- 表 {safeName} 无数据"; + if (count > 200) + return $"-- 错误: 表 {safeName} 数据行数({count})超过200行限制"; + + // 2. 取列定义(列名 + 数据类型) + var cols = db.Ado.GetDataTable(@" + SELECT COLUMN_NAME, DATA_TYPE + FROM USER_TAB_COLUMNS + WHERE TABLE_NAME = :tbl + ORDER BY COLUMN_ID", + new SugarParameter("tbl", safeName)); + + // 3. 查所有数据 + var rows = db.Ado.GetDataTable($"SELECT * FROM {safeName}"); + + // 4. C# 端拼接 INSERT + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"-- 表 {safeName} 共 {count} 行数据"); + + for (int ri = 0; ri < rows.Rows.Count; ri++) + { + var dr = rows.Rows[ri]; + sb.Append("INSERT INTO " + safeName + " ("); + + // 列名 + for (int ci = 0; ci < cols.Rows.Count; ci++) + sb.Append(ci > 0 ? ", " + cols.Rows[ci]["COLUMN_NAME"] : cols.Rows[ci]["COLUMN_NAME"].ToString()); + sb.Append(") VALUES ("); + + // 值(按列名取,保证和列名对应) + for (int ci = 0; ci < cols.Rows.Count; ci++) + { + if (ci > 0) sb.Append(", "); + var colName = cols.Rows[ci]["COLUMN_NAME"].ToString(); + var dataType = cols.Rows[ci]["DATA_TYPE"].ToString(); + var val = dr[colName]; + if (val == null || val == DBNull.Value) + sb.Append("NULL"); + else if (dataType == "DATE" || dataType == "TIMESTAMP") + sb.Append($"TO_DATE('{val:yyyy-MM-dd HH:mm:ss}', 'YYYY-MM-DD HH24:MI:SS')"); + else + sb.Append("'" + val.ToString() + .Replace("'", "''") + .Replace("\r", "").Replace("\n", " ") + "'"); + } + sb.AppendLine(");"); + } + sb.Append("COMMIT;"); + return sb.ToString(); + } + + private static readonly string[] PlsqlKeywords = new[] + { + "PROCEDURE", "FUNCTION", "PACKAGE", "TRIGGER", "TYPE", "VIEW" + }; + + /// 从 CREATE 语句中提取对象名(用于查 USER_ERRORS) + private static string? GetObjectNameFromCreate(string sql) + { + string upper = sql.ToUpperInvariant(); + int createIdx = upper.IndexOf("CREATE "); + if (createIdx < 0) return null; + + // 找到 PROCEDURE / FUNCTION / PACKAGE 等关键字的位置 + int bestIdx = -1, bestLen = 0; + string? keyword = null; + foreach (var kw in PlsqlKeywords) + { + int idx = upper.IndexOf(kw, createIdx); + if (idx >= 0 && (bestIdx < 0 || idx < bestIdx)) + { + bestIdx = idx; + bestLen = kw.Length; + keyword = kw; + } + } + if (bestIdx < 0) return null; + + // 关键字后面的第一个非空 token 就是对象名(支持 schema.name 和 "引号名") + int pos = bestIdx + bestLen; + while (pos < sql.Length && char.IsWhiteSpace(sql[pos])) pos++; + if (pos >= sql.Length) return null; + + StringBuilder sb = new(); + // 处理带引号的标识符 + if (sql[pos] == '"') + { + pos++; // 跳过 opening quote + while (pos < sql.Length && sql[pos] != '"') sb.Append(sql[pos++]); + } + else + { + while (pos < sql.Length && !char.IsWhiteSpace(sql[pos]) && sql[pos] != '(' && sql[pos] != '.') + sb.Append(sql[pos++]); + // 处理 schema.name(再往前一个 token) + // 实际上上面已经读到 . 为止,需要重来 + } + // 简化:直接取 "keyword 之后的第一个完整 token(直到空格或括号)" + string token = GetNextToken(sql, bestIdx + bestLen); + if (string.IsNullOrEmpty(token)) return null; + + // token 可能是 schema.name,只取 name 部分 + if (token.Contains('.')) + token = token.Substring(token.LastIndexOf('.') + 1); + // 去掉引号 + token = token.Trim('"'); + return token.ToUpperInvariant(); + } + + private static string GetNextToken(string sql, int start) + { + while (start < sql.Length && char.IsWhiteSpace(sql[start])) start++; + if (start >= sql.Length) return ""; + StringBuilder sb = new(); + // 引号标识符 + if (sql[start] == '"') + { + start++; // 跳过 " + while (start < sql.Length && sql[start] != '"') sb.Append(sql[start++]); + return sb.ToString(); + } + while (start < sql.Length && !char.IsWhiteSpace(sql[start]) && sql[start] != '(') + sb.Append(sql[start++]); + return sb.ToString(); + } + + /// 检查刚执行的 CREATE 语句是否有编译错误,有则抛异常 + private static void CheckCompilationErrors(string sql) + { + string? objName = GetObjectNameFromCreate(sql); + if (objName == null) return; + + try + { + string query = @"SELECT LINE, POSITION, TEXT FROM USER_ERRORS WHERE NAME = :name ORDER BY LINE, POSITION"; + var dt = SqlSugarHelper.DbDest.Ado.GetDataTable(query, new { name = objName }); + if (dt.Rows.Count == 0) return; + + var sb = new StringBuilder(); + sb.AppendLine($"对象 {objName} 编译错误:"); + foreach (System.Data.DataRow row in dt.Rows) + { + sb.AppendLine($" Line {row["LINE"]}, Pos {row["POSITION"]}: {row["TEXT"]}"); + } + throw new Exception(sb.ToString()); + } + catch (Exception ex) when (!(ex is Exception && ex.Message.Contains("编译错误"))) + { + // 查询 USER_ERRORS 失败(如表不存在),忽略,不阻断写入流程 + } + } + + /// 在目标库执行建表语句 + public static void ExecuteCreate(string sql) + { + foreach (var stmt in SplitStatements(sql)) + { + // 彻底 Trim 去除首尾空白和换行 + string clean = stmt.Trim(); + if (string.IsNullOrWhiteSpace(clean)) continue; + + // 写日志便于排查 ORA 错误(写执行前的清理后语句) + string logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "execute_log.txt"); + File.AppendAllText(logPath, $"=== {DateTime.Now:HH:mm:ss} [{clean.Length} chars] ===\n{clean}\n\n"); + + try + { + SqlSugarHelper.DbDest.Ado.ExecuteCommand(clean); + CheckCompilationErrors(clean); + } + catch (Exception ex) + { + // 追加错误信息到日志 + File.AppendAllText(logPath, $"!!! ERROR: {ex.Message}\n"); + throw; // 重新抛出,让调用方捕获 + } + } + } + + /// 执行预处理存储过程 + public static void PreProcess() + { + var dbOrig = SqlSugarHelper.DbOrig; + foreach (var fname in Directory.GetFiles(CommonHelper.PrePath)) + { + string name = Path.GetFileNameWithoutExtension(fname); + string content = File.ReadAllText(fname); + dbOrig.Ado.ExecuteCommand(content); + } + } + + private static string[] SplitStatements(string sql) + { + // Oracle PL/SQL 块用 \n/ 分隔 + var parts = sql.Split(new[] { "\n/" }, StringSplitOptions.RemoveEmptyEntries); + var result = new List(); + foreach (var part in parts) + { + // Trim 掉首尾空白,保留语句主体 + var trimmed = part.Trim(); + if (!string.IsNullOrWhiteSpace(trimmed)) + result.Add(trimmed); + } + return result.ToArray(); + } + } +} \ No newline at end of file diff --git a/henlo_migration_wpf10/Services/ITabConfig.cs b/henlo_migration_wpf10/Services/ITabConfig.cs new file mode 100644 index 0000000..c636f45 --- /dev/null +++ b/henlo_migration_wpf10/Services/ITabConfig.cs @@ -0,0 +1,20 @@ +using System; +using henlo_migration.Enums; + +namespace henlo_migration.Services +{ + /// Tab 配置接口,描述一个 Tab 与数据库和文件的交互方式 + public interface ITabConfig + { + TabsEnum Tab { get; } + WorkTypeEnum WorkType { get; } + /// 索引文件目录(如 data/from/) + string IndexFolderPath { get; } + /// 内容文件目录(如 data/) + string ContentFolderPath { get; } + string FileName { get; } + CodeExt CodeExt { get; } + Func? ReadDbHandler { get; } + Action? CodeClickHandler { get; } + } +} diff --git a/henlo_migration_wpf10/Services/TabConfig.cs b/henlo_migration_wpf10/Services/TabConfig.cs new file mode 100644 index 0000000..50a646f --- /dev/null +++ b/henlo_migration_wpf10/Services/TabConfig.cs @@ -0,0 +1,38 @@ +using System; +using henlo_migration.Enums; + +namespace henlo_migration.Services +{ + /// Tab 配置实现,每个 Tab 一个实例 + public class TabConfig : ITabConfig + { + public TabsEnum Tab { get; } + public WorkTypeEnum WorkType { get; } = WorkTypeEnum.读; + /// 索引文件目录(如 data/from/) + public string IndexFolderPath { get; } + /// 内容文件目录(如 data/) + public string ContentFolderPath { get; } + public string FileName { get; } + public CodeExt CodeExt { get; } + public Func? ReadDbHandler { get; } + public Action? CodeClickHandler { get; } + + public TabConfig( + TabsEnum tab, + string indexFolderPath, + string contentFolderPath, + string fileName, + CodeExt codeExt, + Func? readDbHandler = null, + Action? codeClickHandler = null) + { + Tab = tab; + IndexFolderPath = indexFolderPath; + ContentFolderPath = contentFolderPath; + FileName = fileName; + CodeExt = codeExt; + ReadDbHandler = readDbHandler; + CodeClickHandler = codeClickHandler; + } + } +} diff --git a/henlo_migration_wpf10/Services/TabServices.cs b/henlo_migration_wpf10/Services/TabServices.cs new file mode 100644 index 0000000..314dd9e --- /dev/null +++ b/henlo_migration_wpf10/Services/TabServices.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using henlo_migration.Common; +using henlo_migration.Enums; +using henlo_migration.Models; + +namespace henlo_migration.Services +{ + /// 管理单个Tab的数据列表和操作 + public class TabServices + { + private readonly ITabConfig _cfg; + + public ObservableCollection List { get; } = new(); + + public event Action? RowClickEvent; + + public TabServices(ITabConfig cfg) + { + _cfg = cfg; + LoadFromFile(); + } + + public string GetCodeExt() => _cfg.CodeExt.ToString(); + /// 内容文件目录(读取后生成的文件存放位置) + public string GetContentFolderPath() => _cfg.ContentFolderPath; + + public void LoadFromFile() + { + // 读索引文件 + string filePath = Path.Combine(_cfg.IndexFolderPath, _cfg.FileName); + var items = CommonHelper.ReadText(filePath); + foreach (var item in items) + { + item.Tab = _cfg.Tab; + // 内容文件存到 data/ 目录 + string itemFileName = $"{item.Name}_{_cfg.CodeExt}.txt"; + string itemFilePath = Path.Combine(_cfg.ContentFolderPath, itemFileName); + item.RStatus = File.Exists(itemFilePath) ? StatusEnum.已存在 : StatusEnum.不存在; + List.Add(item); + } + } + + public void SaveToFile() + { + string filePath = Path.Combine(_cfg.IndexFolderPath, _cfg.FileName); + var lines = new string[List.Count]; + for (int i = 0; i < List.Count; i++) + lines[i] = List[i].Name; + File.WriteAllLines(filePath, lines); + } + + public void AddRow(string name) + { + if (string.IsNullOrWhiteSpace(name)) return; + if (List.Count == 0 || !List.Any(x => x.Name == name)) + { + List.Add(new TableRowModel { Name = name, Tab = _cfg.Tab }); + SaveToFile(); + } + } + + public async Task ReadRow(TableRowModel row) + { + if (!SqlSugarHelper.CheckOrigConnect()) + { + HandyControl.Controls.Growl.Warning("源数据库未连接,请先连接"); + return; + } + row.RStatus = StatusEnum.读取中; + try + { + string code = await Task.Run(() => + _cfg.ReadDbHandler != null ? _cfg.ReadDbHandler(row.Name) : ""); + + // 内容文件写入 data/ 目录 + string fileName = $"{row.Name}_{_cfg.CodeExt}.txt"; + string filePath = Path.Combine(_cfg.ContentFolderPath, fileName); + + CommonHelper.WriteToFile(filePath, code); + row.RStatus = StatusEnum.已读取; + OnRowClick(filePath, code); + } + catch (Exception ex) + { + row.RStatus = StatusEnum.读取失败; + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + Views.ErrorDialog.ShowError($"读取 [{row.Name}] 失败:\n\n{ex.Message}\n\n{ex.StackTrace}"); + }); + } + } + + public async Task WriteRow(TableRowModel row) + { + if (!SqlSugarHelper.CheckDescConnect()) + { + HandyControl.Controls.Growl.Warning("目标数据库未连接,请先连接"); + return; + } + row.WStatus = StatusEnum.写入中; + try + { + // 从 data/ 目录读取内容文件 + string fileName = $"{row.Name}_{_cfg.CodeExt}.txt"; + string filePath = Path.Combine(_cfg.ContentFolderPath, fileName); + string code = File.ReadAllText(filePath); + + await Task.Run(() => DbServices.ExecuteCreate(code)); + row.WStatus = StatusEnum.已写入; + HandyControl.Controls.Growl.Success($"写入成功:{row.Name}"); + } + catch (Exception ex) + { + row.WStatus = StatusEnum.写入失败; + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + Views.ErrorDialog.ShowError($"写入 [{row.Name}] 失败:\n\n{ex.Message}\n\n{ex.StackTrace}"); + }); + } + } + + public void DeleteRow(TableRowModel row) + { + // 删除内容文件(data/ 目录) + string itemFileName = $"{row.Name}_{_cfg.CodeExt}.txt"; + string itemFilePath = Path.Combine(_cfg.ContentFolderPath, itemFileName); + if (File.Exists(itemFilePath)) + File.Delete(itemFilePath); + List.Remove(row); + SaveToFile(); + } + + public void MoveUp(TableRowModel row) + { + int idx = List.IndexOf(row); + if (idx > 0) + { + List.Move(idx, idx - 1); + SaveToFile(); + } + } + + public void MoveDown(TableRowModel row) + { + int idx = List.IndexOf(row); + if (idx < List.Count - 1) + { + List.Move(idx, idx + 1); + SaveToFile(); + } + } + + public void MoveToTop(TableRowModel row) + { + int idx = List.IndexOf(row); + if (idx > 0) + { + List.Move(idx, 0); + SaveToFile(); + } + } + + public void OnRowClick(string filePath, string code) + { + RowClickEvent?.Invoke(filePath, code); + } + } +} \ No newline at end of file diff --git a/henlo_migration_wpf10/ViewModels/MainViewModel.cs b/henlo_migration_wpf10/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..5b82509 --- /dev/null +++ b/henlo_migration_wpf10/ViewModels/MainViewModel.cs @@ -0,0 +1,508 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Input; +using henlo_migration.Common; +using henlo_migration.Enums; +using henlo_migration.Models; +using henlo_migration.Services; + +namespace henlo_migration.ViewModels +{ + public class MainViewModel : INotifyPropertyChanged + { + // 四个 Tab 的数据 + public TabServices TableTab { get; } + public TabServices BosTab { get; } + public TabServices PrcTab { get; } + public TabServices DataTab { get; } + + // 当前激活的 TabServices + private TabServices? _currentTab; + private TabServices? CurrentTab + { + get => _currentTab; + set + { + _currentTab = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(TablesData)); + OnPropertyChanged(nameof(BosTablesData)); + OnPropertyChanged(nameof(StoredProcsData)); + OnPropertyChanged(nameof(CurrentDataData)); + } + } + + // Tab 切换 + private int _selectedTabIndex; + public int SelectedTabIndex + { + get => _selectedTabIndex; + set + { + _selectedTabIndex = value; + CurrentTab = value switch + { + 0 => TableTab, + 1 => BosTab, + 2 => PrcTab, + 3 => DataTab, + _ => TableTab + }; + OnPropertyChanged(); + OnPropertyChanged(nameof(TablesData)); + OnPropertyChanged(nameof(BosTablesData)); + OnPropertyChanged(nameof(StoredProcsData)); + OnPropertyChanged(nameof(CurrentDataData)); + OnPropertyChanged(nameof(CurrentTabName)); + OnPropertyChanged(nameof(CurrentTabCount)); + } + } + + // 四个Tab数据源别名(供DataGrid绑定) + public ObservableCollection TablesData => TableTab.List; + public ObservableCollection BosTablesData => BosTab.List; + public ObservableCollection StoredProcsData => PrcTab.List; + public ObservableCollection CurrentDataData => DataTab.List; + + public string CurrentTabName => SelectedTabIndex switch + { + 0 => "表配置", + 1 => "BOS配置", + 2 => "存储过程", + 3 => "表数据", + _ => "" + }; + + public string CurrentTabCount => CurrentTab?.List.Count.ToString() ?? "0"; + + // 选中的行 + private TableRowModel? _selectedRow; + public TableRowModel? SelectedRow + { + get => _selectedRow; + set + { + _selectedRow = value; + OnPropertyChanged(); + // 选中行时自动加载已有文件内容 + LoadSelectedRowContent(); + } + } + + private void LoadSelectedRowContent() + { + if (_selectedRow == null || CurrentTab == null) return; + + // 构建文件路径 + string fileName = $"{_selectedRow.Name}_{CurrentTab.GetCodeExt()}.txt"; + string filePath = Path.Combine(CurrentTab.GetContentFolderPath(), fileName); + + // 始终设置 CurrentFileName,这样保存按钮可用 + CurrentFileName = filePath; + + if (File.Exists(filePath)) + { + try + { + string content = File.ReadAllText(filePath); + CurrentFileContent = content; + SqlCode = content; + } + catch { CurrentFileContent = ""; SqlCode = ""; } + } + else + { + // 文件不存在,清空内容但保留文件路径 + CurrentFileContent = ""; + SqlCode = ""; + } + } + + // SQL 编辑区 + private string _sqlCode = ""; + public string SqlCode + { + get => _sqlCode; + set { _sqlCode = value; OnPropertyChanged(); } + } + + // 选中行详情面板 + private string _currentFileName = ""; + public string CurrentFileName + { + get => _currentFileName; + set { _currentFileName = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasCurrentFile)); (SaveCurrentContentCommand as RelayCommand)?.RaiseCanExecuteChanged(); } + } + + private string _currentFileContent = ""; + public string CurrentFileContent + { + get => _currentFileContent; + set { _currentFileContent = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasCurrentFile)); } + } + + public bool HasCurrentFile => !string.IsNullOrEmpty(CurrentFileName); + + // 保存当前文件内容命令 + public ICommand SaveCurrentContentCommand { get; } + + // 输入框 + private string _inputText = ""; + public string InputText + { + get => _inputText; + set { _inputText = value; OnPropertyChanged(); } + } + + // 数据库连接状态 + private bool _origConnected; + public bool OrigConnected + { + get => _origConnected; + set + { + _origConnected = value; + OnPropertyChanged(); + // 触发命令可用性更新 + (ConnectOrigCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (DisconnectOrigCommand as RelayCommand)?.RaiseCanExecuteChanged(); + } + } + + private bool _destConnected; + public bool DestConnected + { + get => _destConnected; + set + { + _destConnected = value; + OnPropertyChanged(); + (ConnectDestCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (DisconnectDestCommand as RelayCommand)?.RaiseCanExecuteChanged(); + } + } + + // 连接中状态 + private bool _isConnecting; + public bool IsConnecting + { + get => _isConnecting; + set + { + _isConnecting = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(CanConnect)); + (ConnectOrigCommand as RelayCommand)?.RaiseCanExecuteChanged(); + (ConnectDestCommand as RelayCommand)?.RaiseCanExecuteChanged(); + } + } + + public bool CanConnect => !IsConnecting; + + // 连接配置属性(密码改为明文显示,方便用户确认,保存时才加密写入) + private string _origIp = ""; + public string OrigIp { get => _origIp; set { _origIp = value; OnPropertyChanged(); OnPropertyChanged(nameof(OrigConnDisplay)); } } + private string _origExam = ""; + public string OrigExam { get => _origExam; set { _origExam = value; OnPropertyChanged(); OnPropertyChanged(nameof(OrigConnDisplay)); } } + private string _origUser = ""; + public string OrigUser { get => _origUser; set { _origUser = value; OnPropertyChanged(); OnPropertyChanged(nameof(OrigConnDisplay)); } } + private string _origPwd = ""; + public string OrigPwd { get => _origPwd; set { _origPwd = value; OnPropertyChanged(); } } + + private string _destIp = ""; + public string DestIp { get => _destIp; set { _destIp = value; OnPropertyChanged(); OnPropertyChanged(nameof(DestConnDisplay)); } } + private string _destExam = ""; + public string DestExam { get => _destExam; set { _destExam = value; OnPropertyChanged(); OnPropertyChanged(nameof(DestConnDisplay)); } } + private string _destUser = ""; + public string DestUser { get => _destUser; set { _destUser = value; OnPropertyChanged(); OnPropertyChanged(nameof(DestConnDisplay)); } } + private string _destPwd = ""; + public string DestPwd { get => _destPwd; set { _destPwd = value; OnPropertyChanged(); } } + + public string OrigConnDisplay => string.IsNullOrEmpty(OrigIp) ? "未配置" : $"{OrigIp}/{OrigExam}/{OrigUser}"; + public string DestConnDisplay => string.IsNullOrEmpty(DestIp) ? "未配置" : $"{DestIp}/{DestExam}/{DestUser}"; + + // 密码遮罩显示(用于 TextBox 绑定) + public string OrigPwdDisplay => new string('●', OrigPwd?.Length ?? 0); + public string DestPwdDisplay => new string('●', DestPwd?.Length ?? 0); + + // 命令 + public ICommand ConnectOrigCommand { get; } + public ICommand ConnectDestCommand { get; } + public ICommand SaveOrigCommand { get; } + public ICommand SaveDestCommand { get; } + public ICommand DisconnectOrigCommand { get; } + public ICommand DisconnectDestCommand { get; } + public ICommand AddRowCommand { get; } + public ICommand OpenDataFolderCommand { get; } + public ICommand PreProcessCommand { get; } + public ICommand BatchReadCommand { get; } + public ICommand BatchWriteCommand { get; } + + // 行操作命令 + public ICommand ReadCommand { get; } + public ICommand WriteCommand { get; } + public ICommand DeleteCommand { get; } + public ICommand MoveUpCommand { get; } + public ICommand MoveDownCommand { get; } + public ICommand MoveTopCommand { get; } + + public MainViewModel() + { + // 从配置加载连接信息 + OrigIp = SysConfig.GetOrigIp(); + OrigExam = SysConfig.GetOrigExam(); + OrigUser = SysConfig.GetOrigUser(); + OrigPwd = SysConfig.GetOrigPwd(); + DestIp = SysConfig.GetDestIp(); + DestExam = SysConfig.GetDestExam(); + DestUser = SysConfig.GetDestUser(); + DestPwd = SysConfig.GetDestPwd(); + + // 初始化四个 TabServices(索引文件在 from/,内容文件在 data/) + TableTab = new TabServices(new TabConfig(TabsEnum.表, CommonHelper.DataFromPath, CommonHelper.DataPath, "table.txt", CodeExt.creat, + DbServices.GetTableSql, (p, c) => SqlCode = c)); + + BosTab = new TabServices(new TabConfig(TabsEnum.BOS表配置, CommonHelper.DataFromPath, CommonHelper.DataPath, "bos.txt", CodeExt.bos, + DbServices.GetTableBos, (p, c) => SqlCode = c)); + + PrcTab = new TabServices(new TabConfig(TabsEnum.存储过程, CommonHelper.DataFromPath, CommonHelper.DataPath, "prc.txt", CodeExt.prc, + name => DbServices.GetPrcSql(name), (p, c) => SqlCode = c)); + + DataTab = new TabServices(new TabConfig(TabsEnum.表数据, CommonHelper.DataFromPath, CommonHelper.DataPath, "data.txt", CodeExt.data, + DbServices.GetTableData, (p, c) => SqlCode = c)); + + // Tab行点击 -> 显示SQL + foreach (var tab in new[] { TableTab, BosTab, PrcTab, DataTab }) + { + tab.RowClickEvent += (path, code) => + { + SqlCode = code; + CurrentFileName = path; + CurrentFileContent = code; + }; + } + + // 命令初始化 + ConnectOrigCommand = new RelayCommand(async () => await ConnectOrigAsync(), () => !IsConnecting && !OrigConnected); + ConnectDestCommand = new RelayCommand(async () => await ConnectDestAsync(), () => !IsConnecting && !DestConnected); + SaveOrigCommand = new RelayCommand(SaveOrig); + SaveDestCommand = new RelayCommand(SaveDest); + DisconnectOrigCommand = new RelayCommand(DisconnectOrig, () => OrigConnected); + DisconnectDestCommand = new RelayCommand(DisconnectDest, () => DestConnected); + AddRowCommand = new RelayCommand(AddRow, () => CurrentTab != null && !string.IsNullOrWhiteSpace(InputText)); + OpenDataFolderCommand = new RelayCommand(OpenDataFolder); + PreProcessCommand = new RelayCommand(PreProcess); + BatchReadCommand = new RelayCommand(async () => await BatchReadAsync()); + BatchWriteCommand = new RelayCommand(async () => await BatchWriteAsync()); + + ReadCommand = new RelayCommand(async row => await ReadRowAsync(row)); + WriteCommand = new RelayCommand(async row => await WriteRowAsync(row)); + DeleteCommand = new RelayCommand(DeleteRow); + MoveUpCommand = new RelayCommand(MoveUp); + MoveDownCommand = new RelayCommand(MoveDown); + MoveTopCommand = new RelayCommand(MoveTop); + SaveCurrentContentCommand = new RelayCommand(SaveCurrentContent, () => HasCurrentFile); + + // 默认选中第一个Tab + CurrentTab = TableTab; + } + + private async Task ConnectOrigAsync() + { + IsConnecting = true; + try + { + SysConfig.SetOrig(OrigIp, OrigExam, OrigUser, OrigPwd); + SqlSugarHelper.Connect(); + OrigConnected = SqlSugarHelper.CheckOrigConnect(); + if (!OrigConnected) HandyControl.Controls.Growl.Warning("源数据库连接失败,请检查配置"); + else HandyControl.Controls.Growl.Success("源数据库连接成功"); + } + catch + { + OrigConnected = false; + HandyControl.Controls.Growl.Warning("源数据库连接失败"); + } + finally { IsConnecting = false; } + } + + private async Task ConnectDestAsync() + { + IsConnecting = true; + try + { + SysConfig.SetDest(DestIp, DestExam, DestUser, DestPwd); + SqlSugarHelper.Connect(); + DestConnected = SqlSugarHelper.CheckDescConnect(); + if (!DestConnected) HandyControl.Controls.Growl.Warning("目标数据库连接失败,请检查配置"); + else HandyControl.Controls.Growl.Success("目标数据库连接成功"); + } + catch + { + DestConnected = false; + HandyControl.Controls.Growl.Warning("目标数据库连接失败"); + } + finally { IsConnecting = false; } + } + + // 保存配置(只写 INI,不断开/重连) + private void SaveOrig() { SysConfig.SetOrig(OrigIp, OrigExam, OrigUser, OrigPwd); HandyControl.Controls.Growl.Success("源库配置已保存"); } + private void SaveDest() { SysConfig.SetDest(DestIp, DestExam, DestUser, DestPwd); HandyControl.Controls.Growl.Success("目标库配置已保存"); } + + // 断开连接(只改状态,不真正关闭 SqlSugarScope,保持实例可用) + private void DisconnectOrig() + { + OrigConnected = false; + HandyControl.Controls.Growl.Info("已断开源数据库"); + } + + private void DisconnectDest() + { + DestConnected = false; + HandyControl.Controls.Growl.Info("已断开目标数据库"); + } + + private void AddRow() + { + if (!string.IsNullOrWhiteSpace(InputText)) + { + CurrentTab?.AddRow(InputText.Trim().ToLower()); + InputText = ""; + } + } + + private void SaveCurrentContent() + { + if (string.IsNullOrEmpty(CurrentFileName)) return; + try + { + File.WriteAllText(CurrentFileName, CurrentFileContent); + HandyControl.Controls.Growl.Success("保存成功"); + } + catch (Exception ex) + { + HandyControl.Controls.Growl.Error($"保存失败:{ex.Message}"); + } + } + + private void OpenDataFolder() + { + Process.Start("explorer.exe", CommonHelper.DataPath); + } + + private void PreProcess() + { + try { DbServices.PreProcess(); } + catch { } + } + + private async Task BatchReadAsync() + { + if (CurrentTab == null) return; + foreach (var row in CurrentTab.List) + await CurrentTab.ReadRow(row); + } + + /// 批量写入断点:记录上次失败行的索引,下次从此处继续 + private int _breakpointIndex = -1; + public int BreakpointIndex + { + get => _breakpointIndex; + set { _breakpointIndex = value; OnPropertyChanged(); OnPropertyChanged(nameof(HasBreakpoint)); } + } + public bool HasBreakpoint => _breakpointIndex >= 0; + + private async Task BatchWriteAsync() + { + if (CurrentTab == null) return; + int startIdx = _breakpointIndex >= 0 ? _breakpointIndex : 0; + bool hasNewFailure = false; + for (int i = startIdx; i < CurrentTab.List.Count; i++) + { + var row = CurrentTab.List[i]; + await CurrentTab.WriteRow(row); + if (row.WStatus == StatusEnum.写入失败) + { + _breakpointIndex = i; + OnPropertyChanged(nameof(BreakpointIndex)); + OnPropertyChanged(nameof(HasBreakpoint)); + hasNewFailure = true; + break; + } + } + // 全部成功(或从断点后全部成功)→ 重置断点 + if (!hasNewFailure && _breakpointIndex >= 0) + { + _breakpointIndex = -1; + OnPropertyChanged(nameof(BreakpointIndex)); + OnPropertyChanged(nameof(HasBreakpoint)); + } + } + + private async Task ReadRowAsync(TableRowModel? row) + { + if (row == null || CurrentTab == null) return; + await CurrentTab.ReadRow(row); + } + + private async Task WriteRowAsync(TableRowModel? row) + { + if (row == null || CurrentTab == null) return; + await CurrentTab.WriteRow(row); + } + + private void DeleteRow(TableRowModel? row) + { + CurrentTab?.DeleteRow(row!); + } + + private void MoveUp(TableRowModel? row) + { + CurrentTab?.MoveUp(row!); + } + + private void MoveDown(TableRowModel? row) + { + CurrentTab?.MoveDown(row!); + } + + private void MoveTop(TableRowModel? row) + { + CurrentTab?.MoveToTop(row!); + } + + public event PropertyChangedEventHandler? PropertyChanged; + protected void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + + // 简单命令(无参数) + public class RelayCommand : ICommand + { + private readonly Action _act; + private readonly Func? _can; + public RelayCommand(Action act, Func? can = null) { _act = act; _can = can; } + public bool CanExecute(object? p) => _can?.Invoke() ?? true; + public void Execute(object? p) => _act(); + public event EventHandler? CanExecuteChanged; + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + + // 简单命令(带参数) + public class RelayCommand : ICommand + { + private readonly Action _act; + public RelayCommand(Action act) { _act = act; } + public bool CanExecute(object? p) => true; + public void Execute(object? p) => _act((T?)p); +#pragma warning disable CS0067 + public event EventHandler? CanExecuteChanged; +#pragma warning restore CS0067 + } +} diff --git a/henlo_migration_wpf10/Views/ConnectionDialog.xaml b/henlo_migration_wpf10/Views/ConnectionDialog.xaml new file mode 100644 index 0000000..4e3a3dc --- /dev/null +++ b/henlo_migration_wpf10/Views/ConnectionDialog.xaml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +