使用 Vue 3 + Intersection Observer 打造智慧型目錄組件:支援動態高亮與平滑滾動

作者: Calpa Liu
字數:3345
出版:2025 年 4 月 19 日
分類: 前端開發 Vue.js Intersection Observer技術文章
想提升長篇文章或技術文件的閱讀體驗?本篇教你如何使用 Vue 3 的 Composition API 結合 Intersection Observer 打造智慧型 TOC 組件,實現動態高亮當前段落、平滑滾動跳轉、支援 h2/h3 層級與響應式設計,全面強化你的網站導航與用戶體驗。

為何需要動態目錄?

在面對長篇技術文件、部落格文章或教學內容時,用戶最常見的困擾就是迷失方向感:不知道目前閱讀到哪、接下來會看到什麼、還剩多少內容。這時,一個具備即時互動性的「動態目錄(Dynamic TOC)」就顯得格外重要。

相較於靜態目錄只能提供基礎的章節跳轉功能,動態目錄具備「上下文感知能力」,能根據使用者滾動行為即時更新當前所在的段落,並以視覺方式高亮顯示。這種即時反饋不僅提升導覽體驗,也幫助讀者建立對文章結構的整體理解。對於結構複雜、層級分明的技術內容來說,這一點尤其關鍵。

動態目錄還能顯著提升網站的專業感與使用者體驗。無論是滑鼠滾動還是觸控裝置上的滑動行為,都能維持同步更新的目錄狀態,讓用戶隨時知道自己的位置與上下文邏輯。這種體驗上的細膩設計,往往是用戶判斷網站品質與可信度的重要指標之一。

值得注意的是,傳統的實作方法通常依賴於 scroll 事件監聽,這會導致效能瓶頸:每次滾動都會觸發大量的事件處理,增加主線程壓力,進而影響畫面更新與互動流暢度。這種情況在移動裝置或低階設備上表現尤其明顯。因此,採用效能更佳、非同步觀察機制的 Intersection Observer API,成為動態目錄實作中的理想解法。

Intersection Observer API 基礎

Intersection Observer API 是一個由瀏覽器原生支援的強大功能,專門用來監測元素是否進入或離開某個容器(通常是視口)的可視區域。這個 API 能夠非同步地追蹤元素的可見性變化,因此非常適合用於懶加載圖片、無限滾動、滾動觸發動畫,甚至像本文這樣的智慧型目錄組件。它的設計理念,是為了取代過去需要頻繁監聽 scroll 事件並手動計算元素位置的做法,從而提供一個更高效、更穩定的觀察解法。

與傳統的滾動事件相比,Intersection Observer 具備四個明顯優勢。首先,它是非同步執行的,不會阻塞主線程,這對於效能敏感的應用(特別是移動裝置)非常重要。其次,它的回調只會在元素進入或離開指定的可見區域時觸發,避免了每次滾動都重複執行回調的開銷。第三,開發者可以設定交叉比例(threshold),精確定義在多少可見範圍下觸發邏輯,這比傳統判斷元素是否在視口中更靈活。最後,它的參數配置相當彈性,可以自定義觀察容器(root)、邊界(rootMargin)與觸發條件(threshold),滿足各種使用情境。

在實作上,我們會接觸到幾個重要概念。root 是用來當作觀察參考的容器,預設為瀏覽器的視口;rootMargin 則是設定根容器的內外邊界,能讓你提早或延遲觸發判斷;threshold 是一個範圍在 0 到 1 的數值,代表目標元素需有多少部分進入可視區才會觸發事件;而 isIntersecting 則是每次觀察結果中的布林值,表示該元素是否真的出現在觀察區域內。這些概念組合起來,就構成了一個高效、現代化的滾動觀察機制。

基本使用方式

要創建一個 Intersection Observer 實例,我們需要先定義一些選項:

// 創建 Intersection Observer 實例
const options = {
  root: null, // 使用視口作為參考
  rootMargin: '0px', // 無邊界調整
  threshold: 0.1 // 當目標元素 10% 可見時觸發
};

const callback = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('元素已進入視口');
    } else {
      console.log('元素已離開視口');
    }
  });
};

const observer = new IntersectionObserver(callback, options);

// 開始觀察目標元素
const target = document.querySelector('#target-element');
observer.observe(target);

