200字
畢業專題小札記03:想讓你的 LINE 官方帳號不只能聊天?用 LIFF 為品牌打造專屬互動體驗
2026-01-16
2026-01-16

大家應該都有加過店家的 LINE 會員吧?當你打開 LINE 官方帳號時,下方通常會有一個選單,點擊後就會跳出會員卡、優惠券或是點數查詢等功能頁面,這其實就是透過 LIFF App(LINE Front-end Framework)技術所開發的網頁應用程式,讓店家能在 LINE 聊天室內直接提供互動式服務,不需要跳出到外部瀏覽器,使用體驗更加流暢便利。

什麼是 LIFF?

LIFF 是 LINE 官方推出的前端開發框架,全名為 LINE Front-end Framework

雖然說是「框架」,但它其實沒那麼複雜,簡單來說,開發者可以利用任意的網頁前端技術(如 React、Vue 或純 HTML/CSS/JavaScript)製作他們想要的功能,然後嵌入在 LINE 官方帳號裡。透過 LIFF SDK,開發者可以在他們的 Web App 裡取得使用者的 LINE 個人資料(在使用者授權的情況下)、傳送訊息到聊天室,或是呼叫 LINE Pay 等服務,為使用者打造無縫整合的體驗。

這種技術特別適合用來開發會員系統電商購物問卷調查預約系統等互動式應用,讓企業能更有效地透過 LINE 與顧客互動。

LIFF App vs. 原生 App:為什麼選擇 LIFF?LIFF 能為品牌帶來哪些優勢?

在了解 LIFF 是什麼之後,你可能會好奇:為什麼不直接做一個傳統的會員 App 就好?LIFF 到底有什麼特別的優勢?

傳統會員 App 面臨的困境

許多品牌為了提供更好的服務體驗,會投入大量資源開發專屬的會員 App。表面上看起來很完美,但實際執行起來卻充滿挑戰。

首先是開發成本的問題。一個 App 需要分別開發 iOS 和 Android 兩個版本,光是找到合適的開發團隊就是一筆不小的開支,開發時程通常需要好幾個月。更麻煩的是後續維護,每次想要新增功能或修正錯誤,都要重新送審、等待 Apple 和 Google 的審核通過才能上架,整個流程既耗時又耗錢。

再來是使用者的問題。現在大家的手機裡已經裝了一大堆 App,要讓使用者願意再下載一個新的 App 真的很難,尤其是那些使用頻率不高的服務。你可能花了大錢做了一個很棒的會員 App,結果只有 5% 的客人願意下載,而這 5% 當中又有一半的人用了一兩次之後就把它刪掉了。為什麼?因為手機空間不夠、因為懶得記帳號密碼、因為一個月才用一次覺得沒必要留著。

最後是推廣的問題。就算你成功說服客人下載了 App,要讓他們持續使用也是一大挑戰。推播通知很容易被關閉,App 圖示埋在一堆其他 App 裡面很難被看見,使用者根本不會主動想到要打開你的 App

LIFF 帶來的改變

相較之下,LIFF 用一種完全不同的思路解決了這些問題。它不是要使用者下載新的 App,而是把功能直接嵌入到他們每天都會用的 LINE 裡面。

想像一下這個場景:客人想要點餐,不需要先去 App Store 搜尋、下載、註冊、登入,只要打開 LINE、點開你的官方帳號、按一下選單,馬上就能開始點餐。整個過程不到 10 秒鐘,而且完全不佔手機空間。這就是 LIFF 最大的優勢——零門檻

而且因為是在 LINE 裡面,使用者的基本資料(像是名字、大頭照)LINE 都已經有了,只要經過授權就能直接使用,不需要重新註冊帳號、設定密碼。這對使用者來說方便,對店家來說也降低了客人流失的風險。

從技術面來看,LIFF 用的是網頁技術,這意味著你只需要開發一次,iOS 和 Android 都能用。而且更新超級方便,不需要經過 App Store 或 Google Play 的審核,改完立刻上線。對於需要經常調整功能、快速回應市場需求的品牌來說,這個優勢非常重要。

更重要的是,LIFF 完全整合在 LINE 的生態系裡。在台灣,LINE 的使用率超過 90%,幾乎人人都有,而且每個人每天都會打開 LINE 好幾次。當你的服務嵌入在 LINE 官方帳號裡,就等於每天都有機會出現在客人眼前。你可以透過聊天室推播優惠訊息、發送會員通知,觸及率比傳統 APP 的推播高出許多。

具體差異對比

讓我們用一個表格來看看兩者的具體差異:

比較項目

傳統原生 APP

LIFF App

開發成本

需分別開發 iOS 和 Android,成本高(數十萬到數百萬)

只需開發一次網頁版本,成本降低 70%

開發時程

通常需要 3-6 個月

1-2 個月即可完成

使用門檻

需下載、註冊、登入

