[TOC]

代码示例1

1. 拦截a链接变成JS代码处理

Vue3 组件中用 v-html 渲染富文本,里面可能有 <a href="xxx"> 链接。问题是:

  • v-html 渲染的内容是「静态 HTML」,Vue 不会帮你自动绑定事件。
  • 所以点 <a> 默认就是直接跳转。

👉 要解决:需要在组件里用 事件代理 或者 手动绑定事件 来拦截 <a> 点击,再调用 Vue 方法。<a>链接里可能有参数(比如 query string),点击时拦截,解析参数,用参数做逻辑处理。

<template>
  <div ref="contentRef" v-html="htmlContent"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

const htmlContent = `
  <p>点我看看效果:
    <a href="https://example.com/page?userId=123&action=edit">自定义跳转</a>
  </p>
`
const contentRef = ref<HTMLElement | null>(null)

function handleLinkClick(e: Event) {
  const target = e.target as HTMLElement
  if (target.tagName.toLowerCase() === 'a') {
    e.preventDefault() // 阻止默认跳转
    const href = (target as HTMLAnchorElement).href
    if (href) {
      try {
        // 用 URL API 解析参数
        const url = new URL(href)
        const userId = url.searchParams.get('userId')
        const action = url.searchParams.get('action')

        console.log('拦截到链接:', href)
        console.log('参数 userId:', userId)
        console.log('参数 action:', action)

        // TODO: 这里写自定义处理逻辑
        alert(`处理参数:userId=${userId}, action=${action}`)
      } catch (err) {
        console.error('URL 解析失败:', err)
      }
    }
  }
}

onMounted(() => {
  contentRef.value?.addEventListener('click', handleLinkClick)
})

onBeforeUnmount(() => {
  contentRef.value?.removeEventListener('click', handleLinkClick)
})
</script>

2. 垂直高亮轮播切换效果

效果:

父组件代码:

<template>
  <el-container style="padding:24px;">
    <el-header style="display:flex;align-items:center;gap:12px;">
      <el-input v-model="title" placeholder="标题(仅示例)" style="width:240px" />
      <el-button @click="resetItems">重置数据</el-button>
      <el-button type="primary" @click="addItem">追加一项</el-button>
      <el-button type="danger" @click="removeItem" :disabled="items.length===0">删除末项</el-button>
      <div style="margin-left:auto;color:#cfe0ff">当前索引: {{ currentIndex }}</div>
    </el-header>

    <el-main style="display:flex;justify-content:center;align-items:flex-start;padding-top:20px;">
      <!-- 把数组传给子组件,并监听事件 -->
      <SeamlessLoopHighlight
        :items="items"
        @prev="onPrev"
        @next="onNext"
        @item-click="onItemClick"
      />
    </el-main>
  </el-container>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
import SeamlessLoopHighlight from './component/SeamlessLoopHighlight.vue'; // 根据你项目路径调整

const title = ref('示例:滚动高亮列表');

const initial = [
  {name: '虚拟内存1', value: 1},
  {name: '虚拟CPU', value: 2},
  {name: '虚拟存储', value: 3},
  {name: '虚拟IP', value: 4}, 
  {name: '虚拟网络', value: 5}, 
  {name: '虚拟磁盘', value: 6}, 
  {name: '虚拟GPU', value: 7}, 
  {name: '虚拟带宽', value: 8}, 
  {name: '虚拟安全组', value: 9}, 
  {name: '虚拟交换机', value: 10}, 
  {name: '虚拟防火墙', value: 11}, 
  {name: '虚拟网关', value: 12}, 
  {name: '虚拟负载均衡', value: 13}, 
  {name: '虚拟镜像11', value: 14}
] ;
type Item = { name: string; value: number };
const items = ref<Item[]>([...initial]);

// 如果你想保持父组件里的 currentIndex(展示用)
const currentIndex = ref(0);

function onPrev(a) {
  // 当子组件发出 prev 事件时触发(向上)
  // 这里我们仅演示如何接收事件并更新父状态
  currentIndex.value = (currentIndex.value - 1 + items.value.length) % (items.value.length || 1);
  // ElMessage({ message: '向上翻一项', type: 'info', duration: 800 });
  ElMessage({ message: a, type: 'info', duration: 800 });
}