使用 Vue 3 Composition API 構建 TOC 組件

Vue 3 的 Composition API 提供了更靈活的邏輯組織方式,非常適合實現複雜的交互功能。讓我們利用 <script setup> 語法來構建我們的 TOC 組件。

基本結構

首先,讓我們創建一個 TableOfContents.vue 組件:

<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';

// 存放標題元素的引用
const headingElements = ref([]);
// 當前活躍的標題 ID
const activeHeadingId = ref('');
// Intersection Observer 實例
const observer = ref(null);

// 在組件掛載後初始化
onMounted(() => {
  // 收集所有標題元素
  collectHeadings();
  // 初始化 Intersection Observer
  initIntersectionObserver();
});

// 組件卸載前清理
onBeforeUnmount(() => {
  // 停止觀察所有元素
  disconnectObserver();
});

// 實現相關方法...
</script>

<template>
  <nav class="table-of-contents">
    <h2>目錄</h2>
    <ul>
      <li 
        v-for="heading in headingElements" 
        :key="heading.id"
        :class="{ active: heading.id === activeHeadingId }"
      >
        <a :href="`#${heading.id}`">{{ heading.textContent }}</a>
      </li>
    </ul>
  </nav>
</template>

<style lang="scss" scoped>
.table-of-contents {
  // 樣式定義...
}
</style>

收集文章標題元素

首先,我們需要收集頁面中的所有標題元素,我們選擇文章內容區域的 h2 和 h3 元素:

const collectHeadings = () => {
  // 選擇頁面中所有的 h2 和 h3 元素
  const headings = Array.from(document.querySelectorAll('.article-content h2, .article-content h3'));
  
  // 確保每個標題都有一個 ID(用於目錄項目的跳轉)
  headings.forEach((heading, index) => {
    if (!heading.id) {
      heading.id = `heading-${index}`;
    }
  });
  
  // 儲存收集到的標題元素
  headingElements.value = headings.map(heading => ({
    id: heading.id,
    textContent: heading.textContent,
    level: parseInt(heading.tagName.substring(1)), // 獲取標題級別(2 表示 h2,3 表示 h3)
    element: heading
  }));
};

因此,我們的 headingElements 將包含所有文章標題的 ID、文本內容、層級和 DOM 元素。

初始化 Intersection Observer

接下來,我們初始化 Intersection Observer 來觀察這些標題元素:

const initIntersectionObserver = () => {
  // 設定觀察選項
  const options = {
    root: null, // 使用視口作為參考
    rootMargin: '-100px 0px -70% 0px', // 調整觀察區域,使其大約在視口頂部
    threshold: [0, 0.25, 0.5, 0.75, 1] // 多個閾值,獲取更精確的交叉信息
  };
  
  // 建立回調函數
  const intersectionCallback = (entries) => {
    // 找到當前可見的標題
    const visibleHeadings = entries
      .filter(entry => entry.isIntersecting)
      .map(entry => entry.target.id);
    
    if (visibleHeadings.length > 0) {
      // 如果有多個可見標題,選擇第一個作為當前活躍標題
      activeHeadingId.value = visibleHeadings;
    } else if (entries.length > 0) {
      // 如果沒有可見標題,嘗試確定最接近視口的標題
      determineActiveHeadingBasedOnPosition(entries);
    }
  };
  
  // 創建 observer 實例
  observer.value = new IntersectionObserver(intersectionCallback, options);
  
  // 開始觀察所有標題元素
  headingElements.value.forEach(heading => {
    observer.value.observe(heading.element);
  });
};

這樣,我們就成功建立了基本的動態目錄功能,能夠即時反映當前閱讀位置。

確定當前活躍標題

當沒有標題完全可見時,我們需要一個策略來確定哪個標題應該被標記為活躍:

const determineActiveHeadingBasedOnPosition = (entries) => {
  // 根據元素在頁面中的位置確定活躍標題
  // 簡單方案:選擇最靠近視口頂部的標題
  
  const viewportHeight = window.innerHeight;
  const viewportTop = window.scrollY;
  const viewportBottom = viewportTop + viewportHeight;
  
  let closestHeading = null;
  let closestDistance = Infinity;
  
  entries.forEach(entry => {
    const rect = entry.boundingClientRect;
    const elementTop = rect.top + viewportTop;
    
    // 計算元素與視口頂部的距離
    const distance = Math.abs(elementTop - viewportTop);
    
    if (distance  {
  if (observer.value) {
    observer.value.disconnect();
    observer.value = null;
  }
};

透過這個策略,我們可以確保即使沒有標題完全可見,也能準確地確定當前活躍的標題。

停止觀察

在組件卸載前,我們需要確保停止觀察所有標題元素,以避免記憶體泄漏:

const disconnectObserver = () => {
  if (observer.value) {
    observer.value.disconnect();
    observer.value = null;
  }
};

處理實際應用中的問題

在實際應用中,我們還需要考慮一些重要的細節和邊緣情況。

實現防抖處理

為了避免過於頻繁的更新,我們可以實現一個防抖函數:

const debounce = (func, wait) => {
  let timeout;
  return function(...args) {
    const context = this;
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(context, args);
    }, wait);
  };
};

// 使用防抖包裝我們的回調
const debouncedIntersectionCallback = debounce((entries) => {
  // 處理交叉事件...
}, 50);

為了確保在滾動過程中不會頻繁觸發,我們使用了防抖處理。

處理窗口大小變化

當窗口大小變化時,我們可能需要重新初始化觀察器:

onMounted(() => {
  // ... 其他初始化代碼
  
  // 監聽窗口大小變化
  const handleResize = debounce(() => {
    // 斷開並重新初始化觀察器
    disconnectObserver();
    initIntersectionObserver();
  }, 200);
  
  window.addEventListener('resize', handleResize);
  
  // 記得在卸載時移除事件監聽器
  onBeforeUnmount(() => {
    window.removeEventListener('resize', handleResize);
    disconnectObserver();
  });
});

處理移動設備的特殊考慮

在移動設備上,我們可能需要調整一些參數來提供更好的用戶體驗:

const initIntersectionObserver = () => {
  // 檢測是否為移動設備
  const isMobile = window.innerWidth < 768;
  
  const options = {
    root: null,
    // 在移動設備上使用更大的 margin,因為螢幕更小
    rootMargin: isMobile ? '-60px 0px -60% 0px' : '-100px 0px -70% 0px',
    threshold: isMobile ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1] // 在移動設備上使用更少的閾值
  };
  
  // ... 後續代碼
};

點擊目錄項目時的行為

當用戶點擊目錄項目時,我們希望能夠平滑滾動到對應的標題,並更新活躍標題:

<template>
  <nav class="table-of-contents">
    <h2>目錄</h2>
    <ul>
      <li 
        v-for="heading in headingElements" 
        :key="heading.id"
        :class="{ active: heading.id === activeHeadingId }"
      >
        <a 
          :href="`#${heading.id}`" 
          @click.prevent="scrollToHeading(heading.id)"
        >
          {{ heading.textContent }}
        </a>
      </li>
    </ul>
  </nav>
</template>

<script setup>
// ... 其他代碼

const scrollToHeading = (id) => {
  const element = document.getElementById(id);
  if (element) {
    // 設置平滑滾動
    element.scrollIntoView({ behavior: 'smooth' });
    // 直接更新活躍標題,而不等待 Intersection Observer 回調
    activeHeadingId.value = id;
  }
};
</script>

使用 Vue 的 TransitionGroup 添加動畫效果

為了增強用戶體驗,我們可以使用 Vue 的 <TransitionGroup> 組件為目錄項目添加動畫效果:

<template>
  <nav class="table-of-contents">
    <h2>目錄</h2>
    <TransitionGroup name="toc-item" tag="ul">
      <li 
        v-for="heading in headingElements" 
        :key="heading.id"
        :class="{ 
          active: heading.id === activeHeadingId,
          'indent-level-2': heading.level === 2,
          'indent-level-3': heading.level === 3
        }"
      >
        <a 
          :href="`#${heading.id}`" 
          @click.prevent="scrollToHeading(heading.id)"
        >
          {{ heading.textContent }}
        </a>
      </li>
    </TransitionGroup>
  </nav>
</template>

