VNote 对话框模块开发指南:`*Dialog2` 架构、文案规范与交互模式实战
2026/9/23 2:07:14 网站建设 项目流程

VNote 对话框模块开发指南:*Dialog2架构、文案规范与交互模式实战

【免费下载链接】vnoteA pleasant note-taking platform in native C++.项目地址: https://gitcode.com/gh_mirrors/vn/vnote

本指南以 VNote(原生 C++ 笔记平台)对话框模块的模块文档为核心,系统讲解*Dialog2系列对话框与 Controller 的 MVC 配对架构、依赖注入方式、全项目强制执行的标签大小写与标点规范、Banner 静默抑制交互模式、Git 同步用户名绑定、新建笔记模板解析与图片尺寸编辑等关键实现。读完你将掌握在src/widgets/dialogs/下新增或修改对话框时必须遵守的架构约定、文案规则与可测试性要求,并能直接对照仓库源码理解每一条规则背后的设计动机。

一、模块架构:*Dialog2与 Controller 的 MVC 配对

VNote 的对话框全部位于 src/widgets/dialogs,命名遵循*Dialog2约定。每个对话框都对应 src/controllers 下的一个 Controller,二者构成标准的 MVC 配对:Controller 拥有业务逻辑,对话框是纯粹的 View 层,只负责展示数据、采集用户输入并发出信号,绝不直接修改数据。

对话框通过构造函数注入依赖,这是全项目统一的 Widget 构造模式:

// 接收依赖的构造函数注入(VNote 项目标准模式) class MyDialog2 : public ScrollDialog { Q_OBJECT public: explicit MyDialog2(ServiceLocator &p_services, QWidget *p_parent = nullptr); private: ServiceLocator &m_services; };

以 NewNoteDialog2 为例,它的构造函数接收ServiceLocator &,并在内部创建NewNoteController,随后setupUI()通过m_services.get<FileTypeCoreService>()m_services.get<NotebookCoreService>()等解析所需服务。这一模式让 Controller 与 Dialog 都可以脱离 GUI 单独测试——Controller 不继承任何 QWidget,业务逻辑不依赖界面存在。

从源码结构看,*Dialog2是第二代架构的产物(2后缀源于早期单例架构迁移的历史遗留,新代码不再添加后缀),它们继承自ScrollDialog(scrolldialog.h),后者提供了可滚动的窗体骨架、setInformationText()信息横幅与QDialogButtonBox按钮区等基础能力。例外是ImageInsertDialogImageSizeDialog:它们是遗留风格对话框(无2后缀、不接收ServiceLocator),由MarkdownEditor直接驱动(详见下文"图片尺寸编辑"一节)。

二、标签大小写规范:混合大小写方案

VNote 对话框采用混合大小写(mixed capitalization)方案,这是全项目标准,且清理工作已全部完成。下表是该规范的全部条目:

元素风格示例
表单/字段标签addRow,命名相邻控件的QLabel句子式,无尾冒号"Local root folder""Remote URL""Output directory""Cursor mark"
专有名词或既定技术术语的表单标签保留Title Case,仍无冒号"Personal Access Token"
QInputDialog提示语label参数)句子式,保留尾冒号"Workspace name:""Enter new parent tag (empty for root):"
首字母缩写(URL、PAT、JSON、HTTP)在任何标签内保留规范大小写"Remote URL""JSON path"
占位符QLineEdit内的灰色提示文字)句子式,无句尾句号"Folder to clone into (must not exist or be empty)""Optional (empty to open as read-only)"
按钮QPushButtonQDialogButtonBox按钮)Title Case"Open""Browse""Disable Sync""Close Notebook"
窗口/对话框标题setWindowTitleQFileDialog标题)Title Case"Open Notebook""Select Local Root Folder""Manage Notebooks"
工具提示(Tooltip)句子式,以句号结尾"Remote git URL. Only HTTPS and file:// schemes are supported."
Banner/信息文本消息setInformationText句子式,以句号结尾"Local root folder must be empty (contains 3 item(s)).""Cloning..."
单选按钮/复选框标签句子式"Local folder""Keep both""Expand Tab"
ComboBox 条目标签句子式(仅首词大写)"Bundled notebook""No wrap""Web service""Local JAR"

