news 2026/9/10 8:52:23

Storybook Monorepo 实战指南:共享 Preview、MSW 数据与可验证 Stories 的完整搭建规范

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Storybook Monorepo 实战指南:共享 Preview、MSW 数据与可验证 Stories 的完整搭建规范

Storybook Monorepo 实战指南:共享 Preview、MSW 数据与可验证 Stories 的完整搭建规范

【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook

本文以 Storybook 官方仓库中的 Agent 评测任务书 scripts/eval/prompts/monorepo.md 为核心骨架,系统拆解在 Nx/Workspaces 类 monorepo 项目中从零让 Storybook「完全可用」的标准流程:先通过受控的 Glob/Grep/Read 探测运行时依赖,再一次性构建出足以覆盖绝大多数 story 的共享.storybook/preview.tsx,接入 MSW 模拟数据、处理 portal 挂载点、批量编写带CssCheck校验的 stories,最后用@storybook/addon-vitest在浏览器环境中批量验证。读者读完将掌握一套可直接复制的「发现 → 基础设施 → 故事编写 → 批量验证」流水线,以及判断什么时候该写play、什么时候该修共享 preview 的工程决策准则。

一、这份文档是什么:monorepo 场景下的 Agent 任务规范

scripts/eval/prompts/monorepo.md不是一份面向普通用户的 Storybook 教程,而是 Storybook 官方仓库agent-eval评测体系中用于衡量 AI Agent(MCP 工具与 CLI 插件两种形态)能否独立完成「在 monorepo 项目中完整搭建 Storybook」能力的任务书。它规定了任务环境、交战规则、八步实施计划与明确的完成标准,其产出会经由 agent-eval/lib/experiment.ts 中的评测管线(如813-monorepo-leaf-create-component等核心场景)自动判定。

任务书对应的真实工程样板位于 agent-eval/templates/monorepo/,是一个标准的 npm workspaces monorepo:

packages/ ├── app/ # 消费方应用(@acme/app),依赖 @acme/ui │ ├── src/main.tsx │ └── index.html └── ui/ # 组件库(@acme/ui),Storybook 搭建的目标包 ├── src/ │ ├── components/Card.tsx │ └── index.ts ├── stories/Card.stories.tsx ├── package.json ├── tsconfig.json ├── vitest.config.ts └── vitest.storybook.config.ts

其中@acme/app的入口 main.tsx 直接消费@acme/ui导出的Card组件,@acme/ui的 index.ts 负责统一导出——这正是任务书第 3 条「如果用到本地 monorepo 依赖,先构建所有发现的依赖」所针对的典型结构。@acme/ui的 package.json 中预置了@storybook/react-vite(next 版本)、@storybook/addon-vitest@storybook/addon-a11y@storybook/addon-docs等依赖以及test:stories脚本,说明该包就是 Storybook 要被「点亮」的叶子包。

任务环境速览

PropertyValue
Version10.4.0-alpha.10
Renderer@storybook/react
Framework@storybook/react-vite
Builder@storybook/builder-vite
Config Dir.storybook
LanguageTypeScript
Package Managerunknown package manager(由项目 lockfile 检测)
Addons@storybook/addon-onboarding, @storybook/addon-themes, @storybook/addon-docs, @storybook/addon-designs, @storybook/addon-vitest, @storybook/addon-a11y, storybook-addon-pseudo-states, @chromatic-com/storybook

任务书的最终目标一句话概括:让 Storybook 在本项目中完全可用——正确配置.storybook/preview.tsx的 decorators、为数据接入 MSW、编写最多 10 个与源码同目录的*.stories.tsx文件,并且只在能证明非平凡行为的地方添加play函数。注意eval-template.jsonamazonLinuxPackages: "playwright-chromium"的声明,说明该评测场景的验证环节依赖 Playwright 的 Chromium 浏览器环境。

二、交战规则(Rules of Engagement):把工具纪律当作时间预算

