Nitra App UI · 架構文件
入門 · 01

導覽

Nitra App UI 是一套建構在 Vue 3 + Quasar(Vite) 上的金融科技單頁應用(SPA)——涵蓋卡片、支付、貸款、費用、onboarding/KYB、團隊管理——並用同一份程式碼支援多品牌(white label)。這份文件帶你從零開始,到能有信心地開發功能。

三個核心原則

① 只用 Composition API + <script setup>,app 程式碼裡沒有 Options API。② 以 page group 分類,不以類型分類——某個產品領域的 routes、頁面、模組元件、i18n 全放在 src/pages/<Group>/(對應後端的 feature folder)。③ 每種需求都只有一個入口——所有 HTTP 走 $API、所有狀態在 Pinia、所有跨切面邏輯在 composable。

給誰看

剛加入 Nitra 的前端工程師,以及任何需要一張「一次請求如何變成畫面」地圖的人。你需要 Vue 3 基礎;需要 Quasar 或 Pinia 經驗——本文會補上。

怎麼讀這份文件

若只能讀一章,讀 §5——它是整份文件的主軸。其餘每一章都是這條路徑上某個角色的細節。§1–4 建立詞彙,§6–11 深入各層,§12 端到端做一個功能,§13–15 為參考。

Page Group 的形狀

page group 是工作的單位。最小的一個(src/pages/ErrorPages/)只有兩個檔案,而這個形狀可以往上擴展:

src/pages/<Group>/
  routes.js            # 此 group 的路由(path、name、component=layout、beforeEnter guards)
  <Name>Page.vue       # 頁面元件(PascalCase,一律 *Page.vue)
  components/          # 只在此 group 內使用的模組元件
  composables/         # (選用)模組層 composable
  i18n/en-US.js        # (選用)此 group 的翻譯字串,合併進全域 bundle
你現在知道這 app 是什麼、程式碼怎麼分。下一章 → §2 全景圖:一次導航會流過的七大角色。
入門 · 02

全景圖

這是瀏覽器 SPA,沒有後端那種「三個 process」。取而代之,幾乎所有東西都是一次導航流過的七大角色之一的細節,外加幾個跨切面系統。

flowchart LR
    Boot["boot/
啟動引導"] --> Router["router/
+ guards"] Router --> Layouts["layouts/
外殼"] Layouts --> Pages["pages/
page groups"] Pages --> Stores["stores/
Pinia"] Stores --> API["services/api.js
$API"] Pages -.使用.-> Composables["composables/
+ helpers/"] Stores -.使用.-> Composables API --> BE[("nitra-app-api")]
七大角色:一次導航的流動路徑
角色資料夾職責
Bootsrc/boot/一次性啟動:註冊 i18n、Sentry、FontAwesome、Stripe、version-check 等(§3)
Routersrc/router/匯總各 group 路由;每次導航跑 guards(§5、§8)
Layoutssrc/layouts/頁面渲染所在的穩定外殼(側欄/標頭)(§5)
Pagessrc/pages/<Group>/實際畫面,依產品領域分組(§4)
Storessrc/stores/Pinia——所有共用、響應式狀態(§6)
APIsrc/services/api.js唯一的 HTTP 出口 $API;掛 token + 401 refresh(§7)
Composables / Helperssrc/composables/src/helpers/可重用的響應式邏輯與純工具(§11)

跨切面系統(與角色並存)

  • i18nsrc/i18n/src/boot/i18n.js)——翻譯;元件用 useI18n(),其餘用 i18n.global(§9)。
  • 主題與 white-labeluno.config.jssrc/css/<brand>.variables.scssappSettings.js)——UnoCSS token + 各品牌 build(§9)。
  • 權限permissions.js)——guards 會用到的兩層權限模型(§8)。
  • 事件匯流排與全域 dialogeventEmitter.jsGlobalDialogs.js)——解耦的跨元件溝通(§10)。
你現在能把後面每個主題擺到這張地圖上。下一章 → §3 跑起來
入門 · 03

跑起來

前置需求

  • Node.nvmrc,套件管理一律 Yarn(不用 npm/npx,見 §14)。
  • 一組有 GitHub Packages 讀權限的 GITHUB_TOKEN——app 會從 GitHub Packages 拉 @Nitra-Finance/nitra-ui-library 與 docs CLI(§13)。

步驟

yarn setup-npmrc     # 用 GITHUB_TOKEN 從 template 產出 .npmrc(首次安裝前必做)
yarn install         # 從 GitHub Packages 拉 @Nitra-Finance/nitra-ui-library
# 建立 config/.env.development(git-ignored):API_URL、GITHUB_TOKEN、APP_NAME、VUE_ROUTER_MODE…
yarn sync:docs       # 拉共用 guidelines 到 docs-nitra-fe/(git-ignored,見 §13)
yarn dev             # 開發伺服器(先跑 scripts/css-variables.js,再 quasar dev)