function onNext() {
  currentIndex.value = (currentIndex.value + 1) % (items.value.length || 1);
  ElMessage({ message: '向下翻一项', type: 'success', duration: 800 });
}

function onItemClick(payload: { item: string; index: number }) {
  // payload 来自子组件 emit('item-click', { item, index })
  ElMessage({
    message: `点击项:${payload.item}(索引 ${payload.index})`,
    type: 'warning'
  });
  console.log('wozhixing ');
}

// 示范:父组件修改 items(子组件会响应 watch)
function addItem() {
  items.value.push({ name: `新项${items.value.length + 1}`, value: items.value.length + 1 });
}

function removeItem() {
  items.value.pop();
  if (currentIndex.value >= items.value.length) currentIndex.value = Math.max(0, items.value.length - 1);
}

function resetItems() {
  items.value = [...initial];
  currentIndex.value = 0;
}
</script>

<style scoped>
/* 仅作页面布局示例,可按需删改 */
.el-header { background: transparent; border-bottom: none; padding: 12px 0; }
.el-main { min-height: 360px; }
</style>

子组件:

<template>
  <div class="wrap">
    <el-button class="arrow" type="text" @click="onUp"></el-button>

    <div class="viewport">
      <div ref="listEl" class="list" :style="listStyle">
        <!-- 渲染 VISIBLE+2 个占位节点 -->
        <div v-for="(n, i) in nodesCount" :key="i" class="item" @click="onItemClick(i)">
          {{ visibleTexts[i]?.name }}{{ visibleTexts[i]?.value }}
        </div>
      </div>
    </div>

    <el-button class="arrow" type="text" @click="onDown"></el-button>
  </div>
</template>

<script lang="ts" setup>
import { ref, watch, computed, onMounted, nextTick, onBeforeUnmount } from 'vue';
import type { PropType } from 'vue';

// Props + Emits
type Item = { name: string; value: number };
const props = defineProps({
  items: { type: Array as PropType<Item[]>, default: () => [] }
});
const emit = defineEmits(['prev', 'next', 'item-click']);

// 常量
const ITEM_H = 60;
const VIEW_H = 300;
const VISIBLE = 5;
const CENTER = Math.floor(VISIBLE / 2);
const ANIM_MS = 360;

// DOM refs
const listEl = ref<HTMLElement | null>(null);

// 本地状态
const currentIndex = ref(0);
const isAnimating = ref(false);

// 为了实现跟原生实现一致,保持 VISIBLE+2 个节点
const nodesCount = VISIBLE + 2;

// 计算占位节点显示文本(根据 currentIndex 和 props.items)
const visibleTexts = computed(() => {
  const arr: (Item | null)[] = [];
  const n = props.items.length;
  for (let i = 0; i < nodesCount; i++) {
    const offset = i - (CENTER + 1);
    const idx = n ? ((currentIndex.value + offset) % n + n) % n : -1;
    arr.push(idx >= 0 ? props.items[idx] : null);
  }
  return arr;
});

// list 的 transform 样式(内联 style),用于动画
const baseTranslate = VIEW_H / 2 - ((CENTER + 1) * ITEM_H + ITEM_H / 2);
const translateY = ref(baseTranslate);
const transitioning = ref(false);

const listStyle = computed(() => ({
  transform: `translateY(${translateY.value}px)`,
  transition: transitioning.value ? `transform ${ANIM_MS}ms cubic-bezier(.2,.9,.2,1)` : 'none'
}));

// 更新高亮(字体大小 / 颜色 / opacity)
function updateHighlightStyles() {
  if (!listEl.value) return;
  const children = Array.from(listEl.value.children) as HTMLElement[];
  children.forEach((el, i) => {
    const offset = i - (CENTER + 1);
    const t = Math.max(0, 1 - Math.abs(offset));
    const fontSize = 20 + t * (28 - 20);
    const opacity = 0.6 + t * (1 - 0.6);
    const colorVal = Math.floor(153 + t * (255 - 153));
    el.style.fontSize = `${fontSize}px`;
    el.style.opacity = String(opacity);
    el.style.color = `rgb(${colorVal},${colorVal},${colorVal})`;
  });
}

// 设置 transform(带/不带动画)
function setTransform(y: number, animate = true) {
  transitioning.value = animate;
  translateY.value = y;
}