<style lang="scss" scoped>
.table-of-contents {
  position: sticky;
  top: 2rem;
  max-height: calc(100vh - 4rem);
  overflow-y: auto;
  padding: 1rem;
  border-left: 2px solid #eaeaea;
  
  h2 {
    margin-top: 0;
    margin-bottom: 1rem;
    font-size: 1.2rem;
  }
  
  ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }
  
  li {
    margin: 0.5rem 0;
    transition: all 0.3s ease;
    
    &.indent-level-2 {
      margin-left: 0;
    }
    
    &.indent-level-3 {
      margin-left: 1rem;
      font-size: 0.95em;
    }
    
    &.active {
      a {
        color: #3498db;
        font-weight: bold;
        transform: translateX(5px);
      }
    }
    
    a {
      color: #666;
      text-decoration: none;
      display: inline-block;
      transition: all 0.3s ease;
      
      &:hover {
        color: #3498db;
      }
    }
  }
}

// TransitionGroup 動畫
.toc-item-enter-active,
.toc-item-leave-active {
  transition: all 0.5s ease;
}

.toc-item-enter-from,
.toc-item-leave-to {
  opacity: 0;
  transform: translateX(-20px);
}

.toc-item-move {
  transition: transform 0.5s ease;
}
</style>

這樣的動態目錄功能,不僅提高了用戶體驗,還提供了更好的可訪問性,讓文章結構更加清晰。

完整組件代碼

讓我們整合以上所有功能,創建一個完整的動態 TOC 組件:

<script setup>
import { ref, onMounted, onBeforeUnmount, watchEffect } from 'vue';

// 存放標題元素的引用
const headingElements = ref([]);
// 當前活躍的標題 ID
const activeHeadingId = ref('');
// Intersection Observer 實例
const observer = ref(null);
// 配置選項
const props = defineProps({
  contentSelector: {
    type: String,
    default: '.article-content'
  },
  headingSelector: {
    type: String,
    default: 'h2, h3'
  },
  offset: {
    type: Number,
    default: 100
  },
  mobileBreakpoint: {
    type: Number,
    default: 768
  }
});

// 防抖函數
const debounce = (func, wait) => {
  let timeout;
  return function(...args) {
    const context = this;
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(context, args);
    }, wait);
  };
};

// 收集標題元素
const collectHeadings = () => {
  const selector = `${props.contentSelector} ${props.headingSelector}`;
  const headings = Array.from(document.querySelectorAll(selector));
  
  headings.forEach((heading, index) => {
    if (!heading.id) {
      heading.id = `heading-${index}`;
    }
  });
  
  headingElements.value = headings.map(heading => ({
    id: heading.id,
    textContent: heading.textContent,
    level: parseInt(heading.tagName.substring(1)),
    element: heading
  }));
};

// 初始化 Intersection Observer
const initIntersectionObserver = () => {
  const isMobile = window.innerWidth < props.mobileBreakpoint;
  
  const options = {
    root: null,
    rootMargin: isMobile 
      ? `-${props.offset / 2}px 0px -60% 0px` 
      : `-${props.offset}px 0px -70% 0px`,
    threshold: isMobile ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1]
  };
  
  const intersectionCallback = (entries) => {
    // 找到當前可見的標題
    const visibleHeadings = entries
      .filter(entry => entry.isIntersecting)
      .map(entry => entry.target.id);
    
    if (visibleHeadings.length > 0) {
      // 如果有多個可見標題,選擇第一個
      activeHeadingId.value = visibleHeadings[0];
    } else if (entries.length > 0) {
      // 如果沒有可見標題,確定最接近的標題
      determineActiveHeadingBasedOnPosition(entries);
    }
  };
  
  // 使用防抖包裝回調函數
  const debouncedCallback = debounce(intersectionCallback, 50);
  
  observer.value = new IntersectionObserver(debouncedCallback, options);
  
  // 開始觀察所有標題元素
  headingElements.value.forEach(heading => {
    observer.value.observe(heading.element);
  });
};

// 確定當前活躍標題
const determineActiveHeadingBasedOnPosition = (entries) => {
  const viewportHeight = window.innerHeight;
  const viewportTop = window.scrollY;
  
  let closestHeading = null;
  let closestDistance = Infinity;
  
  entries.forEach(entry => {
    const rect = entry.boundingClientRect;
    const elementTop = rect.top + viewportTop;
    const distance = Math.abs(elementTop - viewportTop);
    
    if (distance < closestDistance) {
      closestDistance = distance;
      closestHeading = entry.target.id;
    }
  });
  
  if (closestHeading) {
    activeHeadingId.value = closestHeading;
  }
};