build/env 模型(一定要讀,大家都踩過)

三個關鍵事實

① 選 env 檔的是 BUILD_ENV,不是 NODE_ENVquasar.config.js:23-34)——非 production 時用 dotenv 載入 config/.env.developmentquasar buildNODE_ENV 永遠是 production,別拿它判斷。

build.env: process.envquasar.config.js:123)把所有環境變數暴露進前端 bundle——你放進 .env.development 的東西會被打包進 build 出來的 JS,只放公開金鑰、絕不放 secret。

③ Router modeVUE_ROUTER_MODE 決定(dev 為 hash),factory 據此選 createWebHistory/Hashsrc/router/index.js:25-27)。

部署目標

  • Staging 與 Production → Cloudflare Pages。
  • Review Apps → Heroku(index.js/server.js 的 Express server;API base 變成 /api 以保 cookie 同源,見 §7)。
app 跑起來了。下一章 → §4 Page Group:你每天會待的地方。
核心概念 · 04

The Page Group

為何以 page 分組,而非以類型

以類型分(所有元件一個資料夾、所有路由另一個),會讓一個功能散落在十幾個資料夾裡。我們改以產品領域分組:Cards 的一切都在 src/pages/CardGroup/。merge conflict 更少,就算長到幾十個 group,程式碼還是好找。

裡面有什麼

每個 group 擁有一個 routes.js(路由定義)與其頁面元件(*Page.vue)。在群組內用的元件放在它的 components/;跨群組重用的元件升級到全域 src/components/(§11、§14)。

路由是「匯總」的,不是自動發現

沒有自動發現。每個 group 的 routes.js 必須被 import 並 spread 進 src/router/routes.js 的主陣列(:56-101):

import cardGroupRoutes from 'src/pages/CardGroup/routes';
// …
const routes = [
  ...cardGroupRoutes,
  // …
  ...errorPagesRoutes,   // catch-all 必須放最後(router/routes.js:100)
];
順序很重要

ErrorPages/:catchAll(.*)*)必須 spread 在最後,否則會吞掉後面的路由。

Layout 由 route 的 component 決定,不是 meta

頂層 route 物件的 component 欄位指向一個 layout;它的 children 渲染在該 layout 的 <router-view> 內。這裡沒有 meta.layout 慣例

{
  path: '/',
  component: () => import('layouts/MainLayout/MainLayout.vue'),  // ← 選 layout
  beforeEnter: [authGuardLoggedInRoute()],                       // ← guard(見 §5/§8)
  children: [ /* 頁面渲染在 MainLayout 內 */ ],
}
一個 page group 宣告了路由、guards、layout。下一章 → §5:導航到這條路由時,實際發生什麼。
核心概念 · 05 — 全文主軸

★ 導航 / 渲染生命週期

這是後端 request lifecycle 的前端對應版。讀懂一次,其餘每章都能掛回這條路徑。

1
URL / 導航
使用者點連結、router.push、深連結或重整。
全域 beforeEachsetPageLoading(true)
2
Router Guards beforeEnter[]
依序執行,第一個幾乎都是 session guard。
authGuardLoggedInRoute()services/auth.js:51)— 讀 refresh token、載入 member + organization、檢查 onboarding / provider
② 權限 guards — role / org-permission / provider / beta(必須在 ① 之後
next() 通過(否則導向 login / home)
3
Layout
route.component 解析(MainLayout / AuthLayout…)。
掛載對應頁面
4
Page
onMounted 呼叫 store action(不直接打 $API)。
store action
5
Pinia Store
action 整理參數,透過 $API 發請求。
$API.<resource>.<method>()
6
$API(services/api.js)
call-site 掛 Authorization;withCredentials401 → refresh + 佇列重送(同時只 refresh 一次)。
HTTP 請求
7
nitra-app-api
回傳扁平 response body { status, success, … }
攔截器 unwrap → 回傳 response.data
8
響應式渲染
store 更新狀態 → Vue 重繪 + UnoCSS 品牌主題 + i18n。afterEachsetPageLoading(false)

完整路徑:URL → guard → layout → page → store → $API → render

1. 導航 → 全域 beforeEach

每次導航先進 router 全域 beforeEach,它切換一個 loading 旗標:setPageLoading(true)src/router/index.js:46-58);afterEach 再切回(:55)。登入/權限的判斷不在這裡,而在各 route 的 guard(下一步)。