加入官方帳號即可使用

手機空間

佔用 50-200 MB

不佔空間

更新方式

需重新上架審核,可能需要 1-2 週

即時更新,立刻生效

觸及率

推播通知容易被忽略

嵌入 LINE,每天都會看到

使用頻率

低頻使用容易被刪除

不需下載,隨時可用

開發者帳號

需付費(iOS $99/年,Android $25 一次性)

免費

實際案例:小餐廳的選擇

讓我們用一個具體的例子來說明。假設你是一家小餐廳的老闆,想要做一個點餐系統:

如果選擇傳統 APP:

你可能要花 50 萬請外包公司開發,等 4 個月才能上線。上線後你開始推廣,印了一堆 DM 請客人下載 App,但一個月下來發現只有 100 個客人下載,而且真正持續使用的只有 20 個。三個月後你想要修改菜單、調整價格,又要花錢請外包改,改完還要等 Apple 和 Google 審核,一來一往又是兩週。

如果選擇 LIFF:

你可能只要花 15 萬請工程師開發,一個半月就能上線。上線後你請客人加入 LINE 官方帳號,桌上放個 QR Code,客人掃描就能馬上點餐。一個月後你有 500 個好友,因為門檻低大家都願意加。當你要修改菜單或價格,工程師改完立刻上線,完全不需要等待審核。而且每次有新菜色或優惠,你都能透過 LINE 直接推播給客人,大家看到的機會比 App 推播高出好幾倍。

這就是為什麼 LIFF 特別適合中小型品牌:低成本、高觸及、易使用。你不需要投入大量資源開發 APP,也能為客戶提供流暢的數位體驗。對於預算有限、需要快速上線的品牌來說,LIFF 是一個非常實際的選擇。


小小商家一點靈的點餐功能

而我畢業專題的點餐頁面就是用 LIFF 做的!

設定

LINE OA 設定

要做 LIFF App 之前,必須有官方帳號,如果還沒有就要先建立。

可以從 https://tw.linebiz.com/login/ 登入 LINE OA (LINE Official Account Manager),用自己的 LINE 帳號就可以。

登入之後,就可以看到我們目前有的所有官方帳號。

如果要建立新的就點擊建立,然後把資料填一填即可。

LINE Developers 設定

設定好官方帳號之後,我們還要登入 LINE Developers,在 LINE Developers 建立一個 channel,才能透過 LIFF 讓使用者登入我們的 web app。

如果還沒有 Provider,要先建立 Provider。

什麼是 Provider?

Provider 可以理解為一個「開發者帳號的管理單位」或「專案的組織層級」。一個帳號可以有多個 Provider。

在 LINE Developers 的架構中,Provider 就像是一個容器或資料夾,用來統一管理你所有的開發專案(Channels)。比如說,如果你是一間公司,公司名稱就可以是你的 Provider;如果你是個人開發者,可以用你的名字或工作室名稱作為 Provider。

一個 Provider 底下可以建立多個 Channels(頻道),每個 Channel 對應不同的功能或服務,例如:

  • 一個 Channel 用於 LIFF App/LINE Login。

  • 一個 Channel 用於 Messaging API(聊天機器人)。

簡單來說,Provider 就是把你所有在 LINE 平台上開發的應用程式集中管理的地方,方便你分類和管理不同的專案。

點擊進入建立好的 Provider,然後建立 Channel,類別選 LINE Login。

什麼是 Channel?

Channel 就是你在 LINE 平台上建立的各種服務或應用程式的「頻道」。

每個 Channel 代表一個獨立的功能或服務,不同類型的 Channel 有不同的用途。常見的 Channel 類型包括:

  • LINE Login:讓使用者可以用 LINE 帳號登入你的網站或 APP,可用來建立嵌入在 LINE 內的網頁應用程式(LIFF App)。

  • Messaging API:用來開發聊天機器人,可以自動回覆訊息或主動推播。

每個 Channel 都會有自己專屬的金鑰(Channel ID、Channel Secret 等),這些金鑰就像是身分證,讓你的應用程式能夠與 LINE 平台溝通。舉例來說,當你要開發 LIFF App 時,就需要建立一個 LIFF 類型的 Channel,然後用這個 Channel 提供的 LIFF ID 來啟動你的網頁應用。

簡單來說,Channel 就是你每一個具體功能的「入口」,讓 LINE 知道這個服務是做什麼用的,以及如何與它互動。

建立時要填寫一些基本資料,App types 可以兩個都勾。

App types 兩個都勾

建立好之後,進到 Channel 連結我們剛剛建立好的官方帳號。

接著點到 LIFF 分頁,把 LIFF App 的網址加進去。

LIFF App 必須部屬在伺服器上,才會有 ip 或網址。我是和後端 Go 程式一起用 Docker Compose 部屬在 Oracle Cloud Infrastructure 上,並使用自己的網域。