为什么是这些规则

  • 表单标签用句子式:与 macOS 及现代 GNOME/KDE 的系统原生惯例一致,在密集型表单中阅读更快,同时在视觉上与按钮区分开来。
  • 字段标签不加尾冒号:与设置页保持一致。设置页是应用内最大的标签字段面,所有行都通过SettingsPageHelper::createSettingRow构建为无冒号标签("Auto save policy""Line ending""Content layout")。当标签已位于表单布局列、紧邻其控件时,冒号是冗余的。
  • QInputDialog提示保留冒号:因为它们是引入输入框的提示句,而非列标签;Qt 自带的对话框也是这么写的。
  • 按钮与标题用 Title Case:与 Windows 及 Qt 内置控件一致——QDialogButtonBox自带的"Open""Cancel""Save"等本就是 Title Case。
  • Tooltip 加句号:使 tooltip 字符串可直接复用为qInfo()日志行以及 accessible-name / accessible-description 的来源。
  • 保留首字母缩写大小写:避免把"Personal Access Token"拆成 OWASP 风格的"Personal access token",以免在 GitHub/GitLab 用户搜索 "PAT" 时造成困惑。

存疑时的决策路径

  1. 参考opennotebookdialog2.cppexportdialog2.cpp——它们是句子式、无冒号标签的参照对话框
  2. 如果你的新对话框以专有名词或领域专属多词术语为主(如同步状态、凭据),遵循最相近同级对话框的约定,而非机械套用句子式。
  3. 绝不要在改动标签字符串的同时,不去全局 grep 旧字符串以及翻译.ts文件——标签对用户可见,且可能被测试通过findChild<>(...)引用。测试查找应使用object name,而非标签文本。

测试发现规则:objectName 优先

测试通过findChild<>("objectName")查找对话框控件,而非标签文本。因此每个*Dialog2中的可交互控件必须设置objectName,命名模式为:

const char *kContentEditName = "newNoteContentEdit"; m_contentEdit->setObjectName(QLatin1String(kContentEditName));

如上所示,kFooName是对话框.cpp文件顶部的常量(参见 newnotedialog2.cpp 中的kContentEditNamekFileTypeComboNamekNameEditNamekEncryptCheckBoxName)。修改标签 TEXT 是 UX 变更;修改 objectName 是测试变更,两者必须解耦。

三、Banner 抑制模式:静默对话框 UX

部分对话框(目前主要是refine-open-notebook-dialog之后的opennotebookdialog2)会刻意抑制ScrollDialog::setInformationText横幅在特定字段变化时的更新,使用户打字过程中对话框保持安静、尺寸稳定。

模式的四个步骤:

  1. 验证结果结构体(对话框内部的RemoteValidation)在validmessage之外携带一个额外的bool surfaceInBanner = false;标志。
  2. 每个验证分支自行决定消息是否"足够可操作、值得上横幅"。URL 方案错误是静默的(用户还在打字,不想要横幅闪烁);文件夹内容错误(已存在的非空目录)则立即上横幅(因为用户已停止打字并点击了文件夹)。
  3. updateOpenButtonState读取surfaceInBanner:为真时调用setInformationText(message, Error)显示;为假时调用setInformationText(QString(), Info)清除。
  4. 克隆的开始/进度/失败/取消事件无论surfaceInBanner如何都始终显示——它们不是"打字过程中"的事件。

源码实现可在 opennotebookdialog2.cpp 验证:远程模式下,URL/PAT 错误只禁用 Open 按钮、横幅保持安静;而本地根文件夹的路径非法、非目录、非空(含隐藏/系统条目)、父目录不存在或不可写等分支都会把surfaceInBanner置为true(见 validateRemoteInputs)。

何时使用该模式:对话框同时拥有"击键驱动型验证"(噪音大)与"离散操作型验证"(如选择文件夹)时使用。何时不要用:每条验证消息都具有同等可操作性时,不要使用——常规setInformationText流程更简单。

四、Markdown 列表设置:自动重编号

设置 → Markdown Editor → Edit暴露Automatically renumber ordered lists(自动重编号有序列表)选项。

  • MarkdownEditorConfig将其持久化为editor.markdown_editor.autoNumberOrderedLists(见 markdowneditorconfig.cpp),C++ 默认值为true(markdowneditorconfig.h)。
  • 通过ConfigMgr2的默认值合并机制,旧配置文件会继承该默认值,同时保留用户显式写入的false
  • 两个编辑器构建器(编辑态与只读态)都通过共享的applyMarkdownConfigFields()映射消费该配置(参见 markdowneditorcontroller.cpp 与设置页 markdowneditorpage.cpp)。
  • 加载笔记或切换该选项不会对未修改的源码进行重编号;只有在后续的结构性列表编辑(增删条目)时才触发重编号。