任务书开篇用「这些是时间预算,不是建议」来强调前九条交战规则。它们本质上是把「在陌生仓库中高效且不破坏性地完成任务」沉淀成可执行约束:

  1. 用 Glob/Grep/Read 工具发现,禁用 shell 探索。列目录用Glob('src/components/*')(别名search_filesfile_search),搜字符串用Grep('pattern', { path: 'src' })(别名grep_searchsearch_files),读文件用Read('path/to/file')(别名read_file),批量编辑用多次Edit或一次Edit+replace_all——而不是lsfindcatheadtail、shellgrepsednode -e。理由是这些 shell 命令单次调用更慢且破坏缓存。
  2. 绝不读或 grepnode_modules。任务书中给出的 import 路径是正确的,不要通过检查已安装包来验证;若感觉不对,重读任务书而不是去翻node_modules
  3. Nx monorepo 局部优先。不要一开始就翻其他包里的配置或既有 Storybook 内容,从目标包本地的配置与工具链开始探索;若用到本地 monorepo 依赖,在写 stories 或跑测试前先构建全部发现的依赖。
  4. 读取预算约 12 个文件。写任何代码前最多读约 12 个文件(index.html、入口、App、providers、路由、根 CSS、2–3 个代表性页面/组件、1–2 个 hooks、1 个测试);不够就概括后继续前进。
  5. Edit 优先于 Write。读过的文件用Edit修改,Write只用于新文件;项目里已有storybook init生成的.storybook/preview.tsx,要Edit它而不是覆盖。
  6. 批量测试循环。先写完所有 stories,再统一跑一次 vitest;在首次批量运行暴露失败之前,不做单文件 vitest。
  7. 每次安装都使用unknown package manager(从本项目 lockfile 检测出的包管理器)。
  8. 优先修共享的.storybook/preview.tsx,当多个 stories 以同样方式失败时,不要在 story 局部打补丁。
  9. 达到成功标准就停,不要无休止打磨。

这套纪律对应到仓库中的验证设施:@acme/ui的 vitest.storybook.config.ts 通过@storybook/addon-viteststorybookTest({ configDir: ... })插件定义storybook测试项目,并用@vitest/browser-playwright以 headless Chromium 实例运行——这就是规则 6「统一跑一次 vitest」所指的命令行npx vitest --project storybook run的底层配置来源。

三、Step 1 — 发现运行时(≤12 次读取)

第一步的目标是在预算内回答一个核心问题:"一个典型页面要渲染,preview 必须供应哪些 providers、CSS、浏览器状态和网络调用?"