2. 各 route 的 guards(beforeEnter[]

每條受保護路由在 beforeEnter 陣列列出 guards,由左到右執行。第一個幾乎都是 authGuardLoggedInRoute()——而它(別被名字誤導)其實在 src/services/auth.js:51-407,不在 routeGuards.js。它扛最重的事:

  • 從 cookie 讀 refresh tokenauth.js:91);無 token → next(genRedirectRoute('login', to)):381,389)。
  • 若未快取則載入 authStore.memberorganizationrolePermissions:109-129)。
  • 驗證 email/手機確認與 active 狀態(:135-169)。
  • 處理 ?organizationId= 切換組織(:175-202)與 onboarding 導向(產品/條款未完成時)(:254-277)。
  • 驗證該 org 的 provider 與當前 white-label build 相符;不符則用一次性 token 做整頁跳轉window.location.href,hard redirect,:207-233)。
  • 載入背景資料:非關鍵走 Promise.allSettled(不阻擋),關鍵走 Promise.all:330,358)。

權限 guards 在它之後跑(它們依賴 member/organization 已被載入,見 §8)。這個順序是強制的,程式碼內有註解(routeGuards.js:45)。

為何用 refresh token、而非 access token 來把關進入?

access token 可能已過期、但 refresh token 仍有效——檢查 refresh token 是否存在,就能在不發請求的情況下回答「這人有可能是登入的嗎?」。較輕量的 softAuthGuard()auth.js:492-515)則檢查 access token,用於「公開但個人化」的頁面。

3. Layout → Page 掛載

guards 若 next(),router 解析 route.component(layout,§4)並在其中掛載對應頁面。頁面通常在 onMounted 呼叫 store action 拉資料。頁面不直接呼叫 $API——它走 store(§6)。

4. Store → $API → 後端(含 401 refresh)

src/services/api.js 是唯一的 HTTP 出口。兩件事讓它特別:

  • 沒有 request 攔截器。 Authorization header 在 call-site helper(apiPostAuth,api.js:255)掛——它讀 JWT 的 entityType,經 AUTHENTICATION_HEADER_PREFIX 映射成 jwt-member/jwt-martmember/jwt-supermemberapi.js:227-241)。withCredentials: true 帶 cookie。
  • response 攔截器 + 401 refresh 佇列api.js:31,46-63,197-)。成功時unwrap response.data,呼叫端直接拿到 body。401 時 refresh:
// api.js — 簡化示意
let isRefreshing = false;
let refreshSubscribers = [];   // refresh 期間撞到 401 的請求,排隊在此
// 第一個 401 設 isRefreshing 並呼叫 refreshAuthToken();其餘 subscribe,
// 等新 token 下來再重送。只 refresh 一次,而非 N 次。
兩個從原始碼驗證的真實坑

① 一般 member 的 refresh 只在「非 JSON」的 401 回應觸發api.js:197);super-member 路徑沒這限制。在 debug「為什麼沒 refresh?」前先知道這點。

② 佇列存在是因為後端有 refresh-token reuse detection——重送已輪替過的 refresh token 會撤銷所有 session。isRefreshing + refreshSubscribers 保證同時只會有一個 refresh 在跑。

5. Response → store → 響應式渲染

攔截器回傳 response.data;store 寫入響應式狀態;Vue 重繪頁面。錯誤以 error?.response?.data || error 形式(api.js:222)拋出,並透過標準錯誤 UI(ErrorBanner / BasicAlert / useNotifyFailed,§11/§14)呈現——絕不丟原始錯誤。

一句話記住

URL → beforeEach(loading)→ beforeEnter[](先 auth 後權限)→ layout → page → store → $API(token + 401 refresh)→ response.data → 響應式渲染。

步驟 3–5 依賴 store 與 $API。接下來兩章拆開這兩塊來看:§6 State 層,然後 §7 API 層。
核心概念 · 06

State 層(Pinia)

所有共用狀態都是 Pinia,且一律 setup 風格defineStore(id, () => {…}))——不用 options 風格。Pinia 在 src/stores/index.js 建立並掛 Sentry plugin。

Store 名冊

Store負責file:line
authStoresession、member、organization、role permissions、已連結的 bank/Stripe 帳戶authStore.js:39
mainStore版面狀態(drawer/側欄)、active 產品選單、全域通知、幣別mainStore.js:48
productsStore產品目錄、各 org 可用性、onboarding 導向狀態productsStore.js:26
onboardingGuideStoreonboarding 步驟與進度(已建卡、已邀請隊友…)onboardingGuideStore.js:32
approvalWorkflowStore審批規則 + lookup map(部門/成員/商家/類別)approvalWorkflowStore.js:45
gamificationStore任務、消費里程碑、進度gamificationStore.js:30
redirectNotifyStore活動導向通知(Quasar Notify 生命週期)redirectNotifyStore.js:16

