首页 > 基础资料 博客日记

在 Web 界面直接编辑 DESIGN.md:从思路到实现

2026-04-09 09:30:01基础资料围观1

文章在 Web 界面直接编辑 DESIGN.md:从思路到实现分享给大家,欢迎收藏极客资料网,专注分享技术知识

在 Web 界面直接编辑 DESIGN.md:从思路到实现

在 MonoSpecs 项目管理系统中,DESIGN.md 承载着项目的架构设计和技术决策。但传统的编辑方式要求用户必须切换到外部编辑器,这种割裂的流程,怎么说呢,就像在读一首诗的时候突然被打断了——灵感没了,心情也没了。本文分享了我们在 HagiCode 项目中实践的解决方案:在 Web 界面直接编辑 DESIGN.md,并支持从线上设计站点导入模板。毕竟,谁不喜欢一气呵成的感觉呢?

背景

DESIGN.md 作为项目设计文档的核心载体,承载着架构设计、技术决策和实现指导等关键信息。然而,传统的编辑方式要求用户必须切换到外部编辑器(如 VS Code),手动定位物理路径后再进行编辑。这过程说起来也不算复杂,只是反复几次之后,人也就乏了。

具体问题体现在以下几个方面:

  • 流程割裂:用户需要在 Web 管理界面和本地编辑器之间频繁切换,破坏了工作流连贯性——就像听歌的时候突然断网了,节奏全乱了。
  • 复用困难:设计站点已经发布了丰富的设计模板库,但无法直接集成到项目编辑流程中。明明有好东西,就是用不上,这感觉确实有点遗憾。
  • 体验缺失:缺少"预览-选择-导入"的闭环,用户必须手动复制粘贴,增加了出错风险。手动操作的次数多了,出错的机会自然也多了。
  • 协作障碍:设计文档与代码实现的同步维护变得高摩擦,阻碍团队协作效率。团队协作本就不易,何必再添这些阻力呢?

为了解决这些痛点,我们决定在 Web 界面中实现 DESIGN.md 的直接编辑能力,并支持从线上设计站点一键导入模板。这也不算是什么惊天动地的决策,只是想让开发体验更顺畅一点罢了。

关于 HagiCode

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手项目,在开发过程中,我们需要频繁维护项目的设计文档。为了让团队能够更高效地协作,我们探索并实现了这套在线编辑和导入方案。其实也没什么特别的,只是遇到了问题,想办法解决而已。

技术方案

整体架构

该解决方案采用前后端分离的同源代理架构,主要由以下几个层次构成。这种架构的设计,说起来也不过是"各司其职"四个字罢了:

1. 前端编辑器层

// 核心组件:DesignMdManagementDrawer
// 位置:repos/web/src/components/project/DesignMdManagementDrawer.tsx
// 功能:承载编辑、保存、版本冲突检测、导入流程

2. 后端服务层

// 核心服务:ProjectAppService.DesignMd
// 位置:repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMd.cs
// 功能:路径解析、文件读写、版本管理

3. 同源代理层

// 代理服务:ProjectAppService.DesignMdSiteIndex
// 位置:repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMdSiteIndex.cs
// 功能:代理设计站点资源、预览图缓存、安全校验

关键技术决策

决策 1:全局抽屉模式

采用单一全局抽屉而非局部弹层,通过 layoutSlice 管理状态,实现了跨视图(classic/kanban)的一致体验。这种方式确保用户无论在哪个视图中打开编辑器,都能获得统一的交互体验。毕竟,一致的体验能让用户感觉更自在,不会因为换个视图就迷失方向。

决策 2:项目作用域 API

将 DESIGN.md 相关接口挂在 ProjectController 下,复用现有项目权限边界,避免了新增独立控制器的复杂度。这样设计的好处是权限管理更清晰,也符合 RESTful 的资源组织原则。有时候,复用比重新创建更有意义,不是吗?

决策 3:版本冲突检测

基于文件系统 LastWriteTimeUtc 派生 opaque version,实现了轻量级的乐观并发控制。当多个用户同时编辑同一文件时,系统能够检测到冲突并提示用户刷新。这种设计既不阻塞用户的编辑操作,又能保证数据的一致性——就像人际交往中的边界感,既不过分疏离,也不越界。

决策 4:同源代理模式

通过 IHttpClientFactory 代理外部设计站点资源,避免了跨域问题和 SSRF 风险。这种设计既保证了安全性,又简化了前端调用。安全这件事,做再多也不为过,毕竟数据安全就像健康,失去了才后悔就晚了。

核心实现

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. 从线上导入设计文件

目录结构

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     # 内容管线

索引数据格式

设计站点的索引文件定义了所有可用的模板。有了这个索引,用户就能像在餐厅点菜一样,轻松选择自己想要的模板:

{
  "entries": [
    {
      "slug": "linear.app",
      "title": "Linear Inspired Design System",
      "summary": "AI 产品 / 深色感",
      "detailUrl": "/designs/linear.app/",
      "designDownloadUrl": "/designs/linear.app/DESIGN.md",
      "previewLightImageUrl": "...",
      "previewDarkImageUrl": "..."
    }
  ]
}