按以下顺序,先用 Glob/Grep 再用针对性 Read:

  • index.html——<link rel="stylesheet">标签、内联<style>块、字体,以及非 JS 创建的<div id="...">挂载点或 portal 根节点;
  • 入口文件(main.tsx/index.tsx)—— 包裹<App />的 providers、根 CSS import;
  • App.tsx—— 顶层布局、路由使用、消费的 providers;
  • providers / context 文件 —— 它们暴露了什么;
  • 根 CSS —— 全局样式、CSS 变量、主题 tokens(包括 JS import 的 CSSindex.html里 link 的);
  • 数据 hooks ——fetch(...)useQueryaxios等(捕获渲染时实际调用的 base URL 与 endpoints);
  • 渲染时实际读取的浏览器状态 ——localStorage/sessionStorage/ cookie 键名;
  • portal 目标 ——createPortal(...)及其挂载的 DOM id(如#modal-root);
  • 1–2 个真实页面或功能组件(作为 story 的 JSX 模式真源)。

以评测模板为例:packages/app/index.html只有一个#root挂载点,main.tsxStrictMode+createRoot渲染,且组件全部来自@acme/ui——这意味着本轮发现只需确认「没有自定义 providers、没有额外 CSS 文件、没有 portal、没有数据请求」,即可直接推进到 Step 2,而不是盲目堆砌基础设施。

四、Step 2 — 构建共享 Preview:一次性解决大多数 story 的准备工作

Step 2 的核心原则是:把 Storybook 一次性配置好,让绝大多数 story 无需逐文件设置。做法是Edit 已有的.storybook/preview.tsx(由storybook init创建),向既有 config 对象中合并新增内容,而不是整体替换。

完整的目标形态如下(将新片段合并进已有内容):

// .storybook/preview.tsx import type { Preview } from '@storybook/react-vite'; import '../src/index.css'; import MockDate from 'mockdate'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { SessionProvider } from '../src/contexts/SessionContext'; import { mswHandlers } from './msw-handlers'; initialize({ onUnhandledRequest: 'bypass' }); const preview: Preview = { decorators: [ (Story) => ( <SessionProvider> <Story /> </SessionProvider> ), ], loaders: [mswLoader], parameters: { msw: { handlers: mswHandlers } }, async beforeEach() { localStorage.setItem('theme', 'dark'); MockDate.set('2024-04-01T12:00:00Z'); }, }; export default preview;

Preview 的四条硬性规则:

  • 使用真实的 provider 树与真实的根 CSS import,不要凭空发明 providers;
  • 如果应用的 CSS 是通过index.html中的<link>加载(而非 JS import),则从 preview 中 import 同一个文件,保证 story 渲染出相同的样式;
  • 只播种应用实际读取的浏览器状态键,不要清空全部localStorage/sessionStorage/ cookies,也不要重置 Storybook 自身状态;
  • mockdate仅在渲染输出依赖日期时使用;不要直接 mockwindowdocumentnavigator、observers 或fetch

这四条规则与评测模板的组件实现相互印证:Card组件的样式全部内联在 JSX 中(见 Card.tsx),因此共享 preview 在此场景下无需导入额外 CSS;而模板的 vitest.storybook.config.ts 中setupFiles: ['.storybook/vitest.setup.ts']的存在,说明beforeEach这类全局生命周期钩子正是 Storybook 测试模式下被统一执行的地方。

五、Step 3 — Portals:写在 decorator 里,而不是 preview-body.html

如果在 Step 1 发现了createPortal(..., document.getElementById('foo'))的调用,.storybook/preview.tsx中添加一个在 story 渲染前创建 portal 根节点的 decorator,不要使用preview-body.html

// Add this entry to the `decorators` array of your preview config: (Story) => { for (const id of ['modal-root', 'drawer-root', 'toast-root']) { if (!document.getElementById(id)) { const el = document.createElement('div'); el.id = id; document.body.appendChild(el); } } return <Story />; }

将该 decorator 加入 preview config 的decorators数组。若 portal 只指向document.body,则完全跳过此步。之所以用 decorator 而非preview-body.html,是因为 decorator 能确保每个 story 渲染前根节点必然存在,且逻辑与 preview 配置保持同源、可被 vitest 测试环境复用;而模板中的Card组件并不使用 portal,属于「跳过此步」的典型情况。

六、Step 4 — MSW handlers:只覆盖 stories 会命中的端点

使用msw-storybook-addon,安装命令为:

<your-package-manager> add -D msw msw-storybook-addon mockdate npx msw init ./public --save

npx msw init会在./public下生成 Service Worker 文件,因此需要确保.storybook/main.ts./public作为静态目录提供服务:

// .storybook/main.ts import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { staticDirs: ['../public'] }; export default config;

handlers 放在.storybook/msw-handlers.ts只覆盖你的 stories 会实际用到的端点,不写 catch-all

// .storybook/msw-handlers.ts import { http, HttpResponse } from 'msw'; export const mswHandlers = { products: [ http.get('https://api.example.com/products', () => HttpResponse.json({ items: [{ id: 'p1', name: 'Example', price: 42 }] }) ), ], };

与 Step 2 中 preview 的loaders: [mswLoader]parameters.msw.handlers组合起来,就构成了「MSW 初始化 → 全局 handler 注入 → 每个 story 渲染前由 loader 激活 mock」的完整链路。initialize({ onUnhandledRequest: 'bypass' })表示未匹配的请求直接放行,避免无关网络调用干扰 story。

七、Step 5 — 批量编写最多 10 个 story 文件(含唯一的 CssCheck)

本步交付物是两样东西,缺一不可:① 最多 10 个与源码同目录的*.stories.tsx;② 恰好一个CssCheckstory(必须加入其中一个文件,见 Step 5b)。Step 5 未完成CssCheck就不算完成。

Step 5a — 挑选目标并编写文件

从真实代码库中挑选约 10 个有意义的目标(从底层可复用组件到页面组件)。跳过子组件、hooks、contexts、helpers,以及存在真实页面组件时的App本身。每个 story 文件:典型组件约 3 个 export,确有实际使用场景时可到约 10 个;JSX 模式从真实页面/路由/测试中复制。

每个新 story 文件从一开始就标记['ai-generated', 'needs-work'],只有在该文件通过 vitest 之后才移除'needs-work'——这样所有尚未验证的内容(包括来不及修复的 stories)都会保持正确标记:

import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect } from 'storybook/test'; import { Button } from './Button'; const meta = { component: Button, tags: ['ai-generated', 'needs-work'], // strip 'needs-work' once vitest passes } satisfies Meta<typeof Button>; export default meta; type Story = StoryObj<typeof meta>; // Smoke check — one is enough per file export const Primary: Story = { args: { children: 'Order now' }, play: async ({ canvas }) => { await expect(canvas.getByRole('button', { name: /order now/i })).toBeVisible(); }, }; // Variant-only stories: no play needed export const Clear: Story = { args: { children: 'Cancel', clear: true } }; export const Large: Story = { args: { children: 'Checkout', large: true } }; export const WithIcon: Story = { args: { icon: 'cart', 'aria-label': 'food cart' } };

Story 编写规则:

  • 每个 meta 都以tags: ['ai-generated', 'needs-work']开头;
  • 显式写出所有 import;
  • 不添加自定义title(由 component 自动推导层级);
  • 不构建大型 story 专用 harness —— 优先修复 preview;
  • 不创建新的应用组件。

评测模板中已有的 Card.stories.tsx 恰好是这一规范的反面示例——它带有title: 'UI/Card'且没有 tags,正是 Agent 需要按任务书「改造/重写」的对象。而Card组件源码中data-testid="card"与内联样式(border: '1px solid #e5e7eb'padding: padded ? 16 : 0)则为CssCheck提供了真实的计算样式断言来源。

Step 5b — 添加唯一的 CssCheck story

在 Step 5 结束前,从刚写过的文件中挑一个视觉上独特的组件,为该文件添加CssCheckexport。整个项目恰好只有一个CssCheck,不是每文件一个。Step 5 直到它存在才算完成。

为什么它是强制项:toBeVisible对未加样式的组件也能通过。一个具体的getComputedStyle值才是「共享 preview 确实加载了应用 CSS」的唯一证明——否则你根本不知道 stories 是否真的渲染正确。

做法:从组件源码中读取一个真实样式值(如 styled-components 中的十六进制颜色、Tailwind 类bg-blue-600、主题中的 CSS 变量),并断言解析后的getComputedStyle值:

export const CssCheck: Story = { args: { children: 'Submit' }, play: async ({ canvas }) => { const button = canvas.getByRole('button', { name: /submit/i }); // PrimaryButton uses bg-blue-600 — fails if Tailwind / global CSS did not load. await expect(getComputedStyle(button).backgroundColor).toBe('rgb(37, 99, 235)'); }, };

八、Step 6 — play 函数的取舍:只在能证明非平凡行为时添加

不要给每个 story 都加play只有当play能断言「仅靠渲染输出本身无法证明」的内容时才值得写。宁可每文件一个好play,也不要五个冗余的。

值得写play的场景:

  • 交互(表单填写 + 提交、点击 → 菜单展开、切换 tab 揭示面板);
  • 异步数据确实从 MSW 到达(等待 mock 内容替换 spinner);
  • portal 渲染进正确的根节点(通过canvasElement.ownerDocument查询);
  • 具有语义意义的 CSS 驱动状态(如主题色、disabled 样式、能确认全局样式表已加载的布局);
  • 组件负责的无障碍(正确的 role/label 暴露)。

完全跳过play的场景:story 只是同一组件的静态变体(不同args,无新行为)。在ClearLargeWithIcon等上面重复getByRole(...).toBeVisible()是冗余的——组件抛出异常或无法挂载时渲染本身就会失败。

Smoke play 必须证明渲染本身证明不了的东西。只做await expect(canvas.getByRole('button')).toBeVisible()的 play 毫无价值。可接受的 smoke play 断言以下之一:

  • 反映状态的 aria 属性aria-expandedaria-disabledaria-checkedaria-current);
  • 以文本或属性渲染的 prop 值(如args.label出现在 DOM 中、href匹配args.to);
  • 异步内容到达findBy*waitFor—— 证明 loader/MSW handler 真的解析了);
  • portal 挂载进正确的根(通过canvasElement.ownerDocument.body查询)。

具体到包含PrimaryClearLargeWithIconButton.stories.tsxPrimary保留一个 smokeplay(每文件一个就够);ClearLargeWithIcon不加play。(项目唯一的CssCheck已在 Step 5 添加,此处不要再加。)

Imports 与 play 上下文——这里搞错会让 vitest 以微妙的方式失败:

  • expectwaitFor来自'storybook/test'—— 必须显式 import;
  • canvasuserEventcanvasElement来自play 参数async ({ canvas, userEvent, canvasElement }) => { ... }不要import { userEvent } from 'storybook/test'不要const canvas = within(canvasElement)——两者都已提供;
  • 仅 portal 查询时,通过canvasElement.ownerDocument.body查询,此时可以从'storybook/test'importwithin(如within(canvasElement.ownerDocument.body).findByTestId(...));其他场景不要用within
export const FilledForm: Story = { play: async ({ canvas, userEvent }) => { await userEvent.type(canvas.getByLabelText('email'), 'a@b.com', { delay: 50 }); await userEvent.click(canvas.getByRole('button', { name: /submit/i })); await expect(await canvas.findByText(/welcome/i)).toBeVisible(); }, };

九、Step 7 — 一次性批量验证,再按失败迭代

运行任何测试前先读这条规则:首次 vitest 调用必须一起运行所有新 stories,批量运行前禁止单文件运行:

npx vitest --project storybook run

随后运行项目的 TypeScript 检查(使用package.json中的脚本,通常是tsc --noEmitunknown package manager run typecheck)。直接读一次原始输出,不要反复用grep/head切片。

对每个失败的处理循环:

  1. 读错误;
  2. 若多个 stories 共享同一失败,修复共享的 preview 配置,而不是修 stories;
  3. 只对受影响文件重跑 vitest:npx vitest --project storybook run path/to/Foo.stories.tsx
  4. 重复直到文件通过,然后继续下一个。每个文件重试上限约 2 次——仍然失败就保留'needs-work'继续前进。

文件通过后,编辑其 meta 移除'needs-work',使 tags 变为['ai-generated'];修不好的文件保留['ai-generated', 'needs-work']——继续前进,不要无限循环。

该步骤在仓库中的执行载体是 vitest.storybook.config.ts:storybookTest({ configDir: path.join(dirname, '.storybook') })让 vitest 直接驱动 Storybook 渲染并执行playbrowser: { enabled: true, headless: true, provider: playwright({}), instances: [{ browser: 'chromium' }] }表明这些 story 测试是在真实浏览器环境中运行的,这也解释了为什么任务书要求「不 mockwindow/document/navigator」——浏览器环境本身已真实存在。

十、Step 8 — 清理收尾

结束前,移除:调试代码、诊断期间添加的宽泛 mocks、未使用的依赖以及评测残留物(eval artifacts)。对应到规则 9「达到成功标准就停」——清理只是回到干净基线,不是继续打磨。

十一、完成标准(Done when)

任务书明确列出以下验收项,缺一不可:

  • 恰好一个CssCheckstory存在于新 stories 中,且断言的是从组件源码读取的某个具体计算样式值(在 Step 5 末尾添加);
  • 每个被 vitest 确认通过的 story 文件都已移除'needs-work',tags 变为['ai-generated'];仍失败的保持['ai-generated', 'needs-work']
  • npx vitest --project storybook run对新文件通过;
  • 项目 TypeScript 检查对变更文件通过;
  • 共享 preview 足够强,stories 无需逐文件的 fetch/provider 变通方案。

最后一条标准与规则 8 呼应:共享 preview 的强度是整套方案是否合格的终极判据——它意味着所有「每个 story 都要重复一次」的准备逻辑(provider、CSS、浏览器状态、MSW handler)都被正确收敛到了.storybook/preview.tsx这一处。

十二、仓库中的配套证据与延伸阅读

本文所述规范并非孤立的 prompt 文本,它在当前仓库中有完整的工程配套,可作为深入研究的入口:

  • 评测模板本体:agent-eval/templates/monorepo/ 中的 workspaces 结构(packages/apppackages/ui)、eval-template.json 的playwright-chromium环境声明;
  • 验证配置:vitest.storybook.config.ts 演示了@storybook/addon-vitest+@vitest/browser-playwright的浏览器内 story 测试接线,这也是npx vitest --project storybook run得以工作的底层配置;
  • 组件与 story 真源:Card.tsx(内联样式 +data-testid)与 Card.stories.tsx(带title的既有写法)——它们是 Step 5 改写练习的典型对象;
  • 评测编排:agent-eval/lib/experiment.ts 中的813-monorepo-leaf-create-component等核心评测与 900s 超时配置,解释了 monorepo 场景在整体评测线中的位置;
  • Storybook 自身能力文档:仓库 docs/writing-stories/、docs/writing-tests/ 与 docs/api/ 目录提供了 decorators、play function 与 vitest 集成的完整用户文档,任务书中的Reference小节指向的正是这些主题。

如需将本流程复用到自己的项目,可将「发现运行时 → 构建共享 preview → 处理 portal/MSW → 批量编写带验证的 stories → 统一跑vitest --project storybook」五段式作为模板,并把CssCheck思想移植过去:任何依赖全局样式的组件库,都应该至少有一个断言getComputedStyle具体值的 story,作为「共享基础设施真的生效了」的守门测试。

【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 8:51:52

LoopX:面向长周期任务的Agent状态控制平面

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 8:51:05

数据服务合规管理落地指南:从元数据到审计日志的闭环实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 8:49:45

AI 矿山机械振动筛电控系统智能功率 MOSFET/IGBT 完整选型方案

AI 技术正赋能矿山机械&#xff08;如振动筛&#xff09;实现无人巡检、自适应物料分析、预测性维护与动态频率调节。电控系统对功率器件提出严苛要求&#xff1a;高可靠性、强抗振性、宽温工作、高效率。微碧半导体基于Trench、超结&#xff08;SJ&#xff09;、FS IGBT等成熟…

作者头像 李华
网站建设 2026/9/10 8:46:55

企业AI部署成熟度:从Demo到生产的四条关键路径

一年前我跟一位制造业客户的CTO聊&#xff0c;他说他们一年内上了三个AI试点项目&#xff0c;半年后复盘只有一个还在跑&#xff0c;另外两个连数据管道都没真正打通。这不算孤例。最近一份行业调研里有个数据让我印象很深&#xff1a;AI领域的投资热度持续飙升&#xff0c;但真…

作者头像 李华
网站建设 2026/9/10 8:45:29

无人机辅助NOMA蜂窝卸载的建模与仿真研究

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华