文章摘要
加载中...|
此内容根据文章生成,并经过人工审核,仅用于文章内容的解释与总结

L站佬友启发,用开源库写了一个 3D 动力学双链关系图谱 (双链点群)。

安装依赖:
bash
npm install 3d-force-graph

1. 静态网络提取与 WebGL 渲染

  • 提取双链:在静态构建期分析各文章对 /posts/YYYY/MMDD 的交叉引用关系,输出 references 双链映射。
  • 动态 WebGL 渲染:利用 3d-force-graph 在客户端进行 3D 动力学粒子网络模拟。为保证服务端渲染(SSR)打包安全,该插件必须在 onMounted 生命周期内使用 await import 动态引入。

2. 极佳无障碍与高对比度交互设计

为了防止用户探索图谱时产生 3D 眩晕,系统默认关闭背景自动旋转,并提供微缩自旋/重置控制台。 为了在黑暗背景和不同主题色下展现完美质感,悬浮选中通知卡片采用了极低反光底板与高对比度配色组合:

  • 卡片底色:使用了 rgba(15, 15, 26, 0.95) 深度拟态毛玻璃。
  • 外边框与光晕:废除了杂乱的紫色发光,统一采用亮丽的 电网青(Cyan) rgba(0, 229, 255, 0.35) 边框和微弱发光阴影。
  • 字体高反差:将状态 badge 设为 #00e5ff 电网青,将引导操作提示 .action-hint 改为醒目温暖的 暖阳金黄 (#ffe082),各元素在暗底色下拥有极其完美的清晰度和视觉无障碍可读性。
scss
/* 选中节点顶部悬浮卡片高反差调优样式 */
.toast-inner {
  background: rgba(15, 15, 26, 0.95);
  border: 1px solid rgba(0, 229, 255, 0.35);
  box-shadow: 0 8px 32px rgba(0, 229, 255, 0.2);
  
  .badge {
    color: #00e5ff;
    background: rgba(0, 229, 255, 0.12);
  }
  .node-name {
    color: #ffffff;
  }
  .action-hint {
    color: #ffe082;
  }
}

3. 3D 点群关系图谱组件完整代码 (PointGroup.vue)