authStore 深入(guards 依賴的那個)

  • Token 存在 cookie,不在 store state。 setAccessTokenAndRefreshToken()authStore.js:191)寫 tokenrefresh-token cookie,sameSite:'Strict'secure:true,到期時間從 JWT 解出。Admin 與 super-member token 各有自己的 cookie 對(:207-267)。
  • Guards 讀 getter,不讀 cookie。 isAuthenticated = !!member.value?.id:90);memberOrganization:102)、rolePermission:118,reduce 成扁平 {permission: bool})、checkProvider:661)、checkBetaUser:622)。這些就是 §8 guards 會呼叫到的 getter。
  • 登出是跨 store reset。 resetSessionStates():562)遍歷每個 active Pinia store 並呼叫其選用的 $resetStates()——所以乾淨登出依賴每個 store 都實作該 hook(七個都有)。

狀態持久化模型

狀態不會自動存到 localStorage——重整時由 guards 從 API 重新載入。例外:token(cookie)與 2FA 臨時 session(雙存於 Pinia + sessionStorage,經 hydrateTwoFactorSession() 重建,authStore.js:746)。

付款/銀行狀態(stripeBankAccountsdefaultCardPaymentBank是 lazy-load 的——沒先呼叫 fetch action 就去檢查它的 gate,永遠看到 null。先 fetch,再 gate。

Store 透過唯一的出口發請求。下一章 → §7:那個出口。
核心概念 · 07

API 層($API

axios 實例

src/services/api.js:21-24 建立一個 axios 實例。baseURL 是條件式的:14-19):Heroku review-app 主機名(/-pr-\d+\.herokuapp\.com$/)用 /api(讓 refresh-token cookie 在 FE Express server 後維持同源);其餘用 process.env.API_URL。所有請求 withCredentials: true

一次呼叫怎麼發

$API 是一大棵 resource 物件樹,直接 import(import $API from 'src/services/api.js')。每個方法委派給 call-site helper:

HelperAuthHeader prefixfile:line
apiPost()api.js:244
apiPostAuth()動態,依 JWT entityTypeapi.js:255
apiPostFormData()硬寫 jwt-memberapi.js:313
apiPostAuthSuperMember()super-member硬寫 jwt-supermemberapi.js:352

Header 格式 Authorization: <prefix> <token>——對應後端的 jwt-<type> scheme。

Response 與 error 形狀(呼叫端實際拿到什麼)

  • 成功:攔截器回傳 response.dataapi.js:34)。所以 await $API.members.read() resolve 的是 body——例如 { member, organization },不是 axios 外殼。
  • 失敗:error?.response?.data || errorapi.js:222)。後端形狀是 { success:false, status, error, message }——branch 在機器碼 error,絕不比對人類 message(§14)。

401 refresh 流程

已在 §5 步驟 4 說明。重點:同時只 refresh 一次(isRefreshing + refreshSubscribers,api.js:46-63);一般 member 只在非 JSON 401 觸發(:197);super-member 有獨立、平行的佇列(:134-144);重送用裸 axios.request 繞過攔截器以避免遞迴(:110-131)。

規則

所有 HTTP 用 $API絕不在元件裡 import 裸 axios/fetchfe-api-safety 規則)。呼叫包 try/catch(或交給 store 集中處理),錯誤用標準 UI 呈現。

§5 步驟 2 的 guards 讀權限決定能否進入。下一章 → §8:那套權限模型怎麼運作。
核心概念 · 08

權限與路由守衛

兩層權限

src/composables/permissions.jsusePermissions())暴露兩個獨立層,皆從 authStorestoreToRefs 響應式取得:

  1. Member-organization 權限——產品層存取(如 BILLPAY_FULL_ACCESS)。用 checkMemberOrganizationPermission(...) 檢查。
  2. Role 權限——細到單一操作的存取權限(如 CARD_CREATE_ALL_CARDS)。用 checkRolePermission(...) 檢查。

物件層級的檢查有層級:ALL > TEAM(同一條管理線,含自己) > SELF > NONEpermissions.js:159-183)。checkCanUpdateMemberCard(member) 之類的 helper 包住它。

各 guard

Guards 是 src/router/routeGuards.js 的 factory(各回傳 (to, from, next) 函式),加上 src/services/auth.js 裡那個大的 session guard:

Guard檢查不通過file:line
authGuardLoggedInRoute()session + 載入 member/org導向 loginservices/auth.js:51
authGuardLoggedOutRoute()已登入 → 導離 login 頁導 home / 登入後目標services/auth.js:413
softAuthGuard()允許兩種狀態(公開+個人化)放行services/auth.js:492
routeGuardRolePermissions(...)role 權限白名單提示 + 導 homerouteGuards.js:84
routeGuardMemberOrganizationPermissions(...)org 權限白名單提示 + 導 homerouteGuards.js:47
routeGuardRoles(...)member role 白名單提示 + 導 homerouteGuards.js:104
routeGuardProviders(...)卡片 provider 允許/拒絕靜默導 homerouteGuards.js:149
routeGuardBetaUser(...)beta 功能旗標靜默導 homerouteGuards.js:129
routeGuardProductionOrganizations(...)正式環境 org 白名單導 homerouteGuards.js:27
routeGuardNitraApp()僅 Nitra build(white-label gate)導 homerouteGuards.js:189
routeGuardSuperMember()super-member cookie 存在導 homerouteGuards.js:170
最重要的順序規則

權限 guards 必須列在 authGuardLoggedInRoute() 之後(同 route 的 beforeEnter 或子路由)。它們要讀 authStore.memberOrganization/rolePermission,而那是 session guard 載入的。順序顛倒就會讀到 undefined。規則記在 routeGuards.js:45 註解。

FE 心智模型

權限在路由邊界強制,不是散落的 v-if。你能開的頁面就是你被允許開的頁面;v-if/canCreateCard 之後只是細化頁面內的操作可見性。

Guards 與頁面都渲染品牌化、已翻譯的 UI。下一章 → §9:i18n、主題,以及一份程式碼如何變成多品牌。
跨切面 · 09

i18n、主題與 White-Labeling

i18n

  • 全域實例在 src/boot/i18n.js;locale bundle 是 src/i18n/en-US/index.js,它import 每個 page group 的 i18n/en-US.js 並 spread 在一起:8-43)。全域字串用 g[key] 前綴,模組字串用 <module>[key] 前綴。
  • 規則:元件const { t } = useI18n()。在 composable、store、helper、guard(非元件)裡 import { i18n } from 'src/boot/i18n.js'; const { t } = i18n.global。混用會破壞 scope 與 hot-reload(§14)。

主題(UnoCSS)

uno.config.js 擴充 UI library 的 token——import { uiTheme, uiExtendTheme, uiShortcuts } from '@Nitra-Finance/nitra-ui-library/unocss'——再 spread 進 themeextendThemeshortcutsuno.config.js:2,24,43,45)。用對應 token 的 UnoCSS utility class 設計樣式(bg-primarytext-gray-600gap-4);罕見的自訂 class 用 BEM(§14)。

White-labeling(一份程式碼,五個品牌)

品牌在 build timeAPP_NAME 決定(預設 nitra,另有 trueaestheticspremiereaestheticstomoexponent——constants.js:245-251)。不能 runtime 切換。它切換的東西:

切換什麼機制file:line
顯示名稱 / <title>appDisplayName.mjsquasar.config.js htmlVariablesappDisplayName.mjs:1quasar.config.js:313
Quasar SCSS 顏色scripts/css-variables.js 在 build 前把 src/css/<brand>.variables.scss 複製成 quasar.variables.scssscripts/css-variables.js:27
聯絡資訊、卡片圖useAppContact()useAppCardImages()useAppName() 取值appSettings.js:213,170
功能 gatingNITRA_PRODUCTS_NOT_AVAILABLE + useProductAvailable()constants.js:119appSettings.js:155
Nitra 專屬品牌資產Vite plugin 在 APP_NAME !== 'nitra' 時刪除 /public/brand/quasar.config.js:49-63

useIsNitraApp()appSettings.js:130,即 useAppName() === defaultAppName)是元件與 routeGuardNitraApp 用的 runtime 品牌判斷。

(1) scripts/css-variables.js 必須在 Quasar 編譯跑,否則顏色錯——yarn dev/build script 已串好。(2) 這裡的功能 gating 是前端;後端也必須強制。(3) 每個 useAppSetting({...}) map 都要有 nitra key,否則 fall through 變壞。

散落各頁的元件仍需溝通。下一章 → §10:解耦溝通。
跨切面 · 10

跨元件溝通

事件匯流排

src/helpers/eventEmitter.js 是一個小的 EventEmitter class(on/off/emit/once/removeAllListeners),以 Map 支撐。listener 的錯誤會被 catch,所以一個壞 handler 不會中斷整條鏈(:144)。

沒有自動清理

onMounted 訂閱,onBeforeUnmount 取消訂閱emitter.off(...)),否則跨導航洩漏 listener。

全域 Dialog

