源码浏览(Code Explorer)功能说明
路由入口:
/code/{**path},本功能让访客直接在浏览器中浏览本站自身的源代码仓库(.NET 10monorepo,约 30 个项目),支持目录树导航、代码高亮(Monaco / Prism)、Markdown README 渲染与目录(TOC)、全文搜索、AI 分析结果展示与评论。
涉及项目:
| 层 | 项目 | 职责 |
|---|---|---|
| Web | Dpz.Core.Web | 页面渲染、SSR 侧栏树、fragment 接口、搜索、PJAX 导航 |
| Service | Dpz.Core.Service | 文件系统条目仓储逻辑、7 天 FusionCache 缓存、README 解析、AI 分析判定 |
| ViewModel | Dpz.Core.Public.ViewModel | CodeNoteTree / ChildrenTree / CodeContainer |
| Entity | Dpz.Core.Public.Entity | CodeFileSystemEntry(MongoDB 集合) |
| Jobs | Dpz.Core.Web.Jobs | 消费 AnalyzeCodeMessage,执行 AI 代码分析(AnalyzeCodeHandler) |
| WebApi | Dpz.Core.WebApi | REST 版扁平列表接口(GetPagedListAsync)+ 同样的 AI 分析触发 |
1. 总体架构
┌───────────────────────────── Dpz.Core.Web ─────────────────────────────┐
浏览器 │ │
┌──────────┐ GET /code/{**path} ┌───────────────────┐ GetChildrenAsync / FindByPathAsync │
│ Index.cshtml (SSR 侧栏树 + 内容) │ CodeController │ ──────────────────────────────────────┐ │
│ CodeExplorer.ts └─────────┬─────────┘ ▼ │
│ ├─ 侧栏树:SSR + 懒加载 + SWR 缓存 │ GET /get/code/children (fragment) ┌───────────────────────┐
│ ├─ PJAX 内容导航 └──────────────────────────────────────▶│ CodeFileSystemEntry │
│ ├─ Monaco/Prism 代码高亮 GET /search/code │ Service │
│ └─ tocbot Markdown 目录 (分组 HTML) │ · 7 天 FusionCache │
│ │ │ · README 解析 │
│ ▼ │ · 物理文件内容自愈 │
│ localStorage(codeExplorerTreeCache_v1) │ · AI 分析判定 │
└──────────┘ └───────────┬───────────┘
│ MongoDB
▼
CodeFileSystemEntry 集合
(由同步任务从本地源码
目录扫描写入)
关键设计决策(后文详述):
- 侧栏树服务端渲染(SSR):根级 + 当前路径各级在首次 HTML 中直接输出,首屏零额外请求即可用。
- 轻量 fragment 接口:目录展开只拉几 KB 行片段,替代早期「拉整页 HTML 再解析」的做法。
- SWR 本地缓存:
localStorage缓存目录片段(TTL 3 小时),命中立即渲染,后台静默刷新。 - Markdown 目录用 tocbot:复用文章页方案,但刻意避开
js-toc/js-toc-fab类名(见 6.2)。
2. 后端
2.1 路由一览(Dpz.Core.Web/Controllers/CodeController.cs)
| 方法 | 路由 | 说明 |
|---|---|---|
Index | GET code/{**path} | 页面入口;构建内容模型 + SSR 侧栏树 |
TreeChildren | GET get/code/children?path= | 目录片段(纯行 HTML),供侧栏懒加载与缓存 |
Search | GET search/code?keyword= | 按名称模糊搜索(支持 */? 通配符),返回按父目录分组的局部视图 HTML |
路由前缀
get/是刻意的:避免与code/{**path}兜底路由冲突;TreeChildren内仍保留真实路径守卫——若path恰好命中真实条目则RedirectToAction回页面,防止抢占真实目录。
2.2 页面渲染流程(Index)
解析 pathArray
│
▼
BuildCodeNoteTreeAsync(pathArray) ← CodeNoteTreeExtensions
│ ├─ FindByPathAsync(文件/目录实体,缓存 7 天)
│ └─ GetChildrenAsync 或 FromFile(内容模型)
▼
模型为文件且 ShouldAnalyzeAsync → 发布 AnalyzeCodeMessage(AI 分析)
▼
Type != FileSystem → 404
▼
BuildSidebarTreeAsync(pathArray) ← 逐级 GetChildrenAsync 构建树
│ 根级 → 沿路径每一级目录附加 Children
▼
return View(new CodeIndexViewModel { NoteTree, SidebarTree })
SSR 侧栏树(BuildSidebarTreeAsync):以当前路径为深度,逐级调用 GetChildrenAsync(每级均命中 7 天缓存,代价可忽略),构建嵌套 CodeTreeNodeViewModel。渲染时目录在前、文件在后,各自按名称排序——与内容区 _CodeListViewPartial 的排序规则一致。
2.3 fragment 接口(TreeChildren)
- 返回
_CodeTreeRowsPartial(内部复用_CodeListViewPartial两次:目录 + 文件),即内容区的行列表子集。 - 行内
<code-icon>由CodeIconTagHelper服务端渲染 Material 图标(.svg),客户端无需任何图标匹配逻辑。 - 响应不含页面壳、面包屑、评论等,几 KB 级,客户端
DOMParser解析成本可忽略。
2.4 服务层(CodeFileSystemEntryService)
| 方法 | 说明 |
|---|---|
FindByPathAsync | 精确匹配 + 大小写不敏感回退;文件自动补全内容(FixPreviewContentAsync) |
GetChildrenAsync | 指定父路径的全部子项;顺带解析每个子目录的 README(一次批量查询) |
SearchAsync | Name 正则模糊搜索;关键词经 ApplicationTools.WildcardToRegexPattern 转义(特殊字符不再报错),*→.*、?→. 通配,含通配符时锚定首尾(glob 语义),否则子串匹配 |
GetAllDirectoriesAsync | 全部目录路径(含子目录前缀过滤),当前前端未使用 |
GetPagedListAsync | 扁平分页列表(WebApi 用) |
SaveDescriptionAsync / SaveAiAnalyzeResultAsync | 写库后失效条目 + 父目录 + 搜索缓存 |
- 缓存:FusionCache L1+L2,
CacheDefaultExpiration = 7 天,key 按方法 + 路径。目录更新(同步任务写库)后,修改操作会显式失效相关缓存;但「服务端 7 天」与「客户端 3 小时」意味着侧栏最坏有 3 小时的陈旧窗口(可接受)。 - 内容自愈:文件内容缺失时按
CodeView:SourceCodeRoot从物理文件读取(带路径越界校验),写入库并更新。 - README 解析:目录的 README 由
ResolveReadmeContentAsync查找readme.md子项(大小写不敏感)。
2.5 AI 分析链路
Index中:文件可预览、行数 ≥ 50、非.min、语言为csharp|javascript|typescript、CodeUseAIAnalyze=true、且结果为空或 hash 不一致 → 发布AnalyzeCodeMessage(携带FileHash用于去重)。Dpz.Core.Web.Jobs的AnalyzeCodeHandler消费消息执行分析,SaveAiAnalyzeResultAsync写回。- 页面再次打开时
_AiAnalyzeResultPartial展示 AI 分析结果(同样走 README 渲染),并写入PageMetadata.Description用于 SEO。
2.6 视图与渲染
| 文件 | 职责 |
|---|---|
Views/Code/Index.cshtml | 页面骨架:侧栏(搜索 + 树)+ 主区(面包屑 + 内容区) |
_CodeTreeNodePartial.cshtml | 递归渲染侧栏树节点;DOM 必须与前端 createTreeNode 输出完全一致(见 6.3) |
_CodeTreeRowsPartial.cshtml | 合并目录/文件行片段(fragment 接口响应体) |
_CodeRowsPartial.cshtml | 内容区:文件视图 / 目录列表 + README + 全局评论;决定 AI 结果是否携带 TOC(!isMarkdownFile) |
_CodeListViewPartial.cshtml | 单侧行列表(目录或文件),行含 data-name/data-is-folder,是树懒加载与 SSR 的数据源 |
_CodeSearchResultsPartial.cshtml | 搜索响应体:顶部「找到 N 项」摘要;按父目录分组(组头 = 服务端 <code-icon> 文件夹图标 + 路径 + 数量,可点击进入目录),组内条目 = 服务端图标 + 名称高亮;空结果渲染 .code-search__empty 空状态块。高亮基于 ApplicationTools.WildcardToRegexPattern(捕获组定位实际命中的字面量片段),如 *.ts 仅高亮 .ts,通配符本身不参与高亮 |
_CodeView.cshtml | 按语言分发:markdown → _ReadmePartial(ShowToc=true);其余 → Monaco host + Prism fallback |
_ReadmePartial.cshtml | Markdig 管线:UseAutoLinks + UsePipeTables + UseTaskLists + UseEmphasisExtras + UseAutoIdentifiers(标题生成 id,tocbot 依赖);外链 target=_blank,相对链接改写为 /code 路由。model 为 (string Content, bool ShowToc):ShowToc=true 时渲染 .readme-layout(目录 nav + 正文 + FAB),目录/正文均带边框 |
_AiAnalyzeResultPartial.cshtml | AI 分析结果(警告条 + README 渲染),model 为 (string? Content, bool ShowToc) |
视图模型:CodeNoteTree(Dpz.Core.Public.ViewModel,含静态工厂 FromDirectory/FromFile/NotFound/FromSearch,其中 FromSearch 仅 WebApi 的搜索接口使用);Web 侧强类型 CodeIndexViewModel + CodeTreeNodeViewModel + 搜索用 CodeSearchResultsViewModel/CodeSearchResultGroup(均在 Dpz.Core.Web/Models/)——不使用 ViewBag/dynamic 传递树。
3. 前端
3.1 生命周期(App.ts → CodeExplorer.ts)
App.initComponents()每次执行都会new CodeExplorer()(首次 DOMContentLoaded 与每次pjax:end各一次)。CodeExplorer.init()两种路径:- 全量路径(
.code-explorer是首次出现):绑定搜索/点击/拖拽事件、初始化 TOC 事件、syncTreeWithUrl、渲染代码视图。 - 早退路径(
data-js-initialized="true",即容器跨 PJAX 复用):只做syncTreeWithUrl()+_syncToc()——此时事件已绑定过,不能重复绑定。
- 全量路径(
3.2 侧栏树(三阶段加载)
┌────────────┐ ┌───────────────┐ ┌───────────────────────────────┐
│ ① SSR 初始树 │ → │ ② 懒加载 + SWR │ → │ ③ PJAX 响应回流(免费优化) │
│ 首屏零请求 │ │ fragment + 3h 缓存│ │ updateTreeFromResponse 填充 │
└────────────┘ └───────────────┘ └───────────────────────────────┘
- ① SSR:首次 HTML 已含根级 + 当前路径各级(
data-loaded="true"、is-expanded),syncTreeWithUrl只补选中态与滚动定位,不发请求。 - ②
loadDirectory(未加载的节点展开时):- 查
_prefetchedDirectoryRows(PJAX 响应行,见 ③)→ 直接填充; - 查
localStorage['codeExplorerTreeCache_v1'](key = 规范化路径)→ 立即填充;若 3 小时内新鲜则不再请求; - 否则请求
/get/code/children;缓存命中时后台静默刷新(void this._fetchDirectoryFragment,不阻塞 UI),响应文本有变化才替换容器。
- 查
- ③ PJAX 回流:点击目录链接导航后,
updateTreeFromResponse从导航响应中提取行,直接填充被点目录(避免二次请求),并顺手写入 localStorage 缓存(仅确认是目录且非空时),刷新后再展开同样秒开。
缓存写入均 try/catch,隐私模式/配额超限静默降级;缓存 key 用
decodeURIComponent规范化,请求 URL 用encodeURIComponent再编码,避免特殊字符/中文路径问题。
3.3 PJAX 导航流(loadContent)
点击 .code-tree__label
→ handleClick:选中节点 + 折叠目录标记 is-expanded;同路径纯锚点(TOC/行号)直接放行,不拦截
→ loadContent(url):$.pjax({ container: '.code-explorer__content' })
├─ pjax:success → 更新树/面包屑 + 缓存回填 → rAF:Prism 高亮 + _syncToc() + _bindTocElements()
└─ pjax:end → App 全局监听 → initComponents() → new CodeExplorer()(早退路径)
⚠️ 早退路径陷阱(曾导致 TOC 闪烁消失):pjax:end 后 App.initComponents() 重建 CodeExplorer,早退路径里必须能访问到最新内容区。因此 _syncToc 每次执行都从 .code-explorer 容器内重新 querySelector('.code-explorer__content'),不能依赖仅在全量路径赋值的字段。
⚠️ TOC 链接点击:handleClick 对容器内任意 <a> 兜底,TOC 链接(href="#锚点")会命中 /code 前缀检查。必须在 preventDefault 前加"同路径 + 有 hash"守卫,交给 tocbot 的平滑滚动处理,否则会误触发 PJAX 整页导航。
3.4 代码查看(_renderCodeView)
- PC(
!util.isMobile()):注入 Monaco(MonacoEditor.ts),加载失败回退.use-prism样式用 Prism 高亮。 - 移动端:直接 Prism 高亮
prism-fallback。 - 语言由
data-language提供(服务端ResolveCodeLanguage+CodeView:ExtensionToLanguage配置)。
3.5 Markdown 目录(tocbot)
- 布局(与 Markdown 同一层级,不占整页宽度):
_ReadmePartial(ShowToc=true)渲染.readme-layoutflex 行——nav.toc.code-explorer__toc(左)|.readme-content.shadow(右)|.toc-fab(移动端),目录与正文均为卡片样式(边框 + 阴影)。 - 规则:每页最多一个 TOC,给"唯一的那个 markdown"——markdown 文件 → 文件内容;代码文件 → AI 分析结果;目录页 → README。
_syncToc():查.code-explorer__content .readme-main .markdown-body(首个即主内容)→ 有h1,h2,h3则destroy + init并加is-visible;否则destroy并隐藏。- 断点行为:
≥1201px:sticky 目录贴在 Markdown 左侧(code.css,top = header + breadcrumbs,宽 200px,自带滚动),向下滚动时贴顶;≤1200px:tocbot.css 浮动 TOC + FAB 抽屉(.is-open-mobile)。
- 交互:目录/FAB 元素随 PJAX 内容重建,
pjax:success后需_bindTocElements()重新绑定;全局"点击外部关闭"只绑一次(实例 flag),事件内实时查询当前元素。
3.6 搜索
- 搜索框:输入框右侧内嵌清除按钮(
code-explorer__search-clear),仅在有内容时显示;点击清除 = 清空输入 + 恢复真实树,随后重新聚焦输入框。 - 搜索流程:
handleSearch(keyword)→GET /search/code?keyword=→ 响应为服务端渲染的分部视图 HTML(_CodeSearchResultsPartial),客户端直接innerHTML注入侧栏树容器,不再有 JSON 拼装逻辑。 - 结果呈现:按父目录分组——组头(
code-search__group-header,文件夹图标 + 完整父路径 + 组内数量,可点击进入该目录)+ 组内条目(code-search__item,服务端<code-icon>按扩展名渲染图标 + 名称高亮,title为完整路径);顶部为「找到 N 项」摘要。高亮只覆盖通配符实际命中的字面量片段(如*.ts仅高亮.ts,Code*仅高亮Code,?og仅高亮og),*/?匹配到的内容不高亮。 - 空结果:渲染
.code-search__empty空状态块(大号搜索图标 + 关键词 + 提示语)。 - 恢复树:空关键词回车或点击清除按钮 →
restoreTree():清空树容器 →loadDirectory('')(命中 SWR 缓存)→syncTreeWithUrl()展开当前路径并高亮当前页面所在节点(等价于搜索前状态)。 - 特殊字符:通配符 → 正则的转换统一走
ApplicationTools.WildcardToRegexPattern(Dpz.Core.Infrastructure):*→.*、?→.,其余字符Regex.Escape字面量转义,MongoDB 正则不会再因*.cs这类输入抛 "quantifier does not follow a repeatable item" 异常;含通配符时锚定首尾(glob 语义,与WildcardMatch全串匹配一致),*.cs匹配所有.cs结尾文件;无通配符时保持子串匹配。_CodeSearchResultsPartial以captureLiterals: true复用同一转换做高亮,保证"命中的才高亮"。
3.7 样式
wwwroot/css/code.css:explorer 布局、树、行列表、Monaco、TOC 布局(.readme-layout+ 左侧 sticky 列)、移动端(<769px隐藏侧栏)。wwwroot/css/tocbot.css:.toc/.toc-fab基础样式(卡片边框/阴影、浮动与 FAB 断点,全局引入,文章页共用)。
4. 数据流时序
sequenceDiagram
participant U as 浏览器
participant W as Dpz.Core.Web
participant S as CodeFileSystemEntryService
participant C as localStorage
rect rgb(240, 245, 250)
Note over U,W: 首次打开 /code/src/Dpz.Core.Web
U->>W: GET /code/src/Dpz.Core.Web
W->>S: BuildCodeNoteTreeAsync + 逐级 GetChildrenAsync
S-->>W: 内容模型 + 侧栏树(7 天缓存)
W-->>U: 完整 HTML(侧栏树已 SSR,含当前路径各级)
end
rect rgb(240, 250, 240)
Note over U,W: 展开未加载目录 e.g. wwwroot
U->>U: loadDirectory('wwwroot/...')
U->>C: 读缓存
alt 命中
U->>U: 立即填充(loaded=true)
U->>W: 后台 GET /get/code/children?path=wwwroot(非阻塞)
else 未命中
U->>W: GET /get/code/children?path=wwwroot
end
W->>S: GetChildrenAsync
S-->>W: 行片段(目录+文件)
W-->>U: 片段 HTML
U->>C: 写入缓存(TTL 3h)
alt 文本有变化
U->>U: 替换容器重新填充
end
end
rect rgb(250, 245, 240)
Note over U,W: 点击 README.md(PJAX 导航)
U->>W: PJAX GET /code/README.md(X-PJAX)
W-->>U: 完整 HTML → 提取 .code-explorer__content
U->>U: pjax:success → 树/面包屑更新 + 目录缓存回填 + Prism + _syncToc 生成目录(_bindTocElements 重绑)
U->>U: pjax:end → App.initComponents → CodeExplorer 早退路径 → _syncToc 重新查询内容区(TOC 保留)
end
5. 配置项
| 配置 | 说明 |
|---|---|
CodeView:SourceCodeRoot | 本地源码根目录(内容自愈读取物理文件) |
CodeView:ExtensionToLanguage | 扩展名/文件名 → Monaco/Prism 语言映射 |
CodeUseAIAnalyze | 是否启用 AI 分析触发(默认 false) |
Mongodb:...(经 AgileConfig) | CodeFileSystemEntry 所在库 |
6. 注意事项与维护指南
6.1 修改侧栏树结构时
SSR 节点(_CodeTreeNodePartial)与 JS createTreeNode 输出必须保持同构,两端依赖的契约:
data-name(与 URL 段解码后比较)、data-is-folder="true|false".code-tree__children上的data-loaded="true"(已加载)/"false"(待懒加载).code-tree__content上的title、.code-tree__label的href(/code/...)- 目录节点才渲染
.code-tree__children容器;文件节点 toggle 加is-hidden
改动任意一侧必须同步另一侧,否则 findNodeByPath/syncTreeWithUrl/populateContainer 会失效。
6.2 类名避让
代码页 TOC 不能使用 js-toc / js-toc-fab:全局 ArticleRead(App.initComponents 每次执行)会抓取 .js-toc 容器强制 display:none 并接管 FAB 事件。代码页统一用 code-explorer__toc / code-explorer__toc-fab。
6.3 PJAX 双事件
pjax:success(本模块)+pjax:end(App 全局,触发整页组件重建)都会执行_syncToc/syncTreeWithUrl——幂等设计,重复执行无害(destroy + init)。- 新增「需要在新内容加载后执行」的逻辑时,两个入口都要考虑;且早退路径无法访问全量路径才有的私有字段。
- 目录/FAB 元素随内容重建:元素级监听必须用
_bindTocElements()在pjax:success后重新绑定;document 级监听绑一次即可(_tocDocListenerBoundflag),事件内实时查询当前元素。
6.4 缓存与时效
- 服务端 FusionCache 7 天;客户端 SWR 3 小时(
CodeExplorer.TREE_CACHE_TTL_MS)。改源码后侧栏最坏延迟 3 小时,内容区总是实时(随导航请求)。 - localStorage 写入必须 try/catch(配额/隐私模式)。
6.5 路由安全
code/{**path}是兜底路由;新增子路由时注意字面量优先的冲突问题,必要时(如get/code/children)把前缀独立到get/下,并加真实路径守卫重定向。
6.6 调试方法
- 无头浏览器验证(本地 dev 服务):
检查chrome --headless=new --remote-debugging-port=9333 --dump-dom "https://localhost:37701/code/README.md".code-explorer__toc .toc-link数量与is-visible类。 - 树缓存:DevTools → Application → Local Storage →
codeExplorerTreeCache_v1。 - 服务端缓存:FusionCache 键形如
CodeFileSystemEntryService:GetChildrenAsync:Parent=...。
7. 常见改动指引
| 需求 | 改动点 |
|---|---|
| 新增可预览文件类型 | 服务端 CodeView:ExtensionToLanguage 配置;前端确认 Prism 组件已引入 |
| 调整 TOC 深度/偏移 | CodeExplorer._syncToc 的 headingSelector / headingsOffset(当前 120 = header 64 + breadcrumbs 48) |
| 调整目录缓存时效 | CodeExplorer.TREE_CACHE_TTL_MS |
| 调整树排序 | 服务端 BuildLevelItemsAsync / TreeChildren(目录在前、名称升序),须与 _CodeListViewPartial 一致 |
| 调整 AI 分析触发条件 | CodeFileSystemEntryService.ShouldAnalyzeAsync |
| 调整搜索通配符/转义规则 | ApplicationTools.WildcardToRegexPattern(Dpz.Core.Infrastructure,当前 */? 通配,其余字符字面量匹配);锚定逻辑在 CodeFileSystemEntryService.BuildSearchPattern(含通配符时锚定首尾) |
| 新增内容区局部视图 | 加入 _CodeRowsPartial;若含标题需同步 _syncToc 的 contentSelector 判断 |