在 Web 界面直接编辑 DESIGN.md:从思路到实现
This content is not available in your language yet.
在 Web 界面直接编辑 DESIGN.md:从思路到实现
Section titled “在 Web 界面直接编辑 DESIGN.md:从思路到实现”在 MonoSpecs 项目管理系统中,DESIGN.md 承载着项目的架构设计和技术决策。但传统的编辑方式要求用户必须切换到外部编辑器,这种割裂的流程,怎么说呢,就像在读一首诗的时候突然被打断了——灵感没了,心情也没了。本文分享了我们在 HagiCode 项目中实践的解决方案:在 Web 界面直接编辑 DESIGN.md,并支持从线上设计站点导入模板。毕竟,谁不喜欢一气呵成的感觉呢?
DESIGN.md 作为项目设计文档的核心载体,承载着架构设计、技术决策和实现指导等关键信息。然而,传统的编辑方式要求用户必须切换到外部编辑器(如 VS Code),手动定位物理路径后再进行编辑。这过程说起来也不算复杂,只是反复几次之后,人也就乏了。
具体问题体现在以下几个方面:
- 流程割裂:用户需要在 Web 管理界面和本地编辑器之间频繁切换,破坏了工作流连贯性——就像听歌的时候突然断网了,节奏全乱了。
- 复用困难:设计站点已经发布了丰富的设计模板库,但无法直接集成到项目编辑流程中。明明有好东西,就是用不上,这感觉确实有点遗憾。
- 体验缺失:缺少”预览-选择-导入”的闭环,用户必须手动复制粘贴,增加了出错风险。手动操作的次数多了,出错的机会自然也多了。
- 协作障碍:设计文档与代码实现的同步维护变得高摩擦,阻碍团队协作效率。团队协作本就不易,何必再添这些阻力呢?
为了解决这些痛点,我们决定在 Web 界面中实现 DESIGN.md 的直接编辑能力,并支持从线上设计站点一键导入模板。这也不算是什么惊天动地的决策,只是想让开发体验更顺畅一点罢了。
关于 HagiCode
Section titled “关于 HagiCode”本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手项目,在开发过程中,我们需要频繁维护项目的设计文档。为了让团队能够更高效地协作,我们探索并实现了这套在线编辑和导入方案。其实也没什么特别的,只是遇到了问题,想办法解决而已。
该解决方案采用前后端分离的同源代理架构,主要由以下几个层次构成。这种架构的设计,说起来也不过是”各司其职”四个字罢了:
1. 前端编辑器层
// 核心组件:DesignMdManagementDrawer// 功能:承载编辑、保存、版本冲突检测、导入流程2. 后端服务层
// 位置:repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMd.cs// 功能:路径解析、文件读写、版本管理3. 同源代理层
// 位置:repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMdSiteIndex.cs// 功能:代理设计站点资源、预览图缓存、安全校验关键技术决策
Section titled “关键技术决策”决策 1:全局抽屉模式
Section titled “决策 1:全局抽屉模式”采用单一全局抽屉而非局部弹层,通过 layoutSlice 管理状态,实现了跨视图(classic/kanban)的一致体验。这种方式确保用户无论在哪个视图中打开编辑器,都能获得统一的交互体验。毕竟,一致的体验能让用户感觉更自在,不会因为换个视图就迷失方向。
决策 2:项目作用域 API
Section titled “决策 2:项目作用域 API”将 DESIGN.md 相关接口挂在 ProjectController 下,复用现有项目权限边界,避免了新增独立控制器的复杂度。这样设计的好处是权限管理更清晰,也符合 RESTful 的资源组织原则。有时候,复用比重新创建更有意义,不是吗?
决策 3:版本冲突检测
Section titled “决策 3:版本冲突检测”基于文件系统 LastWriteTimeUtc 派生 opaque version,实现了轻量级的乐观并发控制。当多个用户同时编辑同一文件时,系统能够检测到冲突并提示用户刷新。这种设计既不阻塞用户的编辑操作,又能保证数据的一致性——就像人际交往中的边界感,既不过分疏离,也不越界。
决策 4:同源代理模式
Section titled “决策 4:同源代理模式”通过 IHttpClientFactory 代理外部设计站点资源,避免了跨域问题和 SSRF 风险。这种设计既保证了安全性,又简化了前端调用。安全这件事,做再多也不为过,毕竟数据安全就像健康,失去了才后悔就晚了。
1. 直接编辑 DESIGN.md
Section titled “1. 直接编辑 DESIGN.md”后端主要负责路径解析、文件读写和版本管理。这些工作虽然基础,但必不可少,就像房子的地基一样:
// 路径解析与安全校验private Task<string> ResolveDesignDocumentDirectoryAsync(string projectPath, string? repositoryPath){ if (string.IsNullOrWhiteSpace(repositoryPath)) { return Task.FromResult(Path.GetFullPath(projectPath)); } return ValidateSubPathAsync(projectPath, repositoryPath);}
// 版本号生成(基于文件系统时间戳和大小)private static string BuildDesignDocumentVersion(string path){ var fileInfo = new FileInfo(path); fileInfo.Refresh(); return string.Create( CultureInfo.InvariantCulture, $"{fileInfo.LastWriteTimeUtc.Ticks:x}-{fileInfo.Length:x}");}版本号的设计其实也挺有意思的,我们用文件的最后修改时间和大小来生成一个唯一的版本标识。这样既轻量又可靠,不需要维护额外的版本数据库。简单的东西,往往更有效,不是吗?
前端实现了脏状态检测和保存逻辑。这种设计让用户随时知道自己的修改是否已保存,减少”万一丢失了怎么办”的焦虑:
// 脏状态检测与保存逻辑const [draft, setDraft] = useState('');const [savedDraft, setSavedDraft] = useState('');const isDirty = draft !== savedDraft;
const handleSave = useCallback(async () => { const result = await saveProjectDesignMdDocument({ ...activeTarget, content: draft, expectedVersion: document.version, // 乐观并发控制 }); setSavedDraft(draft); // 更新已保存状态}, [activeTarget, document, draft]);这个实现中,我们维护了两个状态:draft 是当前编辑的内容,savedDraft 是已保存的内容。通过比较两者来判断是否有未保存的修改。这种设计虽然简单,但能给人安心感,毕竟谁愿意辛辛苦苦写的东西突然消失呢?
2. 从线上导入设计文件
Section titled “2. 从线上导入设计文件”repos/index/└── src/data/public/design.json # 设计模板索引
repos/awesome-design-md-site/├── vendor/awesome-design-md/ # 上游设计模板│ └── design-md/│ ├── clickhouse/│ │ └── DESIGN.md│ ├── linear/│ │ └── DESIGN.md│ └── ...└── src/lib/content/ └── awesomeDesignCatalog.ts # 内容管线索引数据格式
Section titled “索引数据格式”设计站点的索引文件定义了所有可用的模板。有了这个索引,用户就能像在餐厅点菜一样,轻松选择自己想要的模板:
{ "entries": [ { "slug": "linear.app", "title": "Linear Inspired Design System", "summary": "AI 产品 / 深色感", "detailUrl": "/designs/linear.app/", "designDownloadUrl": "/designs/linear.app/DESIGN.md", "previewLightImageUrl": "...", "previewDarkImageUrl": "..." } ]}每个条目包含了模板的基本信息和下载链接。后端会从这个索引中读取可用的模板列表,然后展示给用户选择。这种设计让选择变得直观,而不是在黑暗中摸索。
同源代理实现
Section titled “同源代理实现”为了保证安全性,后端对设计站点的访问做了严格的校验。安全这件事,再怎么小心也不为过:
// 安全的 slug 校验private static readonly Regex SafeDesignSiteSlugRegex = new("^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,127})$", RegexOptions.Compiled);
private static string NormalizeDesignSiteSlug(string slug){ var normalizedSlug = slug?.Trim() ?? string.Empty; if (!IsSafeDesignSiteSlug(normalizedSlug)) { throw new BusinessException( ProjectDesignSiteIndexErrorCodes.InvalidSlug, "Design site slug must be a single safe path segment."); } return normalizedSlug;}
// 预览图缓存(OS 临时目录)private static string ComputePreviewCacheKey(string slug, string theme, string previewUrl){ var raw = $"{slug}|{theme}|{previewUrl}"; var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); return Convert.ToHexString(bytes).ToLowerInvariant();}这里我们做了两件事:一是用正则表达式严格校验 slug 的格式,防止路径遍历攻击;二是对预览图进行缓存,减少对外部站点的请求压力。前者是防护,后者是优化,缺一不可罢了。
3. 完整的导入流程
Section titled “3. 完整的导入流程”// 1. 打开导入抽屉const handleRequestImportDrawer = useCallback(() => { setIsImportDrawerOpen(true);}, []);
// 2. 选择并导入const handleImportRequest = useCallback((entry) => { if (isDirty) { setPendingImportEntry(entry); setConfirmMode('import'); // 覆盖确认 return; } void executeImport(entry);}, [isDirty]);
// 3. 执行导入const executeImport = useCallback(async (entry) => { const result = await getProjectDesignMdSiteImportDocument( activeTarget.projectId, entry.slug ); setDraft(result.content); // 只替换编辑器文本,不自动保存 setIsImportDrawerOpen(false);}, [activeTarget?.projectId]);导入流程的设计遵循了”用户确认”的原则:导入后只更新编辑器内容,不会自动保存。用户可以检查导入的内容,确认无误后再手动保存。毕竟,用户对自己写的东西应该有最终决定权,不是吗?
场景 1:项目根目录 DESIGN.md 创建
Section titled “场景 1:项目根目录 DESIGN.md 创建”当 DESIGN.md 不存在时,后端返回虚拟文档状态。这种设计让前端不需要特殊处理”文件不存在”的情况,统一的 API 接口简化了代码逻辑:
return new ProjectDesignDocumentDto{ Path = targetPath, Exists = false, // 虚拟文档状态 Content = string.Empty, Version = null};
// 首次保存时自动创建文件public async Task<SaveProjectDesignDocumentResultDto> SaveDesignDocumentAsync(...){ Directory.CreateDirectory(targetDirectory); await File.WriteAllTextAsync(targetPath, input.Content); return new SaveProjectDesignDocumentResultDto { Created = !exists };}这种设计的好处是前端不需要特殊处理”文件不存在”的情况,统一的 API 接口简化了代码逻辑。有时候,把复杂性隐藏在后端,前端就能更轻松地专注于用户体验。
场景 2:从设计站点导入模板
Section titled “场景 2:从设计站点导入模板”用户在导入抽屉中选择 “Linear” 设计模板后,系统会通过后端代理获取 DESIGN.md 内容。整个流程对用户来说是透明的,他们只需要选择模板,系统会自动处理所有的网络请求和数据转换:
// 1. 系统通过后端代理获取 DESIGN.md 内容GET /api/project/{id}/design-md/site-index/linear.app
// 2. 后端验证 slug 并从上游获取内容var entry = FindDesignSiteEntry(catalog, "linear.app");using var upstreamResponse = await httpClient.SendAsync(request);var content = await upstreamResponse.Content.ReadAsStringAsync();
// 3. 前端替换编辑器文本setDraft(result.content);// 用户检查后手动保存到磁盘整个流程对用户来说是透明的,他们只需要选择模板,系统会自动处理所有的网络请求和数据转换。用户不需要关心背后的复杂性,这就是我们追求的体验——简单,但强大。
场景 3:版本冲突处理
Section titled “场景 3:版本冲突处理”当多个用户同时编辑同一 DESIGN.md 时,系统会检测到版本冲突。这种乐观并发控制机制确保了数据的一致性,同时又不会阻塞用户的编辑操作:
if (!string.Equals(currentVersion, expectedVersion, StringComparison.Ordinal)){ throw new BusinessException( ProjectDesignDocumentErrorCodes.VersionConflict, $"DESIGN.md at '{targetPath}' changed on disk.");}前端会捕获这个错误并提示用户:
// 前端提示用户刷新并重试<Alert> <AlertTitle>版本冲突</AlertTitle> <AlertDescription> 文件已被其他进程修改。请刷新最新版本后重试。 </AlertDescription></Alert>这种乐观并发控制机制确保了数据的一致性,同时又不会阻塞用户的编辑操作。冲突不可避免,但至少可以让用户知道发生了什么,而不是默默丢失修改。
注意事项与最佳实践
Section titled “注意事项与最佳实践”1. 路径安全
Section titled “1. 路径安全”始终校验 repositoryPath,防止路径遍历攻击。安全这种事,做再多也不为过:
// 始终校验 repositoryPath,防止路径遍历攻击return ValidateSubPathAsync(projectPath, repositoryPath);// 拒绝 "../", 绝对路径等危险输入2. 缓存策略
Section titled “2. 缓存策略”预览图缓存 24 小时,最大 160 个文件。适度的缓存能提升性能,但也不能过度,毕竟平衡才是关键:
// 预览图缓存 24 小时,最大 160 个文件private static readonly TimeSpan PreviewCacheTtl = TimeSpan.FromHours(24);private const int PreviewCacheMaxFiles = 160;// 定期清理过期缓存3. 错误处理
Section titled “3. 错误处理”上游站点不可用时降级处理。这种优雅降级的设计确保了即使外部依赖不可用,核心编辑功能仍然正常工作。毕竟,不能因为一个外部服务挂了,整个系统就瘫痪了:
// 上游站点不可用时降级处理try { const catalog = await getProjectDesignMdSiteImportCatalog(projectId);} catch (error) { toast.error(t('project.designMd.siteImport.feedback.catalogLoadFailed')); // 主编辑抽屉仍然可用}这种优雅降级的设计确保了即使外部依赖不可用,核心编辑功能仍然正常工作。系统应该有韧性,而不是一遇到问题就倒下。
4. 用户体验优化
Section titled “4. 用户体验优化”导入前确认覆盖,导入后不自动保存。用户应该对自己的操作有控制权,而不是系统自作主张:
// 导入前确认覆盖if (isDirty) { setConfirmMode('import'); return;}
// 导入后不自动保存,由用户确认setDraft(result.content); // 只更新草稿// 用户检查后点击 Save 才真正写入磁盘5. 性能考虑
Section titled “5. 性能考虑”使用 HTTP 客户端工厂,避免创建过多连接。资源管理这种事,看似不起眼,但做好了能带来意想不到的效果:
// 使用 HTTP 客户端工厂,避免创建过多连接private const string DesignSiteProxyClientName = "ProjectDesignSiteProxy";private static readonly TimeSpan DesignSiteProxyTimeout = TimeSpan.FromSeconds(8);- Markdown 增强:当前使用基础
Textarea,可考虑升级为 CodeMirror 以支持语法高亮和快捷键。编辑器的体验好了,写文档的心情也会好一些。 - 预览模式:添加 Markdown 实时预览,提升编辑体验。所见即所得,总能给人更多信心。
- 差异合并:实现智能合并算法,而非简单的全文替换。冲突是难免的,但至少可以让处理冲突的过程不那么痛苦。
- 本地缓存:将 design.json 缓存到数据库,减少对外部站点的依赖。依赖越少,系统越稳定,这是简单的道理。
在 HagiCode 项目中,我们通过前后端协作实现了一套完整的 DESIGN.md 在线编辑和导入方案。这套方案的核心价值在于:
- 提升效率:无需切换工具,在统一的 Web 界面完成设计文档的编辑和导入。省下的时间,可以做更有意义的事情。
- 降低门槛:设计模板一键导入,新项目可以快速起步。开始得越容易,坚持下去的可能性就越大。
- 安全可靠:路径校验、版本冲突检测、优雅降级等机制确保系统稳定运行。稳定是基础,没有稳定,一切都是空谈。
- 用户体验:全局抽屉、脏状态检测、确认对话框等细节打磨了交互体验。细节决定成败,这句话在用户体验上尤其适用。
这套方案已经在 HagiCode 项目中实际运行,解决了团队在设计文档管理方面的痛点。如果你也在面临类似的问题,希望这篇文章能给你一些启发。其实也没什么高深的理论,只是遇到了问题,想办法解决而已。
- HagiCode 项目地址:github.com/HagiCode-org/site
- HagiCode 官网:hagicode.com
- MonoSpecs 项目管理系统:docs.hagicode.com
- 30 分钟实战演示:www.bilibili.com/video/BV1pirZBuEzq/
- Docker Compose 安装指南:docs.hagicode.com/installation/docker-compose
- Desktop 桌面端安装:hagicode.com/desktop/
如果本文对你有帮助,欢迎来 GitHub 给个 Star,公测已开始,现在安装即可参与体验。毕竟,开源项目最缺的就是反馈和鼓励,如果你觉得有用,不妨让它被更多人看到…
“美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。”
DESIGN.md 编辑器也是一样,不一定要多么复杂,只要能帮你高效地完成工作,那就是好的罢了。
感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。
- 本文作者: newbe36524
- 原文链接: https://docs.hagicode.com/blog/2026-04-09-design-md-web-editor-implementation/
- 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!