單一 app 層 dialog 由事件驅動,不靠 prop 層層下傳:

  • src/utils/eventEmitter.js 匯出 CommonEventEmitter + 事件名(OPEN_GLOBAL_BASIC_DIALOGCLOSE_GLOBAL_BASIC_DIALOG)。
  • src/components/DialogComponents/GlobalDialogs.jsBasicDialogGlobal())監聽並渲染那唯一一個 dialog。
  • App.vue 掛載一次 <BasicDialogGlobal />,旁邊還有 AutoLogoutDialogPageLinearLoader、UI-library toast stack 與 <router-view />
import { CommonEventEmitter, COMMON_EVENT_NAMES } from 'src/utils/eventEmitter';
CommonEventEmitter.emit(COMMON_EVENT_NAMES.OPEN_GLOBAL_BASIC_DIALOG, {
  title: '確認', caption: '確定嗎?', onOk: () => {/* … */},
});

一次性、頁面內的 modal,用 Quasar $q.dialog()DialogComponents/ 裡的 RightDialog/CenterDialog/BasicDialog wrapper。

匯流排、dialog、權限都是 composable/helper。下一章 → §11:這一層的規則。
跨切面 · 11

Composables 與 Helpers

界線

  • Composablesrc/composables/)= 響應式邏輯——用 ref/computed/watch/生命週期。函式名帶 use 前綴(useNotifyFailed);檔名不帶notify.js)。
  • Helpersrc/helpers/)= 純、非響應式工具(logic.jsconstants.js)。
  • 模組專屬版本放在 page group 下(src/pages/<Group>/composables/)。

你會一直用到的那些

名稱用途file:line
usePermissions()guards 與 template 用的權限檢查(§8)composables/permissions.js:53
useNotifySuccess/Failed/Warning()標準 toast——顯示使用者訊息的唯一方式composables/notify.js:34
useIsNitraApp()useAppSetting()useAppContact()white-label 品牌面(§9)composables/appSettings.js:130
useRole()角色檢查(isAdmininRoles()、label)composables/role.js:145
usePagination()前端分頁,含邊界 clampcomposables/usePagination.js:94
useSocket()Socket.io client,含 token 過期重連composables/useSocket.js:12
LOGIC.*(helper)幣別/日期/大小寫 formatter;guards 用的 normalizeArgshelpers/logic.js
constantsAPP_NAMEMEMBER_ROLEROLE_PERMISSIONNITRA_PRODUCTDATE_FORMAThelpers/constants.js
命名特例

useNotifySuccess() 這些是直接呼叫的函式,不是會「回傳物件」的 composable。照既有 pattern 走,別硬把它們改成回傳物件的形式。

你已知道每個角色與規則。下一章 → §12:把它們組裝成一個新的 page group。
實作 · 12

Worked Example:真實 Page Group 端到端

不自己編,直接追一個真實的 group——TeamGroup → Members——走過 §5 的完整生命週期。以下每個檔案都是真的,可對照打開。

/team   → MainLayout · beforeEnter: [authGuardLoggedInRoute()]        (TeamGroup/routes.js:20-28)
  └ /members        → MembersPage.vue                                  (routes.js:30-)
      └ /members/invite → beforeEnter: [routeGuardRolePermissions(ROLE_PERMISSION_GROUP.MEMBERINVITE_INVITE)]

1. Route + guards(src/pages/TeamGroup/routes.js)——component 選 layout,先 session guard,nested route 上掛權限 guard

import { authGuardLoggedInRoute } from 'src/services/auth';                 // routes.js:9
import { routeGuardRolePermissions } from 'src/router/routeGuards';         // routes.js:10
import { ROLE_PERMISSION_GROUP } from 'src/constants/permissions';          // routes.js:15

{
  path: '/team',
  name: PAGES.MENU_ROUTE_NAME.TEAM,
  component: () => import('layouts/MainLayout/MainLayout.vue'),  // layout
  redirect: { name: 'team-members' },
  beforeEnter: [authGuardLoggedInRoute()],                       // 先 session
  children: [{
    name: 'team-members',
    path: 'members',
    component: () => import('src/pages/TeamGroup/MembersPages/MembersPage.vue'),
    children: [{
      name: 'team-member-invite',
      path: 'invite',
      beforeEnter: [routeGuardRolePermissions(ROLE_PERMISSION_GROUP.MEMBERINVITE_INVITE)], // 後權限
    }],
  }],
}

