想在 Vue 3 项目里把 Confluence 的产品文档和 Notion 的项目笔记整合到同一个检索界面,关键不是把两个 iframe 嵌进去,而是要在前端抽象出一层统一的知识库数据结构。Confluence 和 Notion 各自有一套内容描述格式,如果直接对接原始响应,列表页、详情页、搜索框都要写两遍。本文围绕 Vue 3 组合式 API 与一个轻量 Node 代理,说明如何把两边 API 差异收敛到数据层,并让组件只面向统一的文档节点渲染。

两平台内容格式的底层差异
Confluence 的 REST API 在返回页面详情时,body.storage 字段通常是一个 ADF(Atlassian Document Format)JSON。段落、标题、引用、代码块、表格等元素都嵌套在 content 数组里,每种节点用 type 和 attrs 表达特征。Notion 的 API 则返回 block 数组,每个 block 最外层是 type,例如 paragraph、heading_1、heading_2、code、table_row。两者虽然概念上都是块结构,但字段组织方式几乎完全不同。
举个简单的例子,同样是一段加粗文本。Confluence 会把它描述成一个 text 节点,并且带一个 marks 数组,mark 的 type 是 strong。Notion 会把这段文字放在 rich_text 数组里,每个片段通过 annotations 对象里的 bold 布尔值来表示加粗。列表、表格、图片的差异更大,Confluence 有单独的 media 节点和 table 容器,Notion 则把表格拆成 table、table_row、table_cell 多个层级。如果 Vue 组件直接读取这些原始结构,写着写着就会出现大量 if source 分支,后续维护成本会持续堆积。
因此需要在适配器层定义统一的节点结构。这个结构不需要完整覆盖两边全部能力,但要能表达段落、标题、代码块、列表、表格、图片和基础富文本标记。下面是一个轻量模型,前端组件只消费这个结构,不再关心来源平台。
interface UnifiedBlock {
id: string;
type: 'paragraph' | 'heading' | 'code' | 'list' | 'table' | 'image';
text: string;
level?: number;
language?: string;
children?: UnifiedBlock[];
meta?: Record<string, string>;
}
interface UnifiedDoc {
title: string;
source: 'confluence' | 'notion';
updatedAt: string;
blocks: UnifiedBlock[];
}把转换逻辑放在适配器里,后续新增第三方知识库时只需要多一个适配器,不必改动渲染组件和搜索逻辑。这也是整个集成方案能工程化的前提。
用 Node 代理统一认证与跨域
浏览器直接调用 Confluence 和 Notion 的 API 都会遇到 CORS 限制,而且长期令牌也不适合直接暴露在前端代码里。更稳妥的做法是在 Vue 3 工程里增加一个轻量 Node 服务,比如 Express 或者 Nitro,由它统一代理知识库请求。代理层负责携带 Authorization 请求头、刷新过期的 OAuth 令牌、裁剪响应字段,并返回统一 JSON。
Confluence 通常使用 Basic Auth 或者 Bearer Token,Notion 使用 Bearer Token 并且要求在请求头中显式带上 Notion-Version 字段。代理端可以把这些差异全部封装进不同的上游函数,前端只访问 /api/docs/search 和 /api/docs/detail 这样的内部接口。下面的示例演示了搜索接口如何同时请求两个平台并合并结果。
app.get('/api/docs/search', async function (req, res) {
const q = req.query.q;
const [confluenceRes, notionRes] = await Promise.all([
fetchConfluence(q),
fetchNotion(q)
]);
res.json({ results: [...confluenceRes, ...notionRes] });
});
async function fetchNotion(q) {
const response = await fetch('https://api.notion.com/v1/search', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + process.env.NOTION_TOKEN,
'Content-Type': 'application/json',
'Notion-Version': '2022-06-28'
},
body: JSON.stringify({ query: q })
});
const data = await response.json();
return normalizeNotion(data.results);
}代理层还可以做字段裁剪。Confluence 原始响应里包含 _links、object、parent、space 等大量导航信息,Notion 响应里也有 created_by、last_edited_by、parent 等对象。如果不裁剪,前端每次搜索都会收到很多用不上的冗余字段,拖慢首屏和交互。统一由代理层瘦身,可以减少带宽和前端解析压力。
在 Vue 3 中封装组合式数据源
前端最好不要在组件里直接调用 fetch,而是通过一个 useKnowledgeBase 组合式函数来管理查询状态。这个函数内部维护关键词、来源过滤、分页、加载状态和错误对象,对外只暴露 results、loading、error 以及 search 方法。组件拿到这些状态后,就可以专注于渲染和交互,完全不用感知上游平台差异。
相比直接使用 Pinia 或 Vuex,服务端查询状态更适合用组合式函数承载。因为知识库搜索本质上是一次远程请求,本地缓存、防抖、请求去重都属于请求层逻辑。只有需要在多个页面之间共享筛选条件时,才考虑把状态提升到 Pinia。下面的代码展示了基础封装,其中关键词变化后的防抖可以在组件侧再包一层。
import { ref, computed } from 'vue';
export function useKnowledgeBase() {
const keyword = ref('');
const source = ref('all');
const results = ref([]);
const loading = ref(false);
const error = ref(null);
async function search() {
loading.value = true;
error.value = null;
try {
const params = new URLSearchParams({
q: keyword.value,
source: source.value
});
const response = await fetch('/api/docs/search?' + params.toString());
if (!response.ok) {
throw new Error('知识库接口返回异常');
}
const data = await response.json();
results.value = data.results;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
const hasResult = computed(function () {
return results.value.length > 0;
});
return { keyword, source, results, loading, error, hasResult, search };
}这样做还有一个好处:当后端代理接口发生调整,或者知识库平台更换认证方式时,只需要改代理层和组合式函数内部,所有引用 useKnowledgeBase 的组件都不需要动。前端组件的稳定性会明显提升。
统一渲染节点与安全处理
拿到统一模型之后,就可以用 Vue 3 的动态组件或渲染函数来递归渲染内容。根组件遍历 blocks,根据 type 选择对应的输出方式。段落对应 <p>,标题根据 level 映射到 <h2>、<h3> 或 <h4>,代码块输出 <pre>。对于列表和表格,可以继续递归处理 children。渲染函数写起来比模板更直观,因为 type 是运行时才知道的字符串。
import { h } from 'vue';
const blockComponentMap = {
paragraph: function (block) {
return h('p', { class: 'kb-paragraph' }, renderInline(block.text));
},
heading: function (block) {
const tag = 'h' + Math.min(block.level || 2, 4);
return h(tag, { class: 'kb-heading' }, renderInline(block.text));
},
code: function (block) {
return h('pre', { class: 'kb-code' }, block.text);
}
};
function renderInline(text) {
return text;
}但要注意 Confluence 和 Notion 的富文本里都可能含有内联 HTML。不要在组件中使用 v-html 直接输出原始 HTML,否则容易引入 XSS 风险。合理的做法是只放行一组基础行内标签,例如 <strong>、<em>、<code> 和 <a>,其余标签全部转义为文本。这个过滤逻辑可以放在适配器转换阶段,也可以放在渲染函数的 renderInline 内部。
搜索、缓存与增量同步的落地细节
统一知识库一旦上线,搜索体验会直接影响使用频率。输入框组件层面应做 300 毫秒左右的防抖,并在请求前检查本地缓存。缓存 key 可以由查询词与来源过滤条件拼接生成,命中缓存后直接返回,避免重复请求。简单的 Map 缓存已经能覆盖大部分内部知识库场景,如果数据量很大,可以升级为 IndexedDB。
增量同步是另一个工程化重点。首次进入应用时拉取全量索引成本较高,可以根据文档的 updatedAt 字段维护本地游标。Confluence 支持使用 CQL 按 lastmodified 过滤,Notion 的 search 接口则结合 sort 和过滤条件按更新时间筛选。两边返回的时间格式并不一致,一个是 ISO 字符串,另一个也可能带时区偏移,适配器里应统一转换成时间戳或 UTC 字符串。
function debounce(fn, wait) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(null, args);
}, wait);
};
}权限边界也不容忽视。Confluence 的空间权限和 Notion 的页面分享状态并不完全相同,代理层调用上游接口时可能收到 403。此时应捕获错误并返回统一的 { code: 'FORBIDDEN', message: '当前账号无权访问此文档' } 结构,前端根据错误码展示降级提示,而不是直接抛出堆栈。只有把这些异常路径在工程化阶段处理清楚,知识库集成才不会变成难以维护的补丁集合。
Vue 3ConfluenceNotion修改时间:2026-09-27 10:18:34