面向 Hugo 内容管理者的 Windows 桌面客户端
记录一款面向 Hugo 内容管理者的 Windows 桌面客户端。
一切的起点:Hugo 很好,但“发布”太痛了
我使用 Hugo 搭建个人博客已经很长时间了。坦白说,Hugo 本身作为静态站点生成器,速度快、生态好、主题丰富,几乎无可挑剔。
但有一个环节,始终让我如鲠在喉——发布上线。
每一次写完文章,我需要经历的是:
- 打开命令行,cd 到站点目录;
- 执行
hugo构建,生成public/目录; - 打开 FTP/SFTP 客户端,连接到服务器;
- 手动找到远程网站根目录(比如宝塔面板的
/www/wwwroot/xxx); - 将本地
public/目录里的文件,一个一个上传覆盖; - 刷新浏览器,确认上线。
如果只是偶尔写一篇,倒也还能忍。但当你需要频繁更新、修正错别字、调整排版时,这套流程的重复成本就会急剧放大。更别提有时候 SFTP 客户端连接超时、文件漏传、目录搞错——每一步都藏着让人抓狂的细节。
我也尝试过 Git + GitHub Actions 或 Webhook 的自动化方案,但坦白说,配置门槛同样不低:要在服务器上建 bare 仓库、配置 post-receive hook、设置权限……对于只想安静写点东西的博主来说,这显然是过度工程。
转折忘了具体哪天,我偶然刷到一款叫“一言”的 APP——它的核心功能非常简单:用户在手机端写下几句话,点击发布,内容就会立即出现在个人页面上。没有复杂的后台配置,没有“部署”这个概念,用户感知到的只有一个动作:写,然后它就上线了。
这个极简体验给了我很大的触动。
我在想:Hugo 博客能不能也做到这样?
能不能有一个界面,用户只负责:
- 在本地打开一个编辑器;
- 写 Markdown;
- 点击一个按钮;
然后剩下的所有事情(保存文件、调用 Hugo 构建、通过 SSH/SFTP 上传到宝塔面板的网站目录)全部由工具自动完成?
有想法了,也有目标了,说搞就搞。开始借助AI帮我整理思路,以及项目规划。
一、项目全景与核心目标
1.1 我的项目定位
一款面向 Hugo 内容管理者的 Windows 桌面客户端(C# + WPF),让使用者无需接触命令行,在图形界面中完成:
写文章 → 管文件 → 本地预览 → 构建 → 发布上线 的完整闭环。
1.2 使用的技术栈
| 项 | 内容 | 备注 |
|---|---|---|
| 语言/框架 | C# + WPF | .cs 源文件,非脚本 |
| 目标框架 | net8.0-windows |
必须,WPF 不跨平台 |
| 构建发布 | dotnet build / dotnet publish |
单文件 self-contained exe,build.bat 驱动 |
| 静态站点 | Hugo 0.157.0 extended |
随应用分发,不依赖用户安装 |
| AI 能力 | DeepSeek Chat Completions | OpenAI 兼容,SSE 流式 + function calling |
| 上传发布 | SSH.NET(Renci.SshNet) | 通过 SFTP 上传 public/ 到服务器 |
| 加密依赖 | BouncyCastle.Cryptography 2.4.0 | SSH.NET 的底层加密库(需手工补链) |
| 配置文件 | %APPDATA%\Huge\settings.json |
JSON 明文(含 API Key / SFTP 密码) |
| 增量发布缓存 | %APPDATA%\Huge\deploy-cache\ |
基于 SHA256 的清单,按内容而非 mtime 判断 |
1.3 功能模块清单
| 模块 | 状态 | 说明 |
|---|---|---|
| 文件树导航 | ✅ 已实现 | 只展示 content/static/assets,隐藏生成物;支持右键菜单 + 快捷键 |
| Markdown 编辑器 | ✅ 已实现 | 行号、格式化工具栏、Frontmatter 表单编辑器、图片预览 |
| 编辑/预览切换 | ✅ 已实现 | 一键切换源文件编辑 ↔ 渲染后预览(仿 VNote/VS Code) |
| 内联 HTML 渲染 | ✅ 已实现 | 预览时支持 <span>、<br>、<hr> 等标签,递归解析内部 Markdown |
| Hugo 服务器控制 | ✅ 已实现 | 自动定位/下载 hugo.exe、启动 hugo server、日志与端口检测 |
| AI 助手(DeepSeek) | ✅ 已实现 | 流式对话、Markdown 渲染、read_file 工具调用、AI 自动建文章 |
| 双主题 + 国际化 | ✅ 已实现 | 亮/暗(跟随系统)、中英文界面切换 |
| SFTP 一键发布 | ✅ 已实现 | 私钥优先/密码兜底认证、测试连接、递归上传、增量发布 |
| 增量上传(内容指纹) | ✅ 已实现 | 基于 SHA256 + 大小 的清单比对,跳过未变化文件 |
二、开发路线图(实战修正版)
以下路线图已根据你的开发日志中的真实踩坑与解决方案进行修正和补充。
阶段一:环境准备与项目初始化 ✅
已完成内容:
- Visual Studio 2022 / Rider + .NET 8 SDK
- Hugo extended
v0.157.0放入tools/目录随应用分发 - 创建 WPF 项目,目标框架
net8.0-windows - 添加核心 NuGet 包(在线环境):
Newtonsoft.Json、Markdig、AvalonEdit - 创建标准目录结构(Views/ViewModels/Models/Services/Helpers/Resources/tools/)
⚠️ 离线依赖前置提醒(来自阶段6的经验):
- 如果目标环境无法访问
api.nuget.org,不要依赖dotnet restore。- SSH.NET 和 BouncyCastle 需通过本地引用方式引入(详见阶段六)。
阶段二:基础架构层 ✅
已完成内容:
-
配置服务(
SettingsService)- 配置文件路径:
%APPDATA%\Huge\settings.json - 包含:站点根目录、SFTP 配置(Host/Port/User/Password/KeyFile/RemoteDir)、DeepSeek API Key、UI 主题/语言
- 使用
Newtonsoft.Json读写
- 配置文件路径:
-
日志服务(
LogService)- 同时输出到应用内日志面板 +
%APPDATA%\Huge\logs\文件
- 同时输出到应用内日志面板 +
📌 设计决策:
- API Key 和 SFTP 密码明文存储——需求如此(个人工具可接受)
- 如需更高安全性,可后续增加 Windows DPAPI 加密
阶段三:文件树导航模块 ✅
已完成内容:
- 目录过滤:只展示
content/、static/、assets/三个根目录,隐藏public/、resources/、.git/等 - 右键菜单:新建文章、新建文件夹、重命名、删除、复制/粘贴、在文件管理器中打开
- 快捷键:
Ctrl+C/Ctrl+V→ 复制/粘贴文件(基于内存字段,不与系统剪贴板冲突)F2→ 重命名ESC→ 取消重命名/新建输入框
- 粘贴目标智能判定:选中文件夹→拷入其中;选中文件→拷入其所在目录;选中根/空白→拷入项目根
- 同名自动改名:
hello.md→hello - 2.md,不覆盖 - 文件夹递归复制:支持
CopyDirectoryRecursive
⚠️ 踩坑记录:
- 控件改名必须全局检查:把
GitCommitBtn重命名为DeployBtn后,需同步检查 XAMLx:Name、Click=事件、ApplyLanguage()、SetProjectButtons()等所有引用处。推荐 IDE 重命名功能或全文 grep(注意findstr对 UTF-8 含中文文件会漏匹配)。- C# 保留关键字:不要用
base当变量名,会触发CS1002报错。- D 语言风格命名在 C# 中会报
CS1002,如把Path.GetFileNameWithoutExtension误写成其他库的 API 名。
阶段四:Markdown 编辑器 + 预览模块 ✅
已完成内容:
- 编辑器核心:AvalonEdit 控件,支持行号、Markdown 语法高亮
- 格式化工具栏:加粗、斜体、标题、链接、图片、代码块、有序/无序列表等
- Frontmatter 表单编辑器:解析
---之间的 YAML/TOML/JSON 数据,以表单形式展示和编辑 - 编辑/预览模式切换(阶段1):
- 按钮切换「Markdown 源文件编辑」↔「渲染后页面预览」
- 预览时剥离 frontmatter,调用
MarkdownRenderer.Render()生成 FlowDocument - 编辑/预览与 AI 聊天共用同一渲染器,一处修改两处生效
- 内联 HTML 标签支持(阶段2):
<span class="...">内部文本</span>→ 丢弃标签属性,保留内部文本- 标签内部支持递归解析 Markdown 语法(如
<span>**加粗**</span>) <br>/<hr>→ 转为LineBreak- HTML 注释
<!-- -->→ 整段跳过 - 普通文本中的
<(如a < b)不受影响
⚠️ 踩坑记录:
- XAML 缩进整理时残留重复块:一次整理 Button 模板缩进时,残留了重复的
<Button.Template>块,导致MC3015: The attached property 'Button.Template' is not defined on 'ControlTemplate'。报错行号指向第128行,但根源是上一行的重复块。- 切文件时面板状态残留:
OpenFile和LoadProject中必须重置_previewMode和各面板可见性,否则切换文件时预览状态会残留。
阶段五:Hugo 服务器控制与构建 ✅
已完成内容:
-
Hugo 可执行文件管理:
- 优先检查
./tools/hugo.exe - 其次检查 PATH 环境变量
- 两者都不存在 → 提示用户下载(当前版本未实现自动下载,由分发时携带)
- 优先检查
-
启动
hugo server:Process启动,参数server --source="{siteRoot}" --bind=127.0.0.1 --port={port}- 异步捕获
OutputDataReceived/ErrorDataReceived,转发到日志面板 - 解析输出提取端口,嵌入 WebView2 预览
-
hugo构建(发布时):- 执行
hugo --source="{siteRoot}" --destination="{siteRoot}/public"
- 执行
📌 设计决策:
- Hugo 版本锁定
0.157.0 extended——随应用分发,不依赖用户安装- 构建输出目录固定为
public/,上传器以此为源
阶段六:SFTP 上传发布模块 ✅(核心模块,含较多踩坑)
已完成内容:
-
依赖引入(离线环境处理):
- 由于 SDK 机器无法访问
api.nuget.org,采用本地引用方式:- 在有网络的机器下载
SSH.NET和BouncyCastle.Cryptography的 nupkg - 解包取 DLL 放入项目
lib/目录 Huge.csproj使用<Reference>+<HintPath>引用.gitignore需豁免!lib/*.dll,确保版本库同步时 DLL 不被丢弃
- 在有网络的机器下载
- 关键:本地
<Reference>不会自动解析传递依赖,需要手工补全整条依赖链
- 由于 SDK 机器无法访问
-
SFTP 认证:
- 私钥优先:
PrivateKeyAuthenticationMethod(OpenSSH 格式) - 密码兜底:用户名 + 密码
TestConnection():验证连通性 + 远程目录存在性检查
- 私钥优先:
-
递归上传:
EnsureRemoteDir()逐级创建远程目录UploadDirectoryRecursive()递归上传所有文件
-
增量上传(内容指纹)(阶段8):
- 每次发布后在本地记录清单
%APPDATA%\Huge\deploy-cache\<哈希>.json - 清单内容:相对路径 →
{ Size, SHA256 } - 下次遍历时比对大小 + SHA256:
- 一致 → 跳过(即使 Hugo 重写了文件、mtime 已变)
- 变化/新增 → 覆盖上传并更新清单
- UI 反馈:
上传 N 个,跳过未变化 M 个 - 强制全量:删除
deploy-cache目录再发布
- 每次发布后在本地记录清单
-
安全优先:默认不删除远端多余文件,防止误删手工文件
-
从 Git 改造为 SFTP 一键发布(阶段5):
- 将原
GitCommitBtn改为DeployBtn(🚀 构建发布) - 按钮右键菜单可打开发布设置
- 完整流程:保存未存编辑 → 未配置则弹设置框 → 定位 hugo →
hugo构建 → 校验public/→ SFTP 增量上传 → 汇报数量 - 删除整套 Git 方法
- 将原
⚠️ 踩坑记录(重要):
- NuGet 包名与命名空间不一致:包 ID 是
SSH.NET(大写),命名空间是Renci.SshNet。另有旧包Renci.SshNet(被弃用,指向 1.0.0),别下错。- 本地引用丢失传递依赖:
<Reference>不会自动带入依赖库。SSH.NET 在 net8.0 下依赖BouncyCastle.Cryptography 2.4.0,需手工补链。nuspec 的<dependencies>可一次性列出需补的库。- 运行时「类型初始化失败」:
The type initializer for 'Renci.SshNet.Abstractions.CryptoAbstraction' threw an exception.强烈提示缺 BouncyCastle 依赖 DLL,而非业务 bug。- 程序集版本 ≠ NuGet 包版本:
BouncyCastle.Cryptography的 NuGet 包版本是 2.4.0,但程序集版本是 2.0.0.0,用AssemblyName.GetAssemblyName验证即可。
阶段七:AI 助手模块(DeepSeek) ✅
已完成内容:
-
DeepSeek API 客户端:
- 调用
https://api.deepseek.com/chat/completions(OpenAI 兼容格式) - 使用
HttpClient,携带 Bearer Token
- 调用
-
SSE 流式响应处理:
HttpCompletionOption.ResponseHeadersRead逐行读取- 解析
data: {...}提取delta.content,通过Dispatcher更新 UI
-
Function Calling:
- 定义
read_file工具,AI 可请求读取指定路径文件内容 - 当 AI 返回
tool_calls时,执行对应函数,将结果传回 AI 继续对话
- 定义
-
AI 自动建文章:
- 用户输入主题 → AI 生成标题、Frontmatter 和正文 → 自动创建
.md文件到content/→ 刷新文件树
- 用户输入主题 → AI 生成标题、Frontmatter 和正文 → 自动创建
⚠️ 注意事项:
- API Key 存储在
settings.json中(明文)- SSE 流式响应需在异步线程处理,通过
Dispatcher更新 UI- 空引用处理:
_aiCts?.Token ?? CancellationToken.None(阶段3修复了 CS8602 警告)
阶段八:双主题与国际化 ✅
已完成内容:
-
亮/暗主题切换:
App.xaml中定义两套ResourceDictionary- 通过
Application.Current.Resources.MergedDictionaries动态切换 - 默认跟随系统主题
-
中英文界面切换:
- 使用
.resx资源文件或自定义LocalizationService - 所有 UI 文本通过 Binding 绑定到资源
- 切换时刷新所有界面文本
- 使用
-
多语言镜像复制:已在 AI 建文章流程中支持
📌 设计决策:
- 所有 UI 文案通过
ApplyLanguage()方法统一管理- 新增菜单项/按钮时,需同步在该方法中添加对应文案
阶段九:构建打包与分发 ✅
已完成内容:
-
构建脚本(
build.bat):dotnet publish -c Release -r win-x64 --self-contained true ^ -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true ^ -p:IncludeNativeLibrariesForSelfExtract=true -o ./publish -
分发内容:
Huge/ ├── Huge.exe # 单文件主程序 ├── lib/ # 离线依赖 DLL │ ├── Renci.SshNet.dll │ └── BouncyCastle.Cryptography.dll ├── tools/ │ └── hugo.exe # Hugo extended v0.157.0 └── README.txt
⚠️ 注意事项:
- 单文件 exe 首次启动解压到临时目录,可能被杀毒软件误报
- 后续可考虑代码签名或添加白名单说明
- 可选使用 Inno Setup 制作安装程序
三、开发顺序(实际执行 vs 计划)
| 优先级 | 模块 | 实际执行顺序 | 备注 |
|---|---|---|---|
| P0 | 项目骨架 + 配置服务 | ✅ 第1批 | 基础必须先做 |
| P0 | 文件树导航 + 复制粘贴 | ✅ 第1批 | 核心交互入口 |
| P0 | Markdown 编辑器 + 预览 | ✅ 第1批 | 核心功能 |
| P0 | 编辑/预览切换 + HTML 标签 | ✅ 第2批 | 体验补强 |
| P1 | Hugo 服务器控制 | ✅ 第2批 | 本地预览依赖 |
| P1 | SFTP 上传发布 | ✅ 第3批 | 核心价值——“最后一公里” |
| P1 | 增量上传(内容指纹) | ✅ 第3批 | 发布体验优化 |
| P2 | AI 助手(DeepSeek) | ✅ 第2批 | 增值功能 |
| P2 | 双主题 + 国际化 | ✅ 贯穿全程 | 逐步同步 |
实际执行偏差:AI 助手提前至第2批完成,SFTP 发布延后至第3批(因涉及离线依赖处理耗时较多)。
四、贯穿全程的注意事项(来自实战教训)
4.1 编译错误定位
| 错误码 | 常见原因 | 排查技巧 |
|---|---|---|
CS1002: ; expected |
把别家语言/库的 API 当成 C# 方法;或用保留关键字(如 base)当变量名 |
往前看本行/上一行的关键字与配对 |
CS0103: ... does not exist |
控件改名后漏改某处引用(如 ApplyLanguage()) |
IDE 重命名功能 或 全文 grep(注意 UTF-8 兼容) |
MC3015: ... not defined on 'ControlTemplate' |
XAML 缩进整理时残留了重复的 <Button.Template> 块 |
报错行号指向“发现点”而非“根源”,往前找配对块 |
NU1301: Unable to load the service index |
网络无法访问 api.nuget.org |
改用离线本地引用方案 |
4.2 运行时异常定位
| 异常 | 根因 | 解决方案 |
|---|---|---|
The type initializer for '...CryptoAbstraction' threw an exception |
SSH.NET 缺 BouncyCastle 依赖 DLL | 手工补链,从 nupkg 解包取 DLL |
fatal: not a git repository |
站点目录不是 Git 仓库 | 改用 SFTP 直传方案(已改造) |
| 每次发布都全量重传 | Hugo 重写所有文件 + 上传器只信 mtime | 改用内容指纹(SHA256 + 大小) |
4.3 工具使用注意事项
findstr对含中文的 UTF-8 文件会漏匹配 → 使用支持 UTF-8 的全文搜索工具- NuGet 包离线补链 → 解包后查看
*.nuspec的<dependencies>一次性列出需补的库 - 程序集版本验证 → 使用
AssemblyName.GetAssemblyName("xxx.dll")确认名称和版本
(以上是我的想法以及在开发中遇到的问题。)
写在最后
这个项目的诞生,源于一个很朴素的愿望:让写 Hugo 博客这件事,回归到“写”本身。
说白了,我就是想做一个能让我“写完就发”的工具,不用想别的。 通过 Hugo Markdown Client “省掉中间步骤”的捷径。 对于“一个人写博客”这种简单场景,它是最直接的解决方案。全程无需打开命令行,无需额外 FTP 工具,无需配置 Git 仓库。