ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Vue3+TS简单设计一个查看文章时点击展开和点击收起的小功能|TaoToken 统一 Key 通道实践

基于Vue3+TS简单设计一个查看文章时点击展开和点击收起的小功能|TaoToken 统一 Key 通道实践 1. 文章详情页的展开收起为什么值得单独封装做文章详情页的时候长文折叠几乎是绕不开的需求。用户点进一篇文章如果正文直接铺满三屏评论区、相关推荐、作者信息全被顶到下面阅读节奏会很乱。常见的做法是默认只露出摘要区域高度固定底部加一个渐隐遮罩和一个向下箭头点击后展开全文箭头翻转向上再点一次收起。这个交互看起来简单但真正写起来有几个坑。第一内容高度是动态的你不能写死一个max-height否则短文章也会出现「展开」按钮长文章展开后可能被截断。第二展开和收起的过渡动画如果直接对height: auto做 transition浏览器是不认的动画会失效。第三评论区的展开逻辑和正文折叠逻辑高度相似如果每个组件都复制一遍isExpand和scrollHeight判断维护成本会越来越高。所以这篇的重点不是「写一个能点的按钮」而是用 Vue3 的组合式 API 把「判断是否需要折叠 切换展开状态 暴露给模板」这套逻辑抽成一个可复用的useExpand。正文折叠、评论展开、问答详情、商品参数只要结构是「固定高度容器 超出隐藏」都能直接复用。技术栈就是 Vue3 TypeScript script setup样式用 Less图标用 Element Plus 的ArrowDown/ArrowUp。如果你项目里没装 Element Plus把图标换成内联 SVG 也完全不影响核心逻辑。整篇文章会从组件结构、组合式函数封装、过渡动画配置一直讲到通过统一 Key 通道调用接口、把展开状态和后端返回的hasMore字段联动起来的完整验证流程。你可以跟着一步步敲也可以直接把代码块复制到自己的项目里改。2. 用 useExpand 组合式函数封装折叠逻辑2.1 先想清楚状态该放在哪最直觉的写法是把isExpand和ewRef都写在页面组件里onMounted里判断一次scrollHeight clientHeight然后模板里绑定点击事件。这种写法在只有一个折叠区域时没问题但一旦页面上出现第二个、第三个折叠区域你就会发现每个组件都要重复一遍ref、onMounted、isExpand而且判断逻辑散落在各处改一个阈值要改好几个文件。组合式函数的思路是把「一个可折叠区域」当成一个独立单元。它需要知道三件事容器元素是谁、当前是否展开、内容是否真的超出了可视高度。对外暴露的接口也很清晰targetRef绑定到容器、isExpand控制状态、canExpand表示是否需要显示按钮、toggle用来切换。这样页面组件只负责「把 ref 绑上去」和「把按钮画出来」逻辑全部收进useExpand。2.2 完整代码useExpand.ts在src/composables/useExpand.ts新建文件。这里用ref而不是reactive因为模板里要直接解构使用ref在script setup中会自动解包写起来更顺手。// src/composables/useExpand.ts import { ref, onMounted, onBeforeUnmount, nextTick, type Ref } from vue export interface UseExpandOptions { /** 折叠时容器的高度单位 px默认 150 */ collapsedHeight?: number /** 内容超出多少像素才显示展开按钮默认 0即只要超出就显示 */ threshold?: number /** 初始是否展开 */ defaultExpand?: boolean } export function useExpand(options: UseExpandOptions {}) { const { collapsedHeight 150, threshold 0, defaultExpand false, } options // 绑定到需要折叠的容器 DOM const targetRef refHTMLElement | null(null) // 当前是否展开 const isExpand ref(defaultExpand) // 内容是否真的超出决定要不要渲染按钮 const canExpand ref(false) // 计算内容是否超出可视高度 const measure () { const el targetRef.value if (!el) return // scrollHeight 是内容真实高度clientHeight 是当前可见高度 const overflow el.scrollHeight - collapsedHeight canExpand.value overflow threshold } const toggle () { if (!canExpand.value) return isExpand.value !isExpand.value } const expand () { if (canExpand.value) isExpand.value true } const collapse () { isExpand.value false } // 监听窗口尺寸变化避免响应式布局下判断失效 const handleResize () { measure() } onMounted(async () { await nextTick() measure() window.addEventListener(resize, handleResize) }) onBeforeUnmount(() { window.removeEventListener(resize, handleResize) }) return { targetRef, isExpand, canExpand, toggle, expand, collapse, measure, } }这里有几个细节值得说。measure放在nextTick之后执行是因为onMounted触发时 DOM 已经挂载但如果内容里有异步渲染的图片或代码块scrollHeight可能还没稳定。实际项目里如果正文是接口返回的富文本建议在数据赋值后再手动调一次measure。threshold参数留出来是为了应对「内容只超出一点点不值得显示按钮」的场景比如超出 20px 以内就不显示避免按钮和内容挤在一起。2.3 组件里怎么用新建src/views/Example/ExpandToggle/index.vue。模板结构分三层外层容器负责定位内层e-w-main是真正被折叠的区域底部按钮根据canExpand和isExpand切换图标。template div classe-w div reftargetRef classe-w-main :class{ e-w-expand: isExpand } :style{ height: isExpand ? auto : collapsedHeight px } div classarticle-body b什么是 Vite它与 Vue CLI 有什么区别Volar 又是啥/b p Vite 是一个轻量级、速度极快的构建工具对 Vue SFC 提供第一优先级支持 作者是尤雨溪同时也是 Vue 的作者。 /p p Vue CLI 是官方提供的基于 Webpack 的 Vue 工具链目前处于维护模式。 新项目建议使用 Vite除非你依赖特定的 Webpack 特性。 /p p Volar 是 Vue 的 VS Code 插件也是官方 IDE/TS 支持工具 取代了 Vue 2 时代的 Vetur。在 Vue 3 项目中请确保禁用 Vetur。 /p p 这段内容故意写长一些用来触发折叠效果。实际项目中这里通常是接口返回的 富文本长度不可控所以必须用 scrollHeight 动态判断。 /p /div div v-ifcanExpand !isExpand classview-more div classview-more-box clicktoggle el-icon color#409EFCArrowDown //el-icon /div /div div v-ifcanExpand isExpand classhas-more div classhas-more-box clicktoggle el-icon color#409EFCArrowUp //el-icon /div /div /div /div /template script setup langts import { ArrowUp, ArrowDown } from element-plus/icons-vue import { useExpand } from /composables/useExpand const collapsedHeight 150 const { targetRef, isExpand, canExpand, toggle } useExpand({ collapsedHeight, threshold: 10, }) /script注意:style里用了isExpand ? auto : collapsedHeight px。展开时高度设为auto是为了让内容自然撑开避免写死高度导致长文被截断。收起时回到固定高度配合overflow: hidden实现裁剪。2.4 样式与过渡动画样式部分沿用 Less重点是底部按钮的定位和渐隐遮罩。展开和收起用 CSS transition 做高度过渡但前面说了height: auto不能直接过渡所以这里用max-height方案收起时max-height等于折叠高度展开时给一个足够大的值。style langless scoped .e-w { width: auto; padding: 40px 100px; .e-w-main { position: relative; overflow: hidden; border: 1px solid #ddd; border-radius: 6px; transition: max-height 0.35s ease; max-height: 150px; .e-w-expand { max-height: 3000px; } .article-body { padding: 22px; color: rgb(96, 109, 121); background-color: #f5ecd7; font-family: 楷体, serif; line-height: 1.8; } .view-more { width: 100%; height: 22px; padding-top: 60px; background-image: linear-gradient( -180deg, rgba(255, 255, 255, 0) 0%, #ebebf6 100% ); position: absolute; bottom: 0; .view-more-box { width: 44px; height: 22px; background-color: #fff; border-top-left-radius: 8px; border-top-right-radius: 8px; position: absolute; left: 0; right: 0; bottom: 0; margin: auto; cursor: pointer; .el-icon { position: absolute; left: 0; right: 0; bottom: 0; margin: auto; } } } .has-more { width: 100%; height: 22px; position: absolute; bottom: 0; .has-more-box { width: 44px; height: 22px; background-color: #fff; border-top-left-radius: 8px; border-top-right-radius: 8px; position: absolute; left: 0; right: 0; bottom: 0; margin: auto; cursor: pointer; .el-icon { position: absolute; left: 0; right: 0; bottom: 0; margin: auto; } } } } } /stylemax-height从 150px 过渡到 3000px视觉上会有个「先快后慢」的效果因为过渡的是max-height而不是真实高度。如果追求更顺滑的动画可以用el-collapse-transition或者手动测量scrollHeight后设置具体像素值。日常项目里 3000px 足够覆盖绝大多数文章超过这个长度的内容本身也不适合一次性展开。3. 通过统一 Key 通道调用接口并联动展开状态3.1 为什么这里要接接口前面的折叠逻辑是纯前端的内容写死在模板里。但真实场景中文章详情页的正文和评论都是接口返回的而且后端通常会返回一个hasMore字段告诉你「还有没有更多内容」。这时候展开按钮的显示就不能只靠scrollHeight判断还要结合接口返回的状态。比如评论区分页第一页返回 10 条评论hasMore: true点击「展开更多」时再去请求第二页追加到列表里。如果只靠scrollHeight第一页内容可能没超出容器高度按钮不显示用户就永远看不到后面的评论。所以useExpand需要支持外部传入的canExpand覆盖或者暴露一个方法让调用方手动设置。这里我用 TaoToken 的统一 Key 通道来演示接口调用。它的好处是多个模型、多个服务的调用都走同一个 Base URL 和同一个 Key不用在项目里维护一堆不同的 endpoint 和密钥。对于前端项目来说配置项越少环境变量越干净。3.2 可复制的配置片段在项目根目录新建.env.local写入以下内容。注意VITE_前缀是 Vite 读取环境变量的要求没有这个前缀的变量不会暴露给客户端。# .env.local VITE_TAOTOKEN_BASE_URLhttps://taotoken.net/api VITE_TAOTOKEN_API_KEYsk-你的实际Key VITE_TAOTOKEN_MODELclaude-sonnet-4-5如果你用的是 TypeScript在src/env.d.ts里补上类型声明避免import.meta.env报红。// src/env.d.ts /// reference typesvite/client / interface ImportMetaEnv { readonly VITE_TAOTOKEN_BASE_URL: string readonly VITE_TAOTOKEN_API_KEY: string readonly VITE_TAOTOKEN_MODEL: string } interface ImportMeta { readonly env: ImportMetaEnv }Key 的获取在控制台的 API Keys 页面登录后新建一个即可。注意不要把 Key 提交到 Git.env.local默认在.gitignore里确认一下别被覆盖。3.3 封装请求函数在src/api/article.ts里写一个请求函数。这里用原生fetch不额外引 axios减少依赖。请求头里Authorization用Bearer加 KeyContent-Type固定application/json。// src/api/article.ts export interface ArticleDetail { id: string title: string content: string hasMore: boolean nextCursor?: string } const BASE_URL import.meta.env.VITE_TAOTOKEN_BASE_URL const API_KEY import.meta.env.VITE_TAOTOKEN_API_KEY const MODEL import.meta.env.VITE_TAOTOKEN_MODEL export async function fetchArticleDetail( articleId: string, cursor?: string ): PromiseArticleDetail { const res await fetch(${BASE_URL}/v1/article/detail, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${API_KEY}, }, body: JSON.stringify({ model: MODEL, articleId, cursor: cursor ?? , }), }) if (!res.ok) { const errText await res.text() throw new Error(请求失败 ${res.status}: ${errText}) } const data await res.json() return { id: data.id, title: data.title, content: data.content, hasMore: data.has_more ?? false, nextCursor: data.next_cursor, } }这里把model也放进请求体是因为统一通道下不同模型的路由由这个字段决定。实际业务里如果后端是自己的服务model字段可以去掉只保留articleId和cursor。3.4 在组件里联动回到index.vue把写死的内容换成接口数据并让canExpand同时受scrollHeight和hasMore控制。script setup langts import { ref, watch } from vue import { ArrowUp, ArrowDown } from element-plus/icons-vue import { useExpand } from /composables/useExpand import { fetchArticleDetail, type ArticleDetail } from /api/article const article refArticleDetail | null(null) const loading ref(false) const { targetRef, isExpand, canExpand, toggle, measure } useExpand({ collapsedHeight: 150, threshold: 10, }) async function loadArticle(cursor?: string) { loading.value true try { const data await fetchArticleDetail(1001, cursor) if (cursor) { // 追加模式用于评论展开更多 article.value { ...data, content: (article.value?.content ?? ) data.content, } } else { article.value data } // 数据更新后重新测量高度 await measure() } finally { loading.value false } } // 展开时如果还有更多内容自动拉取下一页 watch(isExpand, (val) { if (val article.value?.hasMore) { loadArticle(article.value.nextCursor) } }) loadArticle() /scriptwatch里监听isExpand展开且hasMore为真时自动请求下一页。这样用户点一次展开既能看到已加载的内容也能触发后续内容的加载。measure在数据更新后重新执行保证canExpand的准确性。4. 验证请求与展开效果4.1 启动项目在终端执行npm install npm run devVite 默认跑在http://localhost:5173。打开浏览器进入文章详情页路由比如/example/expand-toggle。如果控制台没有报错页面应该显示折叠后的正文和底部的向下箭头。4.2 用 curl 先验证接口通不通在写前端联动之前建议先用 curl 确认 Key 和 Base URL 没问题。打开终端把下面的 Key 换成你自己的curl -X POST https://taotoken.net/api/v1/article/detail \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的实际Key \ -d {model:claude-sonnet-4-5,articleId:1001,cursor:}如果返回类似下面的 JSON说明通道正常{ id: 1001, title: Vue3 折叠组件实践, content: 正文内容..., has_more: true, next_cursor: c_002 }如果返回 401说明 Key 不对或者没带Bearer前缀。如果返回 404检查一下路径是不是写成了/api/v1/...Base URL 已经包含/api请求路径里不要再重复。4.3 浏览器里看效果回到页面点击底部向下箭头。预期行为是容器高度从 150px 平滑过渡到内容真实高度箭头变成向上同时如果hasMore为真网络面板里会多一条请求返回的下一页内容追加到正文后面。再点一次向上箭头容器收回到 150px箭头变回向下。打开 DevTools 的 Network 面板筛选article/detail确认请求头里Authorization存在响应状态 200。如果请求发出去了但页面没更新检查watch里的loadArticle是否被正确调用以及article.value的赋值是否触发了响应式更新。4.4 验证展开状态与接口的联动一个容易忽略的点是当接口返回的hasMore为false时即使内容超出了 150px展开按钮也应该显示因为用户需要看到完整内容。而当hasMore为true但当前内容没超出时按钮也应该显示因为点击后要加载更多。所以canExpand的最终值应该是scrollHeight 超出 || hasMore。在useExpand里加一个外部控制参数export function useExpand(options: UseExpandOptions { forceCanExpand?: Refboolean } {}) { const { forceCanExpand } options // ... const measure () { const el targetRef.value if (!el) return const overflow el.scrollHeight - collapsedHeight canExpand.value overflow threshold || (forceCanExpand?.value ?? false) } // ... }组件里传入forceCanExpand: computed(() article.value?.hasMore ?? false)这样接口状态和 DOM 测量就统一了。5. 常见报错与排查5.1 401 Unauthorized这是最常见的。先确认.env.local里的 Key 没有多余空格Authorization头的格式是Bearer sk-xxx中间一个空格。如果 Key 是从控制台复制的注意不要带上引号。改完.env.local后必须重启npm run devVite 不会热更新环境变量。5.2 local proxy failed如果你在vite.config.ts里配了server.proxy把/api代理到别的地址而请求又走了VITE_TAOTOKEN_BASE_URL两者会冲突。检查一下是不是同时存在代理配置和完整 URL。用统一通道时直接请求完整地址即可不需要再配代理。如果确实需要代理把VITE_TAOTOKEN_BASE_URL改成/api然后在 proxy 里转发到https://taotoken.net。5.3 reading choices 报错这个报错通常出现在你按 OpenAI 的响应格式去解析但实际返回结构不同。统一通道下不同模型的响应字段可能不一样。稳妥的做法是先console.log(data)看真实结构再决定取哪个字段。如果返回的是流式响应res.json()会直接报错需要改用res.body.getReader()逐块读取。5.4 OAuth 相关报错如果你用的是 Claude Code 或 Codex 这类工具可能会遇到 OAuth 认证失败。这类工具通常需要单独的配置文件比如 Codex 的auth.json里面要写全三件套Base URL、API Key、Model ID。缺任何一个都会导致认证失败。Claude Code 的配置在~/.claude/settings.jsonCline 的 MCP 配置在插件设置里格式各不相同但核心都是这三个值。5.5 展开后高度不对如果展开后内容被截断检查max-height的值是不是太小。3000px 对大多数文章够用但如果你的正文里有很长的代码块或表格可能需要调到 5000px 甚至更高。另一个可能是scrollHeight在图片加载前就测量了导致canExpand判断错误。解决办法是在img的load事件里再调一次measure。5.6 按钮不显示先确认canExpand的值。在模板里临时加{{ canExpand }}打印出来。如果是false检查collapsedHeight和实际内容高度。如果内容确实超出了但canExpand还是false可能是targetRef没绑上检查reftargetRef是否写在了正确的元素上以及useExpand的返回值是否被正确解构。6. 把折叠逻辑复用到评论区和问答区useExpand封装好之后复用成本非常低。评论区只需要把targetRef绑到评论列表容器上collapsedHeight设成比如 300pxthreshold设成 20px其余逻辑完全不用改。问答区的答案折叠也是同理甚至可以把collapsedHeight做成参数不同区域传不同的值。如果项目里折叠区域很多建议把按钮也抽成一个ExpandButton组件接收isExpand和canExpand两个 props内部渲染对应的图标和点击事件。这样页面模板里只需要写一行ExpandButton :is-expandisExpand :can-expandcanExpand toggletoggle /视觉风格也统一。接口层面统一 Key 通道的价值在多处折叠联动时会体现得更明显。比如文章正文、评论、相关推荐三个区域都要调接口如果每个接口用不同的 Key 和 Base URL环境变量会变得很乱。统一之后.env.local里只有一组配置新增接口只需要改路径和参数认证部分完全复用。最后提醒一点useExpand里的measure依赖 DOM 的真实高度如果内容是通过v-if条件渲染的切换显示后要手动调一次measure。可以在watch里监听内容变化或者用ResizeObserver监听容器尺寸。ResizeObserver的兼容性现在已经很好如果项目不需要兼容很老的浏览器用它替代window.resize监听会更精准。
返回列表