2. 頁面在 onMounted 呼叫 store actions(MembersPages/MembersPage.vue

import { useI18n } from 'vue-i18n';                                                       // :142
import { useMembersStore } from 'src/pages/TeamGroup/MembersPages/stores/membersStore.js'; // :112
import { useNotifyFailed } from 'src/composables/notify';

const { t } = useI18n();
const membersStore = useMembersStore();
const { getMembers, getMemberOrganizationPermissions } = membersStore;                    // :162

onMounted(async () => {                                                                   // :301-313
  try {
    await Promise.all([getMemberOrganizationPermissions(), getMembers()]);
  } catch (error) {
    useNotifyFailed({ message: error.message });
  }
});
注意

這個 store 是模組層 store——住在 group 下的 MembersPages/stores/membersStore.js,不在全域 src/stores/。兩種都存在(§6 講的是 7 個全域 store);只有單一 group 用到的狀態就 co-locate 在群組內。

3. Store action 打 $APIMembersPages/stores/membersStore.js

import $API from 'src/services/api';                                  // :24
export const useMembersStore = defineStore('membersStore', () => {    // :25
  async function getMembers() {                                       // :203-258
    // …組 params…
    const response = await $API.members.query(params);               // 唯一的 HTTP 出口
    // …寫入響應式狀態…
  }
});

4. 用 i18n 渲染

t('members[title]');字串在 MembersPages/i18n/en-US.js'members[title]': 'Members'),spread 進全域 bundle(§9)。

整條鏈,全是真的:TeamGroup/routes.js(guards)→ MembersPage.vueonMounted)→ membersStore.jsgetMembers)→ $API.members.query() → 用 t('members[…]') 響應式渲染——正是 §5 的路徑。

要做你自己的

同樣形狀:新 src/pages/<Group>/ 配一個 routes.jscomponent 選 layout;authGuardLoggedInRoute() 後接權限 guards),spread 進 src/router/routes.js 且在 errorPagesRoutes 之前;一個在 onMounted 呼叫 store action 的頁面;一個 action 會打 $API 的 store(全域模組層);以及 spread 進 src/i18n/en-US/index.js 的模組 i18n。

你能做功能了。下一章 → §13:AI 工具與共用文件系統如何維持團隊一致。
實作 · 13

AI Agent 與文件系統

三 repo 的關係

flowchart TD
    FED["@Nitra-Finance/nitra-fe-docs
共用 guidelines + AI 工具"] UIL["@Nitra-Finance/nitra-ui-library
元件 + UnoCSS tokens + agent-docs"] APP["nitra-app-ui
(這個 app)"] FED -- "yarn sync:docs (npm CLI)" --> APP FED -- "sync" --> UIL UIL -- "GitHub Packages import" --> APP
nitra-fe-docs 供規範、nitra-ui-library 供元件,兩者都被 nitra-app-ui 使用
  • nitra-fe-docs共用 guidelines + AI 工具的單一 source of truth,發布為 npm 套件 @Nitra-Finance/nitra-fe-docs。它的 sync CLI(scripts/cli.jsscripts/sync-docs.js)把 docs/en/ 複製進使用端的 docs-nitra-fe/,也同步 .github/.cursor/.claude/.gemini/ template——依 project group 過濾(PROJECT_GROUP_REPOS:APP / UI_LIBRARY / DOCUMENTATION)。本 repo 的 yarn sync:docs = npx @Nitra-Finance/nitra-fe-docs@latest syncpackage.json:21)。
  • nitra-ui-library 提供共用元件與設計 token,發布到 GitHub Packages。本 app 從 @Nitra-Finance/nitra-ui-library import 元件(UiButtonUiModal…)、從 @Nitra-Finance/nitra-ui-library/unocss import token。它的 agent-docs/figma-component-mapping.md 是 Figma→程式碼映射的 source of truth。
  • nitra-app-ui 同時用到上述兩者。
docs-nitra-fe/ 是 git-ignored,每次 sync 全覆蓋

clone 後要跑 yarn sync:docs,否則共用 guidelines 不存在;絕不手改 docs-nitra-fe/ 下的檔案——下次 sync 會被蓋掉。要改去改 nitra-fe-docs 的來源。

AI 工具

.claude/.cursor/ 的 rules/skills 是從 nitra-fe-docs 同步、不在各專案 fork(只有 .claude/settings.json 是本地)。.claude/rules/ 定義不可違反的規範(tech stack、code structure、API safety、命名);skills(/fe-reviewer/fe-scanner/fe-verifier…)是任務 playbook。從 Figma 實作時,fe-figma-workflow 規則會先拉 UI-library 元件映射。

這就是整個系統。其餘為參考。
參考 · 14 — 合成自同步的 docs-nitra-fe 與 .claude/rules

慣例速查

技術棧(不可違反)

Vue 3 Composition API + <script setup> only;app 程式碼只用 JavaScript(無 TS);Pinia(setup 風格);UnoCSS(自訂 class 用 BEM);只用 Yarn;ESLint 9 flat + StandardJS(無 Prettier);測試用 Cypress。