// 移动函数(step = +1 或 -1)
// 返回一个 Promise,在动画结束时 resolve,便于调用方得到最新高亮项
function move(step: number): Promise<void> {
  return new Promise((resolve) => {
    if (isAnimating.value) return resolve();
    if (!props.items.length) return resolve();

    isAnimating.value = true;
    currentIndex.value = ((currentIndex.value + step) % props.items.length + props.items.length) % props.items.length;

    console.log('move currentIndex.value: ', currentIndex.value );
    // 先更新文本和样式(computed visibleTexts 会变化)
    nextTick(() => {
      updateHighlightStyles();

      // 做动画:移动 step 高度
      setTransform(baseTranslate - step * ITEM_H, true);

      const onEnd = () => {
        if (!listEl.value) return;
        listEl.value.removeEventListener('transitionend', onEnd);

        // 恢复到 baseTranslate(无动画)
        setTransform(baseTranslate, false);
        console.log('我执行了');

        // 等一帧再解除 anim 标志并 resolve
        requestAnimationFrame(() => {
          isAnimating.value = false;
          resolve();
        });
      };

      if (listEl.value) {
        listEl.value.addEventListener('transitionend', onEnd);
        console.log('添加监听');
      } else {
        // 兜底
        setTimeout(() => { isAnimating.value = false; setTransform(baseTranslate, false); resolve(); }, ANIM_MS + 30);
      }
    });
  });
}

// 按钮事件(同时通知父组件)
async function onDown() {
  await move(1);
  // 将当前高亮项(当前 currentIndex 对应的 item)传回父组件
  const item = props.items[currentIndex.value];
  emit('next', item);
}
async function onUp() {
  await move(-1);
  const item = props.items[currentIndex.value];
  emit('prev', item);
}

// 项目点击
function onItemClick(nodeIndex: number) {
  // 计算真实索引
  const n = props.items.length;
  if (!n) return;
  const offset = nodeIndex - (CENTER + 1);
  const idx = ((currentIndex.value + offset) % n + n) % n;
  const item = props.items[idx];
  emit('item-click', item);
}

// 键盘监听
function onKeydown(e: KeyboardEvent) {
  if (e.key === 'ArrowDown') onDown();
  if (e.key === 'ArrowUp') onUp();
}

onMounted(() => {
  // 初始化 transform 与高亮
  setTransform(baseTranslate, false);
  nextTick(() => updateHighlightStyles());
  window.addEventListener('keydown', onKeydown);
});

onBeforeUnmount(() => {
  window.removeEventListener('keydown', onKeydown);
});

// 当父组件修改 items 时,重置 currentIndex 保证不越界并更新显示
watch(() => props.items, (newVal) => {
  if (!newVal || newVal.length === 0) return;
  currentIndex.value = currentIndex.value % newVal.length;
  nextTick(() => updateHighlightStyles());
});
</script>

<style lang="scss" scoped>
:root{
  --bg:#1f2b40;
}
.wrap{
  background-color: #1f2b40;
  text-align:center;
  user-select:none;
  display:flex;
  flex-direction:column;
  align-items:center;
}
.arrow{
  font-size:40px;
  color:#dfe7f6;
  cursor:pointer;
  margin:10px 0;
  -webkit-tap-highlight-color:transparent;
}
.viewport{
  width:320px;
  height:300px;
  overflow:hidden;
  margin:8px auto;
  position:relative;
}
.list{
  display:flex;
  flex-direction:column;
  align-items:center;
  will-change: transform;
}
.item{
  height:60px;
  line-height:60px;
  width:100%;
  text-align:center;
  font-size:20px;
  opacity:0.6;
  color:rgb(153,153,153);
  box-sizing:border-box;
  user-select:none;
  cursor:pointer;
  // display:flex;
  // justify-content:space-between;
  // padding: 0 20px;
  transition: font-size 0.36s cubic-bezier(.2,.9,.2,1),
              color 0.36s cubic-bezier(.2,.9,.2,1),
              opacity 0.36s cubic-bezier(.2,.9,.2,1);
}
// .item .name{
//   flex:1;
//   text-align:left;
// }
// .item .value{
//   width:48px;
//   text-align:right;
// }
</style>
Last Updated: 12/17/2025, 8:51:16 AM