// 滾動到特定標題
const scrollToHeading = (id) => {
  const element = document.getElementById(id);
  if (element) {
    // 計算目標滾動位置,考慮 offset
    const offsetPosition = element.offsetTop - props.offset;
    
    // 平滑滾動到位置
    window.scrollTo({
      top: offsetPosition,
      behavior: 'smooth'
    });
    
    // 直接更新活躍標題
    activeHeadingId.value = id;
  }
};

// 斷開觀察器連接
const disconnectObserver = () => {
  if (observer.value) {
    observer.value.disconnect();
    observer.value = null;
  }
};

// 處理窗口大小變化
const handleResize = debounce(() => {
  disconnectObserver();
  initIntersectionObserver();
}, 200);

// 組件掛載時初始化
onMounted(() => {
  collectHeadings();
  initIntersectionObserver();
  
  // 監聽窗口大小變化
  window.addEventListener('resize', handleResize);
});

// 組件卸載前清理
onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize);
  disconnectObserver();
});
</script>

<template>
  <aside class="table-of-contents" v-if="headingElements.length > 0">
    <h2>目錄</h2>
    <TransitionGroup name="toc-item" tag="ul">
      <li 
        v-for="heading in headingElements" 
        :key="heading.id"
        :class="{ 
          active: heading.id === activeHeadingId,
          'indent-level-2': heading.level === 2,
          'indent-level-3': heading.level === 3
        }"
      >
        <a 
          :href="`#${heading.id}`" 
          @click.prevent="scrollToHeading(heading.id)"
        >
          {{ heading.textContent }}
        </a>
      </li>
    </TransitionGroup>
  </aside>
</template>

<style lang="scss" scoped>
.table-of-contents {
  position: sticky;
  top: 2rem;
  max-height: calc(100vh - 4rem);
  overflow-y: auto;
  padding: 1.5rem;
  border-left: 2px solid #eaeaea;
  background-color: rgba(255, 255, 255, 0.8);
  backdrop-filter: blur(5px);
  border-radius: 0.5rem;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
  
  @media (max-width: 768px) {
    position: relative;
    top: 0;
    max-height: none;
    margin-bottom: 2rem;
  }
  
  h2 {
    margin-top: 0;
    margin-bottom: 1rem;
    font-size: 1.2rem;
    color: #333;
  }
  
  ul {
    list-style: none;
    padding: 0;
    margin: 0;
  }
  
  li {
    margin: 0.75rem 0;
    transition: all 0.3s ease;
    position: relative;
    
    &.indent-level-2 {
      margin-left: 0;
    }
    
    &.indent-level-3 {
      margin-left: 1.5rem;
      font-size: 0.95em;
      
      &::before {
        content: '';
        position: absolute;
        left: -1rem;
        top: 0.6em;
        height: 1px;
        width: 0.5rem;
        background-color: #ddd;
      }
    }
    
    &.active {
      transform: translateX(5px);
      
      a {
        color: #3498db;
        font-weight: 600;
      }
      
      &::before {
        content: '';
        position: absolute;
        left: -1.5rem;
        top: 0;
        height: 100%;
        width: 3px;
        background-color: #3498db;
        border-radius: 3px;
      }
    }
    
    a {
      color: #666;
      text-decoration: none;
      display: inline-block;
      transition: all 0.25s ease;
      padding: 0.2rem 0;
      
      &:hover {
        color: #3498db;
      }
    }
  }
}

// TransitionGroup 動畫
.toc-item-enter-active,
.toc-item-leave-active {
  transition: all 0.5s ease;
}

.toc-item-enter-from,
.toc-item-leave-to {
  opacity: 0;
  transform: translateX(-20px);
}

.toc-item-move {
  transition: transform 0.5s ease;
}
</style>

使用方法

在你的 Vue 應用中使用這個組件非常簡單:

<template>
  <div class="article-layout">
    <article class="article-content">
      <!-- 你的文章內容,包含 h2 和 h3 標題 -->
      <h1>文章標題</h1>
      <p>導言段落...</p>
      
      <h2 id="section-1">第一部分</h2>
      <p>內容...</p>
      
      <h3 id="section-1-1">子部分 1.1</h3>
      <p>內容...</p>
      
      <h2 id="section-2">第二部分</h2>
      <p>內容...</p>
      
      <!-- 更多內容... -->
    </article>
    
    <TableOfContents 
      contentSelector=".article-content"
      headingSelector="h2, h3"
      :offset="80"
    />
  </div>