我的 LIFF App 設定長這樣:

Rich Menu 設定

為了讓使用者可以直接從官方帳號點進我們的 LIFF App,我們必須準備一個 Rich Menu(圖文選單)。Rich menu就是一張可以點的圖,我們可以設定點擊圖上不同區域,連結到不同功能,如為用戶發送特定訊息、打開網站連結等。

雖然 OA 裡面可以設定「圖文選單」,但那必須設定期限,時間到就會自動撤下,並且可點擊區域只能從預設好的幾個模板裡面挑選。所以建議使用 LINE Messaging API 來設定。不會打 API 沒關係,我有個好用的工具,下面會介紹。

建立 Messaging API 的 Channel

我們要先去 LINE Developers 建立新 Channel,選擇 Messaging API。

好的,我忘記當初是怎麼建的了。還是說現在建立官方帳號會直接開通 Messaging API 🤔?

製作 Rich Menu

不管了,我假設大家都已經有 Messaging API,接下來就要製作並上傳 Rich Menu。

該是時候拿出我的秘密武器了!

我先是用 Canva 製作一個像這樣的圖:

然後打開我用別人的開源專案加上 Vibe Coding 做出來的 Rich Menu 管理器(以前可以用 LINE 官方的 LINE Bot Designer,但他們在 2022 就停止維護了😭,詳見:https://developers.line.biz/en/news/2022/01/18/development-of-line-bot-designer-has-been-finished/。只好自己做一個):

https://linebotrm.hazelnut-paradise.com/

  1. 點擊右上角設定

  1. 填入自己的 Channel Access Tocken

你的 Channel Access Tocken 只會存在瀏覽器的 Local Storage,我的伺服器不會保留,請安心。

Channel Access Tocken 可以在 LINE Developers 中對應的 Messaging API Channel 裡找到:

  1. 新增 Rich Menu

設定好 Channel Access Tocken 之後,會看到這個帳號已有的 Rich Menu。我們可以點新增按鈕來加入新的。

  1. 上傳 Rich Menu 圖檔

在這裡上傳製作好的 Rich Menu 圖檔,並設定名稱(給自己看)聊天列文字(會顯示在聊天室底部)

這就是聊天列文字:

S__230121477-bhcg.jpg
  1. 設定點擊區域

接著可以很直觀地用滑鼠畫出一個範圍,設定點擊該範圍會觸發的效果。

設定好一個區域就點「新增區域」,把每個按鈕都加進來。好了之後點「建立 Rich Menu」。

  1. 設定預設 Rich Menu

好了之後,會在左側看到我們新增的 Rich Menu。點擊他然後點設為預設,就大功告成啦!


LIFF App 程式實作:Vue 3 點餐系統開發全記錄

LIFF App 實際上就是 Web App,或者講更簡單點就是網頁,所以介面和功能要自己寫。我使用的技術棧是 Vue 框架。

什麼是 Vue?

Vue.js 是一個用來開發網頁的 JavaScript 框架。如果把網頁開發比喻成造車,HTML 就像是車子的車體結構(車身、車門、車窗),CSS 是外觀和內裝(顏色、儀表板樣式),而 JavaScript 則是讓車子「動起來」的引擎和各種功能(煞車、方向燈、音響)。

但是當網頁功能越來越複雜時,單純用 HTML、CSS、JavaScript 寫會變得很亂很難管理。想像一下,如果你要做一個購物網站,每個商品都有圖片、名稱、價格、加入購物車按鈕,傳統的寫法就是把這些重複的程式碼一直複製貼上,非常沒效率,而且如果要修改某個地方(例如按鈕顏色),就要改幾十個地方。

這時候「框架」就派上用場了。框架提供了一套組織程式碼的方法,讓開發變得更有架構、更容易維護。

框架的元件化概念

現代的前端框架(像 Vue、React)最重要的概念就是「元件化」(Component)。

什麼是元件化?簡單來說,就是把網頁拆解成一個個可重複使用的小零件。就像樂高積木一樣,每個元件都是獨立的、可重複使用的,需要的時候就拿出來組裝。

舉個實際的例子:

傳統寫法(沒有元件化):

  • 商品 A:寫一次圖片 + 名稱 + 價格 + 按鈕的程式碼。

  • 商品 B:再寫一次圖片 + 名稱 + 價格 + 按鈕的程式碼。

  • 商品 C:又寫一次圖片 + 名稱 + 價格 + 按鈕的程式碼。

  • ...(重複 100 次)

元件化寫法:

  • 先做一個「商品卡片元件」,定義好它的外觀和功能。

  • 需要顯示商品時,就呼叫這個元件,並把不同的商品資料(圖片、名稱、價格)傳進去。

  • 如果要修改所有商品卡片的樣式,只要改一個元件就好,所有地方都會自動更新。

這就像是工廠生產手機一樣,不是每台都從零開始打造,而是有一套標準化的生產流程和零件,需要的時候就組裝起來。

為什麼選擇 Vue?

Vue 有幾個適合開發 LIFF App 的優點:

  • 容易上手:相較於其他框架,Vue 的語法比較直觀易懂,學習曲線平緩。

  • 輕量快速:程式檔案小、執行速度快,在手機上打開網頁時載入更迅速。

  • 完善的元件化系統:可以輕鬆把頁面拆分成小元件(例如:菜單列表、商品卡片、購物車),方便重複使用和修改。

  • 即時更新畫面:當資料改變時(例如購物車數量增加),畫面會自動更新,不需要手動重新整理頁面。

對於點餐系統這種需要頻繁互動的應用(選餐點、加入購物車、修改數量等),Vue 的元件化開發能讓程式碼更好維護,使用者體驗也更流暢。

專案架構概覽

在開始深入介紹之前,先來看看整個專案的架構。

liff/
├── src/
│   ├── views/              # 頁面組件
│   │   ├── OrderFood.vue   # 點餐頁面
│   │   ├── MyOrders.vue    # 我的訂單
│   │   └── UserRegister.vue # 用戶註冊
│   ├── components/         # 共用組件
│   │   ├── LoadingSpinner.vue
│   │   ├── MessageAlert.vue
│   │   ├── UserProfileCard.vue
│   │   └── LiffDebugPanel.vue
│   ├── composables/        # 組合式函數
│   │   ├── useLiff.js      # LIFF 相關邏輯
│   │   └── useApi.js       # API 請求邏輯
│   ├── services/           # API 服務層
│   │   ├── api.js
│   │   ├── productService.js
│   │   ├── orderService.js
│   │   └── userService.js
│   ├── router/             # 路由設定
│   ├── utils/              # 工具函數
│   ├── config/             # 設定檔
│   ├── App.vue             # 主要組件
│   └── main.js             # 入口檔案
├── package.json
└── vite.config.js

這種架構遵循了「關注點分離」的原則,把不同功能的程式碼放在不同的資料夾裡。views 放完整的頁面組件,components 放可重複使用的小組件,composables 放可重複使用的邏輯,services 統一管理所有 API 請求。一個 view 相當於一個完整頁面,裡面有可能會使用多個 components 中的小組件和 composables 中的程式邏輯。

這樣設計的好處是當你要修改某個功能時,很清楚要去哪個資料夾找檔案,而且不同的功能不會互相干擾。


一、專案起點:App.vue

<template>
  <div id="app">
    <!-- 全局加載指示器 -->
    <div v-if="isInitializing" class="global-loading">
      <div class="loading-spinner"></div>
      <p>正在初始化 LINE 服務...</p>
    </div>

    <!-- LIFF 初始化錯誤 -->
    <div v-else-if="error" class="global-error">
      <div class="error-icon">⚠️</div>
      <h3>服務初始化失敗</h3>
      <p>{{ error }}</p>
      <button @click="retryInit" class="retry-btn">重試</button>
    </div>

    <!-- 主要內容 -->
    <router-view v-else />
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { useLiff } from './composables/useLiff.js'

const {
  initializeLiff,
  isInitializing,
  error,
  isReady
} = useLiff()

const retryInit = async () => {
  try {
    await initializeLiff()
  } catch (err) {
    console.error('重試初始化失敗:', err)
  }
}

onMounted(async () => {
  await initializeLiff()
})
</script>

App.vue 是整個應用程式的「最外層容器」,它負責在應用程式啟動時初始化 LIFF SDK,並根據不同的狀態顯示不同的內容。如果正在初始化就顯示載入動畫,如果初始化失敗就顯示錯誤訊息和重試按鈕,如果一切正常就顯示實際的頁面內容。<router-view />會根據當前的 URL 路徑顯示對應的頁面,例如當 URL 是/order時顯示點餐頁面,當 URL 是/my-orders時顯示訂單頁面。

對 SPA(Single Page Application)不熟的人在這裡可能會有點困惑——「外層容器」是啥?為什麼我們需要一個容器來包所有頁面?

這是因為 SPA 不像傳統的網站那樣,每次點擊連結就重新載入整個頁面。傳統網站就像是翻書,每翻一頁就是一個全新的頁面,整個頁面從頭到尾重新載入。而 SPA 更像是在同一頁紙上拆掉舊內容、裝上新內容,整個過程中瀏覽器不需要重新整理,只是把頁面的一部分內容替換掉。這樣做的好處是切換頁面更快、使用者體驗更流暢,而且可以保留一些全域的狀態(例如已經初始化好的 LIFF SDK)。App.vue 就是這張「紙」,而<router-view />就是那塊會被替換的區域,其他部分(例如 LIFF 初始化狀態、載入動畫)則一直保持在那裡。

💡 延伸說明:什麼是 SPA?

SPA 的全名是 Single Page Application,中文叫「單頁應用程式」。這個名字聽起來好像整個網站只有一頁,但其實不是這樣。

傳統網站 vs SPA

傳統網站(Multi-Page Application)

當你在傳統網站上點擊連結時:

  1. 瀏覽器向伺服器發送請求。

  2. 伺服器回傳一個完整的新 HTML 頁面。

  3. 瀏覽器清空畫面,載入新頁面。

  4. 所有的 JavaScript、CSS 重新載入。

就像你在看一本實體書,每翻一頁就是完全不同的一頁內容。

SPA(Single Page Application)

當你在 SPA 上切換頁面時:

  1. JavaScript 攔截你的點擊動作。

  2. 用 JavaScript 改變瀏覽器的 URL(不重新整理頁面)。

  3. 用 JavaScript 把頁面的部分內容替換掉。

  4. 整個過程不需要重新載入整個頁面。

就像你在用 iPad 看電子書,只是把畫面上的內容換掉,但 iPad 本身沒有重新啟動。

SPA 的優點

  1. 速度快:不用每次都重新載入整個頁面,只更新需要改變的部分。

  2. 體驗流暢:頁面切換沒有閃爍(變空白再顯示),可以做出更流暢的動畫效果。

  3. 減少伺服器負擔:伺服器只需要提供資料(JSON),不用每次都產生完整的 HTML。

  4. 保持狀態:可以保留一些全域的資料和狀態,不會因為換頁就消失。

SPA 的缺點

  1. 首次載入較慢:第一次要載入整個應用程式的 JavaScript,相當於一次性把網站的所有頁面都下載下來。

  2. SEO 較困難:搜尋引擎爬蟲可能無法正確抓取內容(不過現在已經有解決方案)。

  3. 瀏覽器的上一頁/下一頁需要特別處理:這就是為什麼需要 Vue Router 這樣的工具。

常見的 SPA 框架

  • Vue.js(我們這個專案使用的)

  • React

  • Angular

為什麼 LIFF App 適合用 SPA?

LIFF App 是嵌入在 LINE 裡面的網頁應用程式,使用者期待的是像原生 App 一樣流暢的體驗。如果每次切換頁面都要重新載入,使用者會覺得很卡、很慢。用 SPA 可以讓整個點餐流程(瀏覽商品 → 加入購物車 → 送出訂單 → 查看訂單)都非常順暢,就像在使用一個真正的手機 App 一樣。

二、核心功能:LIFF 整合

useLiff.js

import { reactive, computed, readonly } from 'vue'
import { liff } from '@line/liff'
import { isDev, getLiffId } from '../config'

const liffState = reactive({
    isReady: false,
    isLoggedIn: false,
    profile: null,
    context: null,
    error: null,
    isInitializing: false
})

const initializeLiff = async () => {
    if (liffState.isInitializing || liffState.isReady) {
        return liffState
    }

    liffState.isInitializing = true
    liffState.error = null

    try {
        if (isDev()) {
            // 開發模式:使用 Mock 資料
            const mockProfile = {
                userId: 'U1234567890abcdef',
                displayName: '測試用戶🎭',
                pictureUrl: 'https://profile.line-scdn.net/...',
            }
            liffState.isReady = true
            liffState.isLoggedIn = true
            liffState.profile = mockProfile
            return liffState
        }

        // 生產模式:使用真實 LIFF
        await liff.init({ liffId: getLiffId() })
        
        const context = liff.getContext()
        const isLoggedIn = liff.isLoggedIn()

        liffState.isReady = true
        liffState.context = context
        liffState.isLoggedIn = isLoggedIn

        if (isLoggedIn) {
            const userProfile = await liff.getProfile()
            liffState.profile = userProfile
        }

    } catch (error) {
        let errorMessage = 'LINE 服務初始化失敗'
        if (error.code === 'INVALID_CONFIG') {
            errorMessage = 'LIFF ID 不正確,請檢查設定'
        }
        liffState.error = errorMessage + ',請重新整理頁面'
        throw error
    } finally {
        liffState.isInitializing = false
    }

    return liffState
}

export const useLiff = () => {
    return {
        liffState: readonly(liffState),
        isReady: computed(() => liffState.isReady),
        isLoggedIn: computed(() => liffState.isLoggedIn),
        profile: computed(() => liffState.profile),
        initializeLiff,
        getUserId: () => liffState.profile?.userId,
    }
}

這個檔案封裝了所有與 LIFF 相關的操作。重點在於區分開發模式和生產模式:開發時在電腦上測試沒有真實的 LINE 環境,所以用 Mock 資料模擬;生產時部署到伺服器使用真實的 LIFF SDK。用reactive包裝的物件會自動監視變化,當物件的屬性改變時所有使用這個物件的地方都會自動更新。

組合式函數(Composable)是 Vue 3 的重要概念,它就像是一個「功能包」把相關的狀態和方法打包在一起,讓你可以在不同的組件中重複使用。

三、路由設定:頁面導航與權限控制

router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import UserRegister from '../views/UserRegister.vue'
import OrderFood from '../views/OrderFood.vue'
import MyOrders from '../views/MyOrders.vue'
import { validateUserForNavigation, UserValidator } from '../utils/userValidator.js'
import NProgress from 'nprogress'

const routes = [
    { path: '/', redirect: { name: 'OrderFood' } },
    { path: '/register', name: 'UserRegister', component: UserRegister, meta: { title: '用戶註冊' } },
    { path: '/order', name: 'OrderFood', component: OrderFood, meta: { title: '線上點餐' } },
    { path: '/my-orders', name: 'MyOrders', component: MyOrders, meta: { title: '我的訂單' } }
]

const router = createRouter({
    history: createWebHistory('/liff/'),
    routes
})

router.beforeEach(async (to, from, next) => {
    NProgress.start()
    try {
        const user_line_uid = await UserValidator.getUserIdFromLiff(liff)
        const validation = await validateUserForNavigation(user_line_uid, to.name, to)

        if (validation.shouldRedirect) {
            return next({ name: validation.redirectTo, query: validation.query })
        }

        if (to.meta.title) {
            document.title = "小小商家一點靈 | " + to.meta.title
        }

        next()
    } catch (error) {
        if (to.name !== 'UserRegister') {
            return next({ name: 'UserRegister' })
        }
        next()
    }
})

router.afterEach(() => {
    NProgress.done()
})

export default router

這裡定義了哪個網址應該顯示哪個頁面,並且在前往對應頁面之前會先經過「路由守衛」。

路由守衛就像是一個「檢查哨」,在用戶切換頁面之前先檢查他們是否有權限進入該頁面。這段程式碼會顯示載入進度條、檢查用戶狀態、驗證用戶是否已註冊,如果用戶未註冊就重定向到註冊頁面。有了路由守衛可以確保用戶在正確的時間看到正確的頁面,避免未註冊的用戶直接進入點餐頁面導致錯誤。

四、API 服務層:與後端溝通

services/api.js

import { API_BASE_URL } from '../config'

class ApiService {
    constructor() {
        this.baseURL = API_BASE_URL
    }

    async request(endpoint, options = {}) {
        const url = `${this.baseURL}${endpoint}`
        
        try {
            const response = await fetch(url, {
                headers: {
                    'Content-Type': 'application/json',
                    ...options.headers,
                },
                ...options,
            })

            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`)
            }

            return await response.json()
        } catch (error) {
            console.error('API request failed:', error)
            throw error
        }
    }

    get(endpoint) {
        return this.request(endpoint, { method: 'GET' })
    }

    post(endpoint, data) {
        return this.request(endpoint, {
            method: 'POST',
            body: JSON.stringify(data),
        })
    }
}

export default new ApiService()

services/productService.js

import api from './api'

export const productService = {
    getAllProducts() {
        return api.get('/products')
    },

    getProductsByCategory(category) {
        return api.get(`/products?category=${category}`)
    },

    searchProducts(keyword) {
        return api.get(`/products/search?q=${keyword}`)
    }
}

這個 LIFF App 的所有資料都會去跟我的 Go 語言後端拿。API 服務層統一管理所有與後端的溝通,包括拿資料和送出新訂單。基礎的api.js封裝了fetch API,提供getpost等方法,並統一處理錯誤。各個功能服務(如productServiceorderService)則使用這個基礎服務來實作具體的 API 請求。

這樣的好處是如果之後要修改 API 網址或是加入認證機制,只需要改一個地方就好。

五、點餐頁面:核心功能實作

OrderFood.vue(重點摘錄)

<template>
    <div class="order-food-container">
        <!-- 頂部標題區域 -->
        <header class="header">
            <button @click="goToOrders" class="orders-btn">
                <span>📝</span>
                <span>我的訂單</span>
            </button>
            <h1>🍔 線上點餐</h1>
            <div class="cart-summary" @click="showCart = true">
                <span>🛒</span>
                <span v-if="cartItemCount > 0">{{ cartItemCount }}</span>
                <span>NT$ {{ cartTotal }}</span>
            </div>
        </header>

        <!-- 搜尋欄 -->
        <div class="search-section">
            <input v-model="searchKeyword" type="text" 
                   placeholder="搜尋商品..." 
                   @input="handleSearch">
        </div>

        <!-- 分類篩選 -->
        <div class="category-section">
            <button v-for="category in categories" :key="category"
                    :class="['category-tab', { active: selectedCategory === category }]"
                    @click="selectCategory(category)">
                {{ category }}
            </button>
        </div>

        <!-- 商品列表 -->
        <div class="products-grid">
            <div v-for="product in filteredProducts" :key="product.name" 
                 class="product-card">
                <div class="product-info">
                    <h3>{{ product.name }}</h3>
                    <p>{{ product.description }}</p>
                    <span class="product-price">NT$ {{ product.price }}</span>
                    <div class="quantity-controls">
                        <button @click="decreaseQuantity(product)">-</button>
                        <span>{{ getProductQuantity(product) }}</span>
                        <button @click="increaseQuantity(product)">+</button>
                    </div>
                </div>
            </div>
        </div>

        <!-- 購物車彈窗 -->
        <div v-if="showCart" class="cart-modal">
            <h2>🛒 購物車</h2>
            <div v-for="item in cartItems" :key="item.product.name">
                <span>{{ item.product.name }}</span>
                <span>x{{ item.quantity }}</span>
                <span>NT$ {{ item.product.price * item.quantity }}</span>
            </div>
            <button @click="submitOrder">送出訂單</button>
        </div>
    </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { productService } from '../services/productService'
import { orderService } from '../services/orderService'
import { useLiff } from '../composables/useLiff'

const router = useRouter()
const { profile } = useLiff()

const products = ref([])
const cartItems = ref([])
const searchKeyword = ref('')
const selectedCategory = ref('全部')
const showCart = ref(false)

// 計算屬性
const filteredProducts = computed(() => {
    let result = products.value

    if (selectedCategory.value !== '全部') {
        result = result.filter(p => p.category === selectedCategory.value)
    }

    if (searchKeyword.value) {
        result = result.filter(p => 
            p.name.includes(searchKeyword.value)
        )
    }

    return result
})

const cartItemCount = computed(() => {
    return cartItems.value.reduce((sum, item) => sum + item.quantity, 0)
})

const cartTotal = computed(() => {
    return cartItems.value.reduce((sum, item) => 
        sum + (item.product.price * item.quantity), 0
    )
})

// 方法
const loadProducts = async () => {
    try {
        const data = await productService.getAllProducts()
        products.value = data
    } catch (error) {
        console.error('載入商品失敗:', error)
    }
}

const increaseQuantity = (product) => {
    const item = cartItems.value.find(i => i.product.name === product.name)
    if (item) {
        item.quantity++
    } else {
        cartItems.value.push({ product, quantity: 1 })
    }
}

const decreaseQuantity = (product) => {
    const item = cartItems.value.find(i => i.product.name === product.name)
    if (item) {
        item.quantity--
        if (item.quantity === 0) {
            cartItems.value = cartItems.value.filter(i => i !== item)
        }
    }
}

const submitOrder = async () => {
    try {
        const orderData = {
            user_line_uid: profile.value.userId,
            items: cartItems.value.map(item => ({
                product_name: item.product.name,
                quantity: item.quantity,
                price: item.product.price
            })),
            total: cartTotal.value
        }

        await orderService.createOrder(orderData)
        alert('訂單送出成功!')
        cartItems.value = []
        showCart.value = false
    } catch (error) {
        console.error('送出訂單失敗:', error)
        alert('訂單送出失敗,請稍後再試')
    }
}

onMounted(() => {
    loadProducts()
})
</script>

點餐頁面是整個系統最核心的功能。主要包含幾個部分:

  1. 商品列表:從後端 API 載入所有商品並顯示。

  2. 搜尋功能:透過v-model雙向綁定搜尋關鍵字,用computed即時篩選商品。

  3. 分類篩選:點擊分類按鈕切換篩選條件。

  4. 購物車:用陣列儲存已選商品,提供增減數量功能。

  5. 訂單送出:整理購物車資料後透過 API 送到後端。

使用computed計算購物車的總數量和總金額,這樣當購物車內容改變時這些數值會自動更新。v-for指令可以根據陣列動態渲染多個元素,非常適合用來顯示商品列表和購物車內容。

六、訂單管理:查看歷史訂單

MyOrders.vue(精簡版)

<template>
    <div class="my-orders-container">
        <header>
            <button @click="goBack">← 返回</button>
            <h1>📝 我的訂單</h1>
        </header>

        <div v-if="loading">載入中...</div>

        <div v-else-if="orders.length === 0">
            <p>目前沒有訂單</p>
        </div>

        <div v-else class="orders-list">
            <div v-for="order in orders" :key="order.id" class="order-card">
                <div class="order-header">
                    <span>訂單編號:{{ order.id }}</span>
                    <span :class="`status-${order.status}`">{{ order.status }}</span>
                </div>
                <div class="order-items">
                    <div v-for="item in order.items" :key="item.id">
                        {{ item.product_name }} x{{ item.quantity }}
                    </div>
                </div>
                <div class="order-footer">
                    <span>總金額:NT$ {{ order.total }}</span>
                    <span>{{ formatDate(order.created_at) }}</span>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { orderService } from '../services/orderService'
import { useLiff } from '../composables/useLiff'

const router = useRouter()
const { profile } = useLiff()

const orders = ref([])
const loading = ref(true)

const loadOrders = async () => {
    try {
        loading.value = true
        const data = await orderService.getUserOrders(profile.value.userId)
        orders.value = data
    } catch (error) {
        console.error('載入訂單失敗:', error)
    } finally {
        loading.value = false
    }
}

const formatDate = (dateString) => {
    const date = new Date(dateString)
    return date.toLocaleString('zh-TW')
}

const goBack = () => {
    router.push({ name: 'OrderFood' })
}

onMounted(() => {
    loadOrders()
})
</script>

訂單頁面相對簡單,主要功能是從後端 API 取得該用戶的所有訂單並顯示。用v-ifv-else-ifv-else根據不同狀態顯示不同內容:載入中顯示載入提示,沒有訂單顯示空狀態提示,有訂單就顯示訂單列表。每個訂單卡片顯示訂單編號、狀態、商品明細、總金額和時間。

七、可重用組件

LoadingSpinner.vue

<template>
    <div class="loading-container">
        <div class="spinner"></div>
        <p v-if="message">{{ message }}</p>
    </div>
</template>

<script setup>
defineProps({
    message: {
        type: String,
        default: ''
    }
})
</script>

<style scoped>
.spinner {
    width: 40px;
    height: 40px;
    border: 4px solid rgba(0, 0, 0, 0.1);
    border-top: 4px solid #3498db;
    border-radius: 50%;
    animation: spin 1s linear infinite;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}
</style>

MessageAlert.vue

<template>
    <div v-if="show" :class="['alert', `alert-${type}`]">
        <span>{{ message }}</span>
        <button @click="$emit('close')">✕</button>
    </div>
</template>

<script setup>
defineProps({
    show: Boolean,
    type: {
        type: String,
        default: 'info'
    },
    message: String
})

defineEmits(['close'])
</script>

可重用組件就像是樂高積木,做好一次之後可以在不同地方重複使用。LoadingSpinner用來顯示載入動畫,MessageAlert用來顯示提示訊息。這些小組件透過props接收外部傳入的參數,透過emits向外部發送事件。使用時只需要:

<LoadingSpinner message="載入中..." />
<MessageAlert :show="showAlert" type="success" message="操作成功" @close="showAlert = false" />

八、用戶註冊頁面

UserRegister.vue(核心部分)

<template>
    <div class="register-container">
        <h1>用戶註冊</h1>
        
        <UserProfileCard :profile="profile" />

        <form @submit.prevent="handleRegister">
            <input v-model="form.name" type="text" placeholder="姓名" required>
            <input v-model="form.phone" type="tel" placeholder="手機號碼" required>
            <button type="submit" :disabled="submitting">
                {{ submitting ? '註冊中...' : '完成註冊' }}
            </button>
        </form>
    </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useLiff } from '../composables/useLiff'
import { userService } from '../services/userService'
import UserProfileCard from '../components/UserProfileCard.vue'

const router = useRouter()
const { profile } = useLiff()

const form = ref({
    name: '',
    phone: ''
})
const submitting = ref(false)

const handleRegister = async () => {
    try {
        submitting.value = true
        
        await userService.registerUser({
            line_uid: profile.value.userId,
            name: form.value.name,
            phone: form.value.phone
        })

        alert('註冊成功!')
        router.push({ name: 'OrderFood' })
    } catch (error) {
        console.error('註冊失敗:', error)
        alert('註冊失敗,請稍後再試')
    } finally {
        submitting.value = false
    }
}

onMounted(() => {
    if (profile.value) {
        form.value.name = profile.value.displayName
    }
})
</script>

註冊頁面讓新用戶填寫基本資料。使用v-model雙向綁定表單欄位,@submit.prevent處理表單送出並阻止預設行為。按鈕的:disabled屬性綁定submitting狀態,避免重複送出。註冊成功後使用router.push導航到點餐頁面。


總結

這個 LIFF 點餐系統使用 Vue 3 開發,透過元件化的方式組織程式碼讓每個部分都職責分明。LIFF SDK 的整合讓用戶可以在 LINE 內直接使用不需要另外註冊帳號,也讓我省下自己開發整套會員系統的時間。路由守衛確保用戶流程的正確性,API 服務層統一管理與後端的溝通。透過 Vue 的響應式系統和組合式函數讓程式碼更容易維護和重複使用。

整個系統的核心在於:

  • 元件化拆分功能模組。

  • composables封裝可重用邏輯。

  • services統一管理 API。

  • 路由守衛控制頁面權限。

  • 響應式資料自動更新畫面。

這樣的架構不只讓開發更有效率,也讓之後要新增功能或修改程式碼時更加容易。

這些程式碼都放在 GitHub 上,有需要可以自行參考取用:https://github.com/TimLai666/lineliteshop1.0

但目前暫不開放商用,麻煩不要直接抄來賣,感謝配合🙏🙏。

評論