点击查看 PointGroup.vue 完整代码
vue
// .vitepress/theme/views/PointGroup.vue
<template>
  <div class="point-group-page s-card">
    <!-- 顶栏标题区 -->
    <div class="page-header">
      <div class="header-text">
        <h1 class="title">双链点群关系图</h1>
        <p class="subtitle">
          交互式 3D 动力学网络结构。绿色轨迹呈递文章双向链接(双链)流通路径,圆点联动邻点聚焦。
        </p>
      </div>
      
      <!-- 移动端专用的切换标签页 -->
      <div class="mobile-tabs-header">
        <button 
          class="tab-btn" 
          :class="{ active: activeMobileTab === 'graph' }" 
          @click="activeMobileTab = 'graph'"
        >
          <svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
            <circle cx="6" cy="12" r="2"></circle>
            <circle cx="18" cy="6" r="2"></circle>
            <circle cx="18" cy="18" r="2"></circle>
            <path d="m8 11 8-4"></path>
            <path d="m8 13 8 4"></path>
          </svg>
          3D 点群图谱
        </button>
        <button 
          class="tab-btn" 
          :class="{ active: activeMobileTab === 'list' }" 
          @click="activeMobileTab = 'list'"
        >
          <i class="iconfont icon-toc"></i>
          目录文字索引
        </button>
      </div>
    </div>

    <!-- 主体内容布局 -->
    <div class="page-body">
      <!-- 左侧:目录文字索引面板 (桌面端常驻,移动端在 activeMobileTab === 'list' 时显示) -->
      <aside class="list-panel" :class="{ 'mobile-show': activeMobileTab === 'list', 'mobile-hidden': activeMobileTab !== 'list' }">
        <div class="panel-header">
          <h3>目录索引</h3>
          <span class="count">共 {{ postData.length }} 篇文章</span>
        </div>
        
        <div class="tree-shell">
          <ul class="tree-root">
            <!-- 独立文章 (无分类) -->
            <li class="tree-group" v-if="rootPosts.length > 0">
              <div class="group-header no-collapse">
                <i class="iconfont icon-star"></i>
                <span class="group-title">独立文章</span>
              </div>
              <ul class="group-children">
                <li v-for="post in rootPosts" :key="post.id" class="tree-item" :class="{ active: currentActiveNode === 'article:' + post.id }">
                  <a @click="handleArticleClick(post)" class="item-link">
                    {{ post.title }}
                  </a>
                </li>
              </ul>
            </li>

            <!-- 分类文章 -->
            <li v-for="(catInfo, catName) in categoriesData" :key="catName" class="tree-group">
              <div class="group-header" @click="toggleGroup(catName)">
                <i class="iconfont" :class="collapsedGroups[catName] ? 'icon-right' : 'icon-down'"></i>
                <i class="iconfont icon-folder"></i>
                <span class="group-title">{{ catName }}</span>
                <span class="group-count">{{ catInfo.articles.length }}</span>
              </div>
              <ul v-show="!collapsedGroups[catName]" class="group-children">
                <li v-for="post in catInfo.articles" :key="post.id" class="tree-item" :class="{ active: currentActiveNode === 'article:' + post.id }">
                  <a @click="handleArticleClick(post)" class="item-link">
                    {{ post.title }}
                  </a>
                </li>
              </ul>
            </li>
          </ul>
        </div>
      </aside>

      <!-- 右侧:3D 图谱渲染区 (桌面端常驻,移动端在 activeMobileTab === 'graph' 时显示) -->
      <main class="canvas-panel" :class="{ 'mobile-show': activeMobileTab === 'graph', 'mobile-hidden': activeMobileTab !== 'graph' }">
        <!-- 3D Canvas 容器 -->
        <div ref="graphContainerRef" class="graph-container-element"></div>

        <!-- 选中节点浮动卡片 (点击跳转) -->
        <Transition name="slide-up">
          <div 
            v-if="selectedNode && selectedNode.type === 'article'" 
            class="selected-node-toast"
            @click="handleArticleJump(selectedNode)"
            title="点击阅读全文"
          >
            <div class="toast-inner">
              <span class="badge">📄 当前选中</span>
              <span class="node-name">{{ selectedNode.label }}</span>
              <span class="action-hint">点击进入 ➔</span>
            </div>
          </div>
        </Transition>

        <!-- 3D 动力学图谱浮动控制栏 -->
        <div class="graph-controls">
          <div class="btn-group">
            <button class="ctrl-btn" :class="{ active: autoRotate }" @click="toggleAutoRotate" title="开/关自旋转">
              <i class="iconfont icon-refresh"></i>
              <span>自旋</span>
            </button>
            <button class="ctrl-btn" @click="resetCamera" title="重置相机视角">
              <i class="iconfont icon-home"></i>
              <span>重置</span>
            </button>
          </div>

          <!-- 3D 点色说明 -->
          <div class="legends">
            <div class="legend-item"><span class="dot root-dot"></span><span>核心</span></div>
            <div class="legend-item"><span class="dot cat-dot"></span><span>分类</span></div>
            <div class="legend-item"><span class="dot art-dot"></span><span>文章</span></div>
            <div class="legend-item"><span class="dot ref-dot"></span><span>双链连线</span></div>
          </div>
        </div>
      </main>
    </div>
  </div>
</template>

<script setup>
const router = useRouter();
const { theme } = useData();

// 切换标签页 (仅在移动端起效)
const activeMobileTab = ref("graph");

// 获取文章和分类的依赖数据
const postData = computed(() => theme.value.postData || []);
const categoriesData = computed(() => theme.value.categoriesData || {});

// 没有分类的文章
const rootPosts = computed(() => {
  return postData.value.filter(post => !post.categories || post.categories.length === 0);
});

// 折叠控制
const collapsedGroups = ref({});
const toggleGroup = (catName) => {
  collapsedGroups.value[catName] = !collapsedGroups.value[catName];
};

// 3D 动力学属性
const graphContainerRef = ref(null);
const autoRotate = ref(false); // 默认关闭自旋转以支持更好的手动探索
const currentActiveNode = ref(null);
const selectedNode = ref(null); // 当前选中节点

let graphInstance = null;
let rotationTimer = null;