每个条目包含了模板的基本信息和下载链接。后端会从这个索引中读取可用的模板列表,然后展示给用户选择。这种设计让选择变得直观,而不是在黑暗中摸索。

同源代理实现

为了保证安全性,后端对设计站点的访问做了严格的校验。安全这件事,再怎么小心也不为过:

// 安全的 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. 完整的导入流程

// 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 创建

当 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:从设计站点导入模板

用户在导入抽屉中选择 "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:版本冲突处理

当多个用户同时编辑同一 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>

这种乐观并发控制机制确保了数据的一致性,同时又不会阻塞用户的编辑操作。冲突不可避免,但至少可以让用户知道发生了什么,而不是默默丢失修改。

注意事项与最佳实践

1. 路径安全

始终校验 repositoryPath,防止路径遍历攻击。安全这种事,做再多也不为过:

// 始终校验 repositoryPath,防止路径遍历攻击
return ValidateSubPathAsync(projectPath, repositoryPath);
// 拒绝 "../", 绝对路径等危险输入

2. 缓存策略

预览图缓存 24 小时,最大 160 个文件。适度的缓存能提升性能,但也不能过度,毕竟平衡才是关键:

// 预览图缓存 24 小时,最大 160 个文件
private static readonly TimeSpan PreviewCacheTtl = TimeSpan.FromHours(24);
private const int PreviewCacheMaxFiles = 160;
// 定期清理过期缓存

3. 错误处理

上游站点不可用时降级处理。这种优雅降级的设计确保了即使外部依赖不可用,核心编辑功能仍然正常工作。毕竟,不能因为一个外部服务挂了,整个系统就瘫痪了:

// 上游站点不可用时降级处理
try {
    const catalog = await getProjectDesignMdSiteImportCatalog(projectId);
} catch (error) {
    toast.error(t('project.designMd.siteImport.feedback.catalogLoadFailed'));
    // 主编辑抽屉仍然可用
}

这种优雅降级的设计确保了即使外部依赖不可用,核心编辑功能仍然正常工作。系统应该有韧性,而不是一遇到问题就倒下。

4. 用户体验优化

导入前确认覆盖,导入后不自动保存。用户应该对自己的操作有控制权,而不是系统自作主张:

// 导入前确认覆盖
if (isDirty) {
    setConfirmMode('import');
    return;
}

// 导入后不自动保存,由用户确认
setDraft(result.content); // 只更新草稿
// 用户检查后点击 Save 才真正写入磁盘

5. 性能考虑

使用 HTTP 客户端工厂,避免创建过多连接。资源管理这种事,看似不起眼,但做好了能带来意想不到的效果:

// 使用 HTTP 客户端工厂,避免创建过多连接
private const string DesignSiteProxyClientName = "ProjectDesignSiteProxy";
private static readonly TimeSpan DesignSiteProxyTimeout = TimeSpan.FromSeconds(8);

扩展建议

  1. Markdown 增强:当前使用基础 Textarea,可考虑升级为 CodeMirror 以支持语法高亮和快捷键。编辑器的体验好了,写文档的心情也会好一些。
  2. 预览模式:添加 Markdown 实时预览,提升编辑体验。所见即所得,总能给人更多信心。
  3. 差异合并:实现智能合并算法,而非简单的全文替换。冲突是难免的,但至少可以让处理冲突的过程不那么痛苦。
  4. 本地缓存:将 design.json 缓存到数据库,减少对外部站点的依赖。依赖越少,系统越稳定,这是简单的道理。

总结

在 HagiCode 项目中,我们通过前后端协作实现了一套完整的 DESIGN.md 在线编辑和导入方案。这套方案的核心价值在于:

  • 提升效率:无需切换工具,在统一的 Web 界面完成设计文档的编辑和导入。省下的时间,可以做更有意义的事情。
  • 降低门槛:设计模板一键导入,新项目可以快速起步。开始得越容易,坚持下去的可能性就越大。
  • 安全可靠:路径校验、版本冲突检测、优雅降级等机制确保系统稳定运行。稳定是基础,没有稳定,一切都是空谈。
  • 用户体验:全局抽屉、脏状态检测、确认对话框等细节打磨了交互体验。细节决定成败,这句话在用户体验上尤其适用。

这套方案已经在 HagiCode 项目中实际运行,解决了团队在设计文档管理方面的痛点。如果你也在面临类似的问题,希望这篇文章能给你一些启发。其实也没什么高深的理论,只是遇到了问题,想办法解决而已。

参考资料

如果本文对你有帮助,欢迎来 GitHub 给个 Star,公测已开始,现在安装即可参与体验。毕竟,开源项目最缺的就是反馈和鼓励,如果你觉得有用,不妨让它被更多人看到......


"美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。"

DESIGN.md 编辑器也是一样,不一定要多么复杂,只要能帮你高效地完成工作,那就是好的罢了。

原文与版权说明

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。
本内容采用人工智能辅助协作,最终内容由作者审核并确认。


文章来源:https://www.cnblogs.com/newbe36524/p/19838745
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云