news 2026/10/7 7:00:29

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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 = ref<HTMLElement | 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 class="e-w"> <div ref="targetRef" class="e-w-main" :class="{ 'e-w-expand': isExpand }" :style="{ height: isExpand ? 'auto' : collapsedHeight + 'px' }" > <div class="article-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-if="canExpand && !isExpand" class="view-more"> <div class="view-more-box" @click="toggle"> <el-icon color="#409EFC"><ArrowDown /></el-icon> </div> </div> <div v-if="canExpand && isExpand" class="has-more"> <div class="has-more-box" @click="toggle"> <el-icon color="#409EFC"><ArrowUp /></el-icon> </div> </div> </div> </div> </template> <script setup lang="ts"> 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 lang="less" 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; } } } } } </style>

max-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_URL=https://taotoken.net/api VITE_TAOTOKEN_API_KEY=sk-你的实际Key VITE_TAOTOKEN_MODEL=claude-sonnet-4-5

如果你用的是 TypeScript,在src/env.d.ts里补上类型声明,避免import.meta.env报红。

// src/env.d.ts /// <reference types="vite/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加 Key,Content-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 ): Promise<ArticleDetail> { 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 lang="ts"> 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 = ref<ArticleDetail | 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() </script>

watch里监听isExpand,展开且hasMore为真时自动请求下一页。这样用户点一次展开,既能看到已加载的内容,也能触发后续内容的加载。measure在数据更新后重新执行,保证canExpand的准确性。

4. 验证请求与展开效果

4.1 启动项目

在终端执行:

npm install npm run dev

Vite 默认跑在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?: Ref<boolean> } = {}) { 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 dev,Vite 不会热更新环境变量。

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.json,Cline 的 MCP 配置在插件设置里,格式各不相同,但核心都是这三个值。

5.5 展开后高度不对

如果展开后内容被截断,检查max-height的值是不是太小。3000px 对大多数文章够用,但如果你的正文里有很长的代码块或表格,可能需要调到 5000px 甚至更高。另一个可能是scrollHeight在图片加载前就测量了,导致canExpand判断错误。解决办法是在img的load事件里再调一次measure。

5.6 按钮不显示

先确认canExpand的值。在模板里临时加{{ canExpand }}打印出来。如果是false,检查collapsedHeight和实际内容高度。如果内容确实超出了但canExpand还是false,可能是targetRef没绑上,检查ref="targetRef"是否写在了正确的元素上,以及useExpand的返回值是否被正确解构。

6. 把折叠逻辑复用到评论区和问答区

useExpand封装好之后,复用成本非常低。评论区只需要把targetRef绑到评论列表容器上,collapsedHeight设成比如 300px,threshold设成 20px,其余逻辑完全不用改。问答区的答案折叠也是同理,甚至可以把collapsedHeight做成参数,不同区域传不同的值。

如果项目里折叠区域很多,建议把按钮也抽成一个ExpandButton组件,接收isExpand和canExpand两个 props,内部渲染对应的图标和点击事件。这样页面模板里只需要写一行<ExpandButton :is-expand="isExpand" :can-expand="canExpand" @toggle="toggle" />,视觉风格也统一。

接口层面,统一 Key 通道的价值在多处折叠联动时会体现得更明显。比如文章正文、评论、相关推荐三个区域都要调接口,如果每个接口用不同的 Key 和 Base URL,环境变量会变得很乱。统一之后,.env.local里只有一组配置,新增接口只需要改路径和参数,认证部分完全复用。

最后提醒一点:useExpand里的measure依赖 DOM 的真实高度,如果内容是通过v-if条件渲染的,切换显示后要手动调一次measure。可以在watch里监听内容变化,或者用ResizeObserver监听容器尺寸。ResizeObserver的兼容性现在已经很好,如果项目不需要兼容很老的浏览器,用它替代window.resize监听会更精准。

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

DAY6 CSS133-147182-183

DAY1→HTML1-29 DAY2→HTML29-53 DAY3→HTML&CSS53-79 DAY4→CSS79-108 DAY5→CSS108-133 接DAY5内容 56.浮动 &#xff08;二&#xff09;元素浮动后的特点 1.脱离文档流 2.无论浮动前是什么元素&#xff0c;浮动后的默认宽与高都是被内容撑开&#xff08;尽可能小&#…

作者头像 李华
网站建设 2026/10/7 6:58:30

金融科技专业本科生转售前顾问,2027届校招的技术和沟通要求

一、能做&#xff0c;而且金融科技背景在这行是“刚需配置”金融科技专业本科生做售前顾问&#xff0c;方向对得很准。成都萌想科技的金融科技售前管培生岗位&#xff0c;专业要求明确写了“金融、计算机、数学、工程、商科等相关专业优先”。深圳金证的售前工程师岗位也写了“…

作者头像 李华
网站建设 2026/10/7 6:56:59

嘉立创PCB产品介绍二维码制作教程(零废话)

文章目录前言视频教程前言 分享嘉立创 PCB 产品介绍二维码的完整制作流程。从 PCB 下单时添加二维码、调整尺寸和摆放位置&#xff0c;到收货后在官网后台编辑图文、图片、链接等内容都有详细说明。同时整理了实测遇到的编辑保存失败、视频压缩模糊等常见问题和对应的解决办法…

作者头像 李华
网站建设 2026/10/7 6:56:51

大模型 API 调用实战:从零掌握 LLM 应用开发的完整链路

大模型 API 调用实战&#xff1a;从零掌握 LLM 应用开发的完整链路 很多开发者的第一行 AI 代码是这样写出来的&#xff1a;照着文档复制一段调用示例&#xff0c;换一个 API Key&#xff0c;跑通一个 Hello World&#xff0c;然后就没有然后了。真正要把大模型能力嵌进自己的业…

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

开题报告别再硬憋:出入境管理专业的 AI 工具选择指南 [特殊字符]

如果你读的是法学 / 公安学类 / 出入境管理专业&#xff0c;大概率会遇到一种很典型的“开题焦虑”&#xff1a;题目看起来能写&#xff0c;但一动手就发现它同时牵涉移民管理、行政法、口岸执法、数据治理和地方政策。 比如毕业论文选题是&#xff1a; 《粤港澳大湾区口岸“合…

作者头像 李华
网站建设 2026/10/7 6:54:37

三菱伺服扭矩控制实战:从参数设置到线速度控制案例

1. 从“拧螺丝”到“精密装配”的认知跃迁很多人第一次接触伺服电机&#xff0c;脑子里蹦出来的画面就是“高级一点的电机”&#xff0c;觉得它能调速、能定位&#xff0c;比普通异步电机强。但真正在产线上摸爬滚打过几年的人会告诉你&#xff0c;伺服系统的灵魂根本不在“转不…

作者头像 李华