// 配色配置 (融合了 HSL Premium 暗色主题的深邃视觉)
const COLORS = {
  root: "#ff4d6d",
  category: "#00d2ff",
  article: "#9c27b0",
  structure: "rgba(128, 128, 128, 0.45)",
  reference: "#39ff14" // 荧光绿双向引用
};

// 格式化 3D 节点和连线数据
const prepareGraphData = () => {
  const nodes = [];
  const links = [];

  // 1. 核心 root
  nodes.push({ id: "root", label: "点群主节点", type: "root", color: COLORS.root, size: 8 });

  const pathToId = {};

  // 2. 文件夹/分类节点
  Object.keys(categoriesData.value).forEach((catName) => {
    const catId = `category:${catName}`;
    nodes.push({ id: catId, label: catName, type: "category", color: COLORS.category, size: 5.5 });
    links.push({ source: "root", target: catId, kind: "structure" });
  });

  // 3. 文章节点
  postData.value.forEach((post) => {
    const articleId = `article:${post.id}`;
    pathToId[post.regularPath] = articleId;

    nodes.push({
      id: articleId,
      label: post.title,
      type: "article",
      color: COLORS.article,
      size: 4,
      route: post.regularPath
    });

    if (post.categories && post.categories.length > 0) {
      post.categories.forEach((catName) => {
        links.push({ source: `category:${catName}`, target: articleId, kind: "structure" });
      });
    } else {
      links.push({ source: "root", target: articleId, kind: "structure" });
    }
  });

  // 4. 双链引用线
  postData.value.forEach((post) => {
    const sourceId = `article:${post.id}`;
    if (post.references && post.references.length > 0) {
      post.references.forEach((refPath) => {
        const targetId = pathToId[refPath];
        if (targetId && targetId !== sourceId) {
          links.push({ source: sourceId, target: targetId, kind: "reference" });
        }
      });
    }
  });

  return { nodes, links };
};

// 初始化 3D 渲染图谱
const init3DGraph = async () => {
  if (typeof window === "undefined" || !graphContainerRef.value) return;

  // 避免重复初始化
  if (graphInstance) return;

  try {
    const { default: ForceGraph3D } = await import("3d-force-graph");
    const graphData = prepareGraphData();

    graphInstance = ForceGraph3D()(graphContainerRef.value)
      .graphData(graphData)
      .backgroundColor("rgba(0,0,0,0)") // 透明底层,融合卡片磨砂效果
      .showNavInfo(false)
      .cooldownTicks(80)
      
      // 节点大小及颜色
      .nodeRelSize(1)
      .nodeVal(node => node.size)
      .nodeColor(node => node.color)
      .nodeLabel(node => {
        const icon = node.type === 'root' ? '💡 ' : node.type === 'category' ? '📂 ' : '📄 ';
        return `<div class="graph-tooltip"><strong>${icon}${node.label}</strong></div>`;
      })

      // 连线配置
      .linkWidth(link => link.kind === "reference" ? 1.8 : 1.2)
      .linkColor(link => link.kind === "reference" ? COLORS.reference : COLORS.structure)
      
      // 双链连线发光流动粒子效果
      .linkDirectionalParticles(link => link.kind === "reference" ? 3 : 0)
      .linkDirectionalParticleSpeed(0.01)
      .linkDirectionalParticleWidth(1.6)
      .linkDirectionalParticleColor(() => COLORS.reference)
      
      // 节点交互与跳转逻辑
      .onNodeClick((node) => {
        if (node.type === 'article') {
          if (selectedNode.value && selectedNode.value.id === node.id) {
            handleArticleJump(node);
          } else {
            selectedNode.value = node;
            focusOnNode(node);
          }
        } else {
          selectedNode.value = null;
          focusOnNode(node);
        }
      })
      .onBackgroundClick(() => {
        selectedNode.value = null;
        currentActiveNode.value = null;
      });

    resetCamera();
    startSelfRotation();

  } catch (error) {
    console.error("加载 3d-force-graph 图谱模块失败", error);
  }
};

// 自旋算法
const startSelfRotation = () => {
  if (!graphInstance) return;
  stopSelfRotation();
  
  let angle = 0;
  const radius = 280;
  
  rotationTimer = setInterval(() => {
    if (!autoRotate.value) return;
    angle += 0.0025;
    graphInstance.cameraPosition({
      x: radius * Math.sin(angle),
      z: radius * Math.cos(angle)
    });
  }, 25);
};