命名

  • 元件 PascalCaseBaseButton.vue);頁面 *Page.vue;群組 *Group//*Pages/;store camelCase+Store;composable 檔名 camelCaseuse 前綴;composable 函式 use*
  • Props/變數/函式 camelCase;template 事件 kebab-case;布林 is/has/can/should;template ref *Ref;常數 UPPER_SNAKE_CASE;route name kebab-case;CSS BEM kebab-case

Import

src/ 絕對路徑(import X from 'src/...');相對路徑只給同層檔案。順序:Vue/Quasar → 元件 → store → composable → service → 常數 → helper → 第三方。

元件

全域、無邏輯、Base*src/components/;模組層、業務命名放 src/pages/<Group>/components/。SFC 區塊順序:<docs><template><script setup><style>

API 安全

所有 HTTP 走 $API;每次呼叫 try/catch 或 store 集中處理;友善錯誤用 ErrorBanner/BasicAlert/ValidatedField/useNotifyFailed(不丟原始);branch 在機器碼 error 而非 message;response optional-chain;unmount 時取消 pending 請求。

i18n

字串放 src/i18n/,絕不 inline;元件用 useI18n(),其餘用 i18n.global

HTTP response 形狀(來自 nitra-app-api)

扁平,無 data 巢狀。成功 { status, success, <具名內容> };錯誤 { success:false, status, error, message }。列表:page/limit/sort(逗號 list,-=降冪)/filter(逗號=IN),回 total

參考 · 15

附錄

術語表(Glossary)

術語意義
Page groupsrc/pages/ 下的資料夾,擁有一個產品領域的 routes + 頁面 + 模組元件。
Layout頁面渲染所在的外殼(側欄/標頭);由 route 的 component 選定。
GuardbeforeEnter[] 裡的 (to,from,next) 函式,放行或導向一次導航。
$API唯一的 axios HTTP service(src/services/api.js)。
Boot filesrc/boot/ 的一次性啟動註冊,順序由 quasar.config.js 決定。
Providerorg 所屬的發卡方 / white-label 夥伴(Nitra、Tomo、Exponent…)。
Soft auth允許登入與未登入兩種狀態的 guard。
docs-nitra-fe/nitra-fe-docs 同步、git-ignored 的共用 guidelines 副本。

指令速查

yarn setup-npmrc        # 用 GITHUB_TOKEN 產出 .npmrc(首次安裝前)
yarn dev                # 開發伺服器(先 css-variables.js → quasar dev)
yarn dev:pwa            # PWA 模式開發
yarn build              # 正式 build      |  yarn build:pwa
yarn lint  / lint:fix   # lint(StandardJS)|  yarn lint src/pages/Foo(限定範圍)
yarn cy:open            # Cypress runner
yarn sync:docs          # 拉共用 guidelines 進 docs-nitra-fe/
APP_NAME=tomo yarn build   # build 特定 white-label 品牌

Repo 地圖

路徑是什麼
src/boot/啟動註冊(i18n、axios、sentry、stripe、version-check…)
src/router/index.js factory · routes.js 匯總 · routeGuards.js · redirect.js
src/services/api.js$API)· auth.js(真正的 session guard)
src/stores/7 個 Pinia store + index.js(pinia + Sentry plugin)
src/layouts/8 個 layout(MainLayout 有 Desktop/Mobile 拆分)
src/pages/29 個 page group
src/composables/ src/helpers/響應式邏輯 · 純工具 + constants.js + eventEmitter.js
src/components/~36 個依領域分組的 *Components/ 資料夾
uno.config.js · src/css/<brand>.variables.scss主題 token · 各品牌 SCSS
quasar.config.jsboot 順序、plugins、build env、white-label Vite plugins

Troubleshooting — 常見坑(皆上文驗證過)

  • 共用 guidelines 不見 / docs-nitra-fe 是空的 → 跑 yarn sync:docs(它是 git-ignored)。
  • yarn install@Nitra-Finance/* 401GITHUB_TOKEN/.npmrc 沒設好;跑 yarn setup-npmrc
  • 品牌顏色錯scripts/css-variables.js 沒在 Quasar 前跑;用 yarn dev/build script。
  • 權限 guard 看到 undefined → 它列在 authGuardLoggedInRoute() 之前;移到後面(§8)。
  • 「token 沒 refresh」 → 一般 member 的 refresh 只在非 JSON 401 觸發(api.js:197)。
  • 環境變數沒進 app → 它必須在 build time 存在;build.env: process.env 只捕捉當下設定的。

你現在對 Nitra App UI 有了端到端的理解——boot、導航/渲染生命週期、stores、$API、guards/權限、i18n/主題/white-label、跨元件溝通——以及如何用這一切建一個 page group。更深的參考是 README.md 與同步下來的 docs-nitra-fe/ guidelines。歡迎加入。 ↑ 回到頂部