列表装饰与编号相互独立。每个内置主题的src/data/extra/themes/*/text-editor.theme都在markdown-editor-styles中提供ListItemGuide.text-colorActiveListItem.background-color,要求每个调色板中引导线可见、激活填充比光标行填充更柔和。颜色属于主题资产,而非 C++ 样式表字面量;自定义主题若缺少某个样式,该装饰即被禁用——不要注入浅色主题默认值。

五、Git 同步用户名:从 URL 派生、非破坏性修复

Configure Sync / Sync InfoOpen Notebook(Remote URL)均暴露gitUsernameEdit字段。Gitee 要求 PAT 所有者的账户登录名,它可能与仓库所有者不同——不要从 URL 路径推断它

WidgetsFactory::createUrlUserNameEdit将该字段绑定到 HTTPS URL 的非机密用户名组件,使用QUrl进行编码(widgetsfactory.cpp)。要点:

  • URL 是唯一事实来源:加载/重置/取消流程都会恢复用户名,现有 Controller 无需第二个配置键即可持久化它。
  • Token 仍然独立:密码掩码、绝不预填、只通过凭据存储(credentials store)保存。
  • 用户名输入框对本地文件远程仓库及远程打开过程中禁用
  • 仅修改 HTTPS 用户名即可就地重新启用认证并保留本地 Git 历史——它不会走破坏性的仓库变更流程(不重新 clone)。
  • OK 与 Apply 会等待applyComplete才清除编辑框;OK 仅在成功时关闭对话框,因此已保存 token 的检索不会被对话框销毁取消。

createUrlUserNameEdit的双向绑定逻辑:URL 文本变化时用QUrl(url).userName()回填编辑框(并用QSignalBlocker避免回环);用户编辑用户名时反过来把用户名写回 URL 的QUrl::FullyEncoded形式。opennotebookdialog2.cppnotebooksyncinfodialog2.cpp都是它的消费方。

六、新建笔记模板解析:会话缓存优先,配置默认兜底

NewNoteDialog2按以下顺序挑选初始模板(newnotedialog2.cpp):

  1. 会话缓存NewNoteDialog2::s_lastTemplateByFileType,一个进程生命周期的QHash<fileTypeName, templateName>只在笔记成功创建后写入(被拒绝的名称或创建失败不算"最后使用的模板")。键存在即生效,即使值为空——显式的 "None" 会在本次运行剩余时间内一直生效。
  2. 配置默认值WidgetConfig::getNewNoteDefaultTemplate(fileTypeName)(widgetconfig.cpp),由vnotex.json中的newNoteDefaultTemplates对象({fileTypeName: templateName}映射)支持。当键缺失时 VNote 播种{"Markdown": "title.md"};对象已存在(包括空对象)则视为用户选择,绝不重新播种。

如果两者提供的模板在磁盘上已不存在(NoteTemplateSelector::setCurrentTemplate返回false),选择器回退到 "None",并删除过期的会话条目,让配置默认值获得第二次机会。

模板解析在每次文件类型变化时都会重新运行,包括用户在 Name 字段输入后缀所触发的隐式类型变化——默认值按文件类型区分,必须跟随类型。一旦用户手动挑选了模板(m_templateChosenByUser置位),解析即停止跟随;编程式选择通过m_templateSelectorMuted被排除在该标志之外。捕获对话框(BodyMode::LiteralContent)没有模板选择器,因此既不读也不写会话缓存。快捷笔记路径无关此机制:其模板名按 scheme 持久化在SessionConfig::QuickNoteScheme::m_template中。

七、快捷笔记窗口偏好:分离窗口 + 全局热键

设置 → Quick Access → Quick NoteOpen in detached window按 scheme 持久化为session.json中的quickNoteSchemes[].detachedView。缺失表示false,以兼容旧 scheme。该标志必须纳入 scheme 相等性判断:仅切换该选项的编辑也必须持久化。托盘、工具栏、键盘与标签页栏请求共享同一个选择器并尊重所选 scheme 的目的地;快捷笔记仍以 Edit 模式打开。

新 scheme 使用ConfigMgr2::getDefaultNotebookPath()(与FirstRunController共享):Qt 可写 Documents 位置下的my_notebook,无显式主目录回退。该辅助函数仅计算路径;启动创建仍要求版本变更且零打开笔记本。现有 scheme(包括显式空的 Folder)不迁移。

SystemTrayHelper将配置的NewQuickNote绑定注册为 OS 级QHotkey,由MainWindow2持有,与唤醒热键一致。托盘与工具栏只显示快捷键文本;工具栏不得注册重复的窗口级快捷键。全局激活触发受保护的托盘动作,因此启动就绪与可重入性对两个入口同样适用;活动模态对话框期间的请求被忽略。

若干生命周期细节值得注意:

  • 托盘项在ViewArea2::corePropagationReady之前及同步请求期间保持禁用。
  • 隐藏/最小化主窗口的选择器与错误均为无父窗口;取消与创建失败绝不打开 buffer。
  • 分离 scheme 不打扰主窗口;成功的非分离捕获在主窗口隐藏/最小化时走常规MainWindow2::showMainWindow()路径。
  • 无父选择器在exec()显示后排队raise()activateWindow()——仅靠全局热键不会给对话框前景焦点。回调以选择器为作用域,并跳过已关闭的选择器;绝不激活隐藏的主窗口,也不让选择器永久置顶来绕开焦点问题。
  • MessageBoxHelper对无父消息(包括快捷笔记创建错误)使用相同的延迟激活;有父消息框保留现有行为。

八、对话框清单

下表是src/widgets/dialogs下对话框与 Controller 的完整配对关系:

对话框Controller用途
NewNoteDialog2NewNoteController新建笔记
NewFolderDialog2NewFolderController新建文件夹
NewNotebookDialog2NewNotebookController新建笔记本
OpenNotebookDialog2OpenNotebookController打开已有笔记本(本地或远程克隆);通过OpenV3NotebookRequested结果码转交 V3 导入流程
ManageNotebooksDialog2ManageNotebooksController笔记本管理
ImportFolderDialog2ImportFolderController将外部文件夹导入为笔记本
OpenVNote3NotebookDialog2(遗留迁移)导入 VNote3 笔记本
NotebookSyncInfoDialog2NotebookSyncInfoController查看/编辑笔记本同步配置
ExportDialog2(export controller / 内联)导出笔记
NewQuickAccessItemDialog(内联)添加快捷访问条目(在设置内使用)
SnippetInfoWidget2/ 代码片段对话框SnippetController代码片段元数据
ImageInsertDialog(内联,MarkdownEditor插入图片;也是尺寸编辑界面(见下)
ImageSizeDialog(内联,MarkdownEditor对已有图片执行Image > Set Size…

注意NewNotebookDialog2ManageNotebooksDialog2共享NotebookInfoWidget:表单拥有字段布局与可编辑性;对话框/Controller 保留创建、同步、验证与持久化职责。根目录与类型在创建后不可变;不支持的字段保持可见但不可编辑;只读文本仍可选中。setNotebookInfo()填充或重置字段时不发射inputEdited(),因此加载笔记本永远不会弄脏管理对话框。创建时评估名称代码片段;编辑既有笔记本时保留其字面名称。

九、插入图片编码:保留原始字节

ImageInsertDialog::getImageData()对来自文件和 URL 的图片保留原始字节

  • 对剪贴板QImage,图片文件插入使用 Qt 默认质量编码为 JPEG,alpha 以原始像素尺寸(独立于设备像素比)合成到白色背景上。
  • Base64 插入保留带 alpha 的无损 PNG;选择图片文件绝不能改变该源
  • MarkdownEditor从编码后的字节推导保存/上传的文件名后缀。

这意味着同一张图走不同通道可能得到不同编码——文件路径插入保留文件原样,剪贴板插入经 JPEG 压缩,Base64 插入保留 PNG alpha。

十、图片尺寸编辑:Set Size…与往返验证

两个界面均为遗留风格对话框(无2后缀、无ServiceLocator、由MarkdownEditor直接驱动),遵循既有ImageInsertDialog的形态:

  • ImageInsertDialog增加可选的Width (px)/Height (px)字段。默认留空;源图片自然尺寸仅作为placeholder文本出现。预填会让每次插入都变成 HTML<img>
  • ImageSizeDialogImage > Set Size…动作,从光标下的图片预填。两个字段都留空表示"无尺寸"。

任何非零尺寸都会让生成的引用变成 HTML<img …/>而非 Markdown 链接——Markdown 没有可移植的方式表达尺寸(=WxH仅本编辑器可理解,其他工具大多不支持)。vte::MarkdownUtils::generateImageLink(title, url, alt, w, h)在一个位置做出该选择。

Set Size…转换表

MarkdownEditor::setImageSize()(markdowneditor.cpp)实现全部转换。所有编辑通过单个QTextCursor编辑块完成(一次撤销步骤),按区间降序应用,使较早的区间保持有效:

当前新尺寸结果
Markdown非空generateImageTag(alt, dest, title, w, h)替换该区域
=WxH尺寸的 Markdownalt替换——去掉尺寸,保持 Markdown
无尺寸的 Markdown无操作
HTML非空就地编辑width/height;缺失的一个插入到src之后;移除新值为 0 的维度的每一处出现;设置某个维度时更新第一次出现并移除所有后续重复
HTML,!hasUnknownAttrs() && !hasDuplicateAttrs()往返验证通过alt替换
HTML,其他情况就地移除所有width/height;保留标签

Markdown 图片未必无尺寸:=WxH扩展由本编辑器的 cmark fork 解析并被PreviewMgr遵循,因此ImageSizeDialog对带尺寸的图片会打开时预填,而"留空两者以移除尺寸"的提示必须真正生效。

绝不要重新生成 VNote 未创作的 HTML 标签——用户手写的classstyledata-*loading及任何其他属性必须保留。这就是带尺寸场景采用属性编辑而非重发标签的原因。

HTML → Markdown 的转换由"验证过的往返"把关,而非字符黑名单。构建候选 Markdown 后,用fetchImageLinks()重新解析,并要求恰好一个覆盖整个候选区的图片,且其解码后的 url、alt、title 与源完全一致(见 markdownRoundTrips)。黑名单被证明不够用:裸a\_b.png目标解析回来变成a_b.png,是一个悄然不同的文件。往返只比较有效(先到先得)属性值,因此观察不到被丢弃的重复属性——这就是额外要求!hasDuplicateAttrs()前置条件的原因。

清理width="100" width="200"必须同时移除两者:只揭露第二个会让图片仍被静默设定尺寸。解析与生成位于 vtextedit 子模块,详见 libs/vtextedit/AGENTS.md § Image References。

十一、大小写清理的完成状态

跨新架构设置页(settings/settingswidget.cpp)与*Dialog2对话框的句子式清理已全部完成:

  • 内容字符串(表单标签、复选框/单选标签、下拉框条目、分组/章节正文标签、tooltip、占位符)使用句子式。
  • 按钮、窗口/对话框标题、设置页标题、SettingsPageHelper::addSection卡片标题保持 Title Case。
  • 专有名词/产品名(PlantUml、MathJax、Graphviz、VNote、Vi、Git)、首字母缩写(URL、PAT、JSON、JAR、HTML、PDF)、键盘按键名(Tab、Ctrl)以及既定术语Personal Access TokenRemote URL保留规范大小写。
  • 冒号清理同样完成src/widgets/下没有任何表单/字段标签以:结尾。仅存的尾冒号包括:QInputDialog提示语(按规则保留)、markdowneditor.cpp中的消息体标题、syncconflictdialog2.cpp:64引入列表的句子式说明标签,以及quickaccesspage.cpp:72中非可视的设置搜索词(该字符串只传给addSearchItem,从不渲染)。

翻译.ts文件(src/data/core/translations/)被有意保留未动;未来的lupdate会刷新源条目。

十二、相关模块

  • src/widgets/AGENTS.md — Widget 模块总览、ViewArea2 框架、2后缀约定、Widget 构造模式
  • src/controllers/AGENTS.md — 与这些对话框配对的 Controller 的 MVC 规则
  • AGENTS.md — 项目级架构、MVC 规则、代码风格

总结:VNote 的对话框体系是一个严格遵循 MVC 与依赖注入的成熟实现——*Dialog2负责纯展示、Controller 承载逻辑、ServiceLocator贯穿全程。新对话框只要遵循本文的架构模式、文案规范表、objectName 测试规则,并理解 Banner 抑制、模板解析、图片尺寸往返验证等既有交互的边界条件,就能与整个代码库保持一致。

【免费下载链接】vnoteA pleasant note-taking platform in native C++.项目地址: https://gitcode.com/gh_mirrors/vn/vnote

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询