const stopSelfRotation = () => {
  if (rotationTimer) {
    clearInterval(rotationTimer);
    rotationTimer = null;
  }
};

const toggleAutoRotate = () => {
  autoRotate.value = !autoRotate.value;
  if (autoRotate.value) {
    startSelfRotation();
  } else {
    stopSelfRotation();
  }
};

// 相机聚焦节点
const focusOnNode = (node) => {
  if (!graphInstance) return;
  
  currentActiveNode.value = node.id;
  
  if (autoRotate.value) {
    stopSelfRotation();
  }

  const distance = 85;
  const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
  
  graphInstance.cameraPosition(
    { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio },
    node,
    2000
  );

  setTimeout(() => {
    if (autoRotate.value) {
      startSelfRotation();
    }
  }, 3000);
};

// 树形列表中的文章点击
const handleArticleClick = (post) => {
  if (graphInstance) {
    const nodeId = `article:${post.id}`;
    const myData = graphInstance.graphData();
    const node = myData.nodes.find(n => n.id === nodeId);
    if (node) {
      focusOnNode(node);
      // 在页面上,如果是本页,可以平滑定位,也可以直接跳转
      setTimeout(() => {
        router.go(post.regularPath);
      }, 1500);
    } else {
      router.go(post.regularPath);
    }
  } else {
    router.go(post.regularPath);
  }
};

// 触发跳转至文章页面
const handleArticleJump = (node) => {
  if (node && node.route) {
    router.go(node.route);
  }
};

const resetCamera = () => {
  if (!graphInstance) return;
  currentActiveNode.value = null;
  graphInstance.cameraPosition(
    { x: 0, y: 0, z: 280 },
    { x: 0, y: 0, z: 0 },
    1500
  );
};

const destroyGraph = () => {
  stopSelfRotation();
  if (graphInstance) {
    try {
      if (graphContainerRef.value) {
        graphContainerRef.value.innerHTML = "";
      }
      graphInstance = null;
    } catch (e) {
      console.warn("销毁 3D 图谱失败", e);
    }
  }
};

// 在组件可见且在移动端标签切换时处理图谱实例的激活
watch(activeMobileTab, (newVal) => {
  if (newVal === "graph") {
    nextTick(() => {
      init3DGraph();
    });
  } else {
    destroyGraph();
  }
});

onMounted(() => {
  nextTick(() => {
    // 桌面端或者移动端默认为 graph 时初始化
    if (activeMobileTab.value === "graph") {
      init3DGraph();
    }
  });
});

onBeforeUnmount(() => {
  destroyGraph();
});
</script>

<style lang="scss" scoped>
.point-group-page {
  width: 100%;
  display: flex;
  flex-direction: column;
  background-color: var(--main-card-background);
  border: 1px solid var(--main-card-border);
  box-shadow: 0 12px 32px -4px var(--main-border-shadow);
  border-radius: 16px;
  padding: 1.5rem 2rem;
  margin-top: 1rem;
  animation: fade-up 0.6s backwards;

  @media (max-width: 768px) {
    padding: 1rem;
    border-radius: 12px;
  }
}

.page-header {
  border-bottom: 1px solid var(--main-card-border);
  padding-bottom: 1.2rem;
  margin-bottom: 1.5rem;
  display: flex;
  justify-content: space-between;
  align-items: flex-end;

  @media (max-width: 768px) {
    flex-direction: column;
    align-items: stretch;
    gap: 12px;
    padding-bottom: 0.8rem;
    margin-bottom: 1rem;
  }

  .header-text {
    .title {
      font-size: 1.8rem;
      font-weight: 800;
      color: var(--main-font-color);
      margin: 0 0 6px 0;
      letter-spacing: -0.01em;
    }
    .subtitle {
      font-size: 0.9rem;
      color: var(--main-font-second-color);
      margin: 0;
      opacity: 0.8;
      max-width: 680px;
      line-height: 1.5;
    }
  }
}