</template>

<script setup>
import TableOfContents from '@/components/TableOfContents.vue';
</script>

<style scoped>
.article-layout {
  display: grid;
  grid-template-columns: 1fr 300px;
  gap: 2rem;
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

@media (max-width: 768px) {
  .article-layout {
    grid-template-columns: 1fr;
  }
}

.article-content {
  /* 文章內容樣式 */
}
</style>

結論

如果你也在為技術文章、部落格或文件平台打造更佳的閱讀體驗,Vue 3 的 Composition API 結合 Intersection Observer API,正是一個高效又現代化的解法。這種組合不僅能打造具備滾動感知、高亮當前段落與平滑跳轉的智慧型 TOC 元件,還能讓你的網站更具專業感與可讀性。

與傳統的 scroll 事件相比,Intersection Observer 採用非同步觀察機制,大幅降低效能負擔,同時提供更精準的觸發時機。搭配 Composition API 的邏輯抽象能力,不僅讓開發更模組化、可維護,也能讓整體程式碼結構更加清晰。

另外,Intersection Observer 其實還能應用在延遲加載圖片、無限捲動、滾動動畫觸發等多種互動場景。如果你對這些實作也有興趣,歡迎留言或私訊我,我會在後續文章中繼續深入探索。別忘了將這篇文章分享給關心內容體驗的朋友,一起把技術作品推向更高層次!

前端開發 Vue.js Intersection Observer 技術文章
關於 Calpa

Calpa 擅長使用 TypeScriptReact.jsVue.js 建立 Responsive Website。

他積極參與開源社區,曾在 2019 年的香港開源大會上擔任講者,提供工作經驗和見解。此外,他也在 GitHub 上公開分享個人博客程式碼,已獲得超過 300 顆星星和 60 個分支的支持。

他熱愛學習新技術,並樂意分享經驗。他相信,唯有不斷學習才能跟上快速演變的技術環境。

熱門文章

最新文章

圖片管理中心
管理圖片資源
IP 查詢
快速查詢和定位 IP 地址的地理位置和相關信息
Python 運行器
無需後端、無需登入,只需打開瀏覽器即可運行 Python 代碼(由 Pyodide 提供支持)
封面圖生成器
自動創建適合各種平台的文章封面圖
原作(青山剛昌)產生器
一鍵創建原作(青山剛昌)的封面圖
日本色彩
探索和使用傳統日本色彩
部落格內容洞察儀表板
以視覺化儀表板方式追蹤文章成效、分享熱度與分類分布,協助創作者掌握內容表現。
蒙特卡羅估算 π
使用蒙特卡羅方法演示 π 值的估算過程
LLM
使用 LLM 模型進行聊天
活動圖生成器
一鍵創建活動的封面圖
Wagmi Card
一鍵創建 Wagmi 的封面圖
Facebook Quote
Facebook Quote
Music Macro Language (MML) Studio
用程式語法編寫旋律,用音符構築想像
Blurhash
一鍵創建 Blurhash
文字分類器
使用 MediaPipe TextClassifier 分類文字
前端工程師免費工具資源
前端工程師免費工具資源
後端工程師免費工具資源
後端工程師免費工具資源
全端工程師免費工具資源
全端工程師免費工具資源
Web3 工程師免費工具資源
Web3 工程師免費工具資源
紫微斗數排盤系統|結合 AI 的命盤性格與事業財務分析生成器
紫微斗數排盤工具,輸入生日與時辰,自動生成完整命盤分析提示(Prompt)。結合最專業紫微理論與 AI 助力,助你深入解析性格、事業、財務與人際課題。免費使用,適合命理師及紫微愛好者。
PixAI Prompt 組合器|快速打造可用於 AI 繪圖的語言拼圖
使用 PixAI 卻不會寫 prompt?這個工具幫你一鍵組裝角色、表情、風格語彙,輸出高品質繪圖提示語句(Prompt),可直接貼入 PixAI 使用。適合插畫師、創作者、AI 新手與 VTuber 角色開發者。