/* 移动端专用的标签页切换头部 */
.mobile-tabs-header {
  display: none;
  background: var(--main-card-second-background);
  border: 1px solid var(--main-card-border);
  border-radius: 8px;
  padding: 3px;
  gap: 4px;

  @media (max-width: 768px) {
    display: flex;
  }

  .tab-btn {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    background: none;
    border: none;
    padding: 8px 12px;
    font-size: 12px;
    font-weight: 600;
    color: var(--main-font-second-color);
    border-radius: 6px;
    cursor: pointer;
    transition: all 0.25s;

    .icon-svg {
      width: 14px;
      height: 14px;
    }
    .iconfont {
      font-size: 14px;
    }

    &.active {
      background: var(--main-color);
      color: var(--main-card-background);
      box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
    }
  }
}

.page-body {
  width: 100%;
  height: 600px;
  display: flex;
  gap: 1.5rem;
  overflow: hidden;

  @media (max-width: 768px) {
    height: 520px;
    gap: 0;
  }
}

/* 左侧列表面板 */
.list-panel {
  width: 300px;
  border: 1px solid var(--main-card-border);
  border-radius: 12px;
  background-color: var(--main-card-second-background);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  transition: all 0.3s ease;

  @media (max-width: 768px) {
    width: 100%;
    border: none;
    background-color: transparent;
    
    &.mobile-hidden {
      display: none;
    }
    &.mobile-show {
      display: flex;
    }
  }

  .panel-header {
    padding: 1rem 1.2rem;
    border-bottom: 1px solid var(--main-card-border);
    display: flex;
    justify-content: space-between;
    align-items: center;

    h3 {
      font-size: 13px;
      font-weight: 700;
      margin: 0;
      color: var(--main-font-color);
    }
    .count {
      font-size: 11px;
      color: var(--main-font-second-color);
      opacity: 0.6;
    }
  }

  .tree-shell {
    flex: 1;
    overflow-y: auto;
    padding: 0.8rem;

    &::-webkit-scrollbar {
      width: 4px;
    }
    &::-webkit-scrollbar-thumb {
      background: var(--main-card-border);
      border-radius: 4px;
    }
  }
}

.tree-root {
  list-style: none;
  padding: 0;
  margin: 0;
}

.tree-group {
  margin-bottom: 0.6rem;

  .group-header {
    display: flex;
    align-items: center;
    padding: 6px 10px;
    border-radius: 6px;
    cursor: pointer;
    font-size: 13px;
    font-weight: 600;
    color: var(--main-font-color);
    transition: background-color 0.25s;

    &:hover {
      background-color: var(--main-card-border);
    }

    .iconfont {
      font-size: 13px;
      margin-right: 6px;
      opacity: 0.8;
    }
    
    .icon-right, .icon-down {
      font-size: 10px;
      width: 10px;
    }

    .group-title {
      flex: 1;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    .group-count {
      font-size: 10px;
      background: var(--main-card-border);
      padding: 1px 5px;
      border-radius: 8px;
      color: var(--main-font-second-color);
    }
  }

  .no-collapse {
    cursor: default;
    &:hover {
      background: none;
    }
  }
}

.group-children {
  list-style: none;
  padding-left: 20px;
  margin: 2px 0 0 0;
}

.tree-item {
  margin: 3px 0;

  .item-link {
    display: block;
    padding: 5px 10px;
    border-radius: 5px;
    font-size: 12px;
    color: var(--main-font-second-color);
    cursor: pointer;
    transition: all 0.2s;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;

    &:hover {
      background: var(--main-card-border);
      color: var(--main-color);
    }
  }

  &.active .item-link {
    background: var(--main-card-border);
    color: var(--main-color);
    font-weight: 600;
  }
}

/* 右侧画布面板 */
.canvas-panel {
  flex: 1;
  border: 1px solid var(--main-card-border);
  border-radius: 12px;
  background-color: var(--main-card-second-background);
  position: relative;
  overflow: hidden;

  @media (max-width: 768px) {
    width: 100%;
    border: none;
    border-radius: 8px;
    background-color: var(--main-card-second-background);

    &.mobile-hidden {
      display: none;
    }
    &.mobile-show {
      display: block;
    }
  }
}

.graph-container-element {
  width: 100%;
  height: 100%;
  outline: none;
}

/* 控制覆层 */
.graph-controls {
  position: absolute;
  bottom: 16px;
  left: 16px;
  right: 16px;
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  pointer-events: none;
  z-index: 10;

  @media (max-width: 768px) {
    bottom: 8px;
    left: 8px;
    right: 8px;
    flex-direction: column;
    align-items: stretch;
    gap: 8px;
  }

  .btn-group {
    display: flex;
    gap: 6px;
    pointer-events: auto;
  }

  .ctrl-btn {
    background: var(--main-card-background);
    border: 1px solid var(--main-card-border);
    color: var(--main-font-color);
    padding: 6px 12px;
    border-radius: 20px;
    font-size: 11px;
    font-weight: 600;
    display: flex;
    align-items: center;
    gap: 5px;
    cursor: pointer;
    transition: all 0.25s;

    .iconfont {
      font-size: 12px;
    }

    &:hover {
      background: var(--main-card-border);
      transform: translateY(-1px);
    }

    &.active {
      background: var(--main-color);
      color: var(--main-card-background);
      border-color: var(--main-color);
    }
  }

  .legends {
    background: var(--main-card-background);
    border: 1px solid var(--main-card-border);
    padding: 8px 12px;
    border-radius: 10px;
    display: flex;
    gap: 12px;
    pointer-events: auto;

    @media (max-width: 768px) {
      gap: 6px;
      padding: 6px 10px;
      flex-wrap: wrap;
      justify-content: space-between;
    }

    .legend-item {
      display: flex;
      align-items: center;
      gap: 5px;
      font-size: 10.5px;
      color: var(--main-font-second-color);
    }

    .dot {
      width: 7px;
      height: 7px;
      border-radius: 50%;
      display: inline-block;
    }

    .root-dot { background-color: var(--COLORS-root, #ff4d6d); }
    .cat-dot { background-color: var(--COLORS-cat, #00d2ff); }
    .art-dot { background-color: var(--COLORS-art, #9c27b0); }
    .ref-dot {
      background-color: var(--COLORS-ref, #39ff14);
      box-shadow: 0 0 4px var(--COLORS-ref, #39ff14);
    }
  }
}

/* 选中节点顶部悬浮通知栏 (Premium 磨砂及粒子呼吸灯特效) */
.selected-node-toast {
  position: absolute;
  top: 20px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 20;
  pointer-events: auto;
  cursor: pointer;
  animation: pulse-glow 2s infinite alternate;

  @media (max-width: 768px) {
    top: 12px;
    width: 90%;
  }
}

.toast-inner {
  display: flex;
  align-items: center;
  gap: 12px;
  background: rgba(15, 15, 26, 0.95);
  border: 1px solid rgba(0, 229, 255, 0.35);
  backdrop-filter: blur(20px);
  padding: 8px 18px;
  border-radius: 30px;
  box-shadow: 0 8px 32px rgba(0, 229, 255, 0.2);
  transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);

  @media (max-width: 768px) {
    padding: 6px 14px;
    gap: 8px;
    justify-content: space-between;
  }

  &:hover {
    background: rgba(25, 25, 40, 0.95);
    border-color: rgba(0, 229, 255, 0.6);
    box-shadow: 0 10px 40px rgba(0, 229, 255, 0.35);
    transform: translateY(-2px);
  }

  .badge {
    font-size: 10px;
    font-weight: 700;
    color: #00e5ff;
    background: rgba(0, 229, 255, 0.12);
    padding: 2px 8px;
    border-radius: 20px;
    white-space: nowrap;
  }

  .node-name {
    font-size: 13px;
    font-weight: 700;
    color: #ffffff;
    max-width: 250px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;

    @media (max-width: 768px) {
      max-width: 150px;
    }
  }

  .action-hint {
    font-size: 11px;
    font-weight: 600;
    color: #ffe082;
    white-space: nowrap;
    animation: bounce-right 1s infinite alternate;
  }
}

@keyframes pulse-glow {
  0% {
    box-shadow: 0 0 12px rgba(0, 229, 255, 0.12);
  }
  100% {
    box-shadow: 0 0 24px rgba(0, 229, 255, 0.3);
  }
}

@keyframes bounce-right {
  0% { transform: translateX(0); }
  100% { transform: translateX(5px); }
}

.slide-up-enter-active,
.slide-up-leave-active {
  transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide-up-enter-from {
  opacity: 0;
  transform: translate(-50%, -24px);
}
.slide-up-leave-to {
  opacity: 0;
  transform: translate(-50%, -24px);
}
</style>
评论 隐私政策