導覽
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全景圖
這是瀏覽器 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")]
七大角色:一次導航的流動路徑
| 角色 | 資料夾 | 職責 |
|---|---|---|
| Boot | src/boot/ | 一次性啟動:註冊 i18n、Sentry、FontAwesome、Stripe、version-check 等(§3) |
| Router | src/router/ | 匯總各 group 路由;每次導航跑 guards(§5、§8) |
| Layouts | src/layouts/ | 頁面渲染所在的穩定外殼(側欄/標頭)(§5) |
| Pages | src/pages/<Group>/ | 實際畫面,依產品領域分組(§4) |
| Stores | src/stores/ | Pinia——所有共用、響應式狀態(§6) |
| API | src/services/api.js | 唯一的 HTTP 出口 $API;掛 token + 401 refresh(§7) |
| Composables / Helpers | src/composables/、src/helpers/ | 可重用的響應式邏輯與純工具(§11) |
跨切面系統(與角色並存)
- i18n(
src/i18n/、src/boot/i18n.js)——翻譯;元件用useI18n(),其餘用i18n.global(§9)。 - 主題與 white-label(
uno.config.js、src/css/<brand>.variables.scss、appSettings.js)——UnoCSS token + 各品牌 build(§9)。 - 權限(
permissions.js)——guards 會用到的兩層權限模型(§8)。 - 事件匯流排與全域 dialog(
eventEmitter.js、GlobalDialogs.js)——解耦的跨元件溝通(§10)。
跑起來
前置需求
- 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_ENV(quasar.config.js:23-34)——非 production 時用 dotenv 載入 config/.env.development;quasar build 時 NODE_ENV 永遠是 production,別拿它判斷。
② build.env: process.env(quasar.config.js:123)把所有環境變數暴露進前端 bundle——你放進 .env.development 的東西會被打包進 build 出來的 JS,只放公開金鑰、絕不放 secret。
③ Router mode 由 VUE_ROUTER_MODE 決定(dev 為 hash),factory 據此選 createWebHistory/Hash(src/router/index.js:25-27)。
部署目標
- Staging 與 Production → Cloudflare Pages。
- Review Apps → Heroku(
index.js/server.js的 Express server;API base 變成/api以保 cookie 同源,見 §7)。
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 內 */ ],
}★ 導航 / 渲染生命週期
這是後端 request lifecycle 的前端對應版。讀懂一次,其餘每章都能掛回這條路徑。
router.push、深連結或重整。beforeEach → setPageLoading(true)beforeEnter[]authGuardLoggedInRoute()(services/auth.js:51)— 讀 refresh token、載入 member + organization、檢查 onboarding / providernext() 通過(否則導向 login / home)route.component 解析(MainLayout / AuthLayout…)。onMounted 呼叫 store action(不直接打 $API)。$API 發請求。$API(services/api.js)Authorization;withCredentials。401 → refresh + 佇列重送(同時只 refresh 一次)。{ status, success, … }。response.dataafterEach → setPageLoading(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 token(
auth.js:91);無 token →next(genRedirectRoute('login', to))(:381,389)。 - 若未快取則載入
authStore.member、organization、rolePermissions(: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)。
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 攔截器。
Authorizationheader 在 call-site helper(apiPostAuth,api.js:255)掛——它讀 JWT 的entityType,經AUTHENTICATION_HEADER_PREFIX映射成jwt-member/jwt-martmember/jwt-supermember(api.js:227-241)。withCredentials: true帶 cookie。 - response 攔截器 + 401 refresh 佇列(
api.js:31,46-63,197-)。成功時unwrapresponse.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 → 響應式渲染。
$API。接下來兩章拆開這兩塊來看:§6 State 層,然後 §7 API 層。State 層(Pinia)
所有共用狀態都是 Pinia,且一律 setup 風格(defineStore(id, () => {…}))——不用 options 風格。Pinia 在 src/stores/index.js 建立並掛 Sentry plugin。
Store 名冊
| Store | 負責 | file:line |
|---|---|---|
authStore | session、member、organization、role permissions、已連結的 bank/Stripe 帳戶 | authStore.js:39 |
mainStore | 版面狀態(drawer/側欄)、active 產品選單、全域通知、幣別 | mainStore.js:48 |
productsStore | 產品目錄、各 org 可用性、onboarding 導向狀態 | productsStore.js:26 |
onboardingGuideStore | onboarding 步驟與進度(已建卡、已邀請隊友…) | 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)寫token與refresh-tokencookie,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)。
付款/銀行狀態(stripeBankAccounts、defaultCardPaymentBank)不是 lazy-load 的——沒先呼叫 fetch action 就去檢查它的 gate,永遠看到 null。先 fetch,再 gate。
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:
| Helper | Auth | Header prefix | file:line |
|---|---|---|---|
apiPost() | 無 | — | api.js:244 |
apiPostAuth() | 有 | 動態,依 JWT entityType | api.js:255 |
apiPostFormData() | 有 | 硬寫 jwt-member | api.js:313 |
apiPostAuthSuperMember() | super-member | 硬寫 jwt-supermember | api.js:352 |
Header 格式 Authorization: <prefix> <token>——對應後端的 jwt-<type> scheme。
Response 與 error 形狀(呼叫端實際拿到什麼)
- 成功:攔截器回傳
response.data(api.js:34)。所以await $API.members.read()resolve 的是 body——例如{ member, organization },不是 axios 外殼。 - 失敗:
error?.response?.data || error(api.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/fetch(fe-api-safety 規則)。呼叫包 try/catch(或交給 store 集中處理),錯誤用標準 UI 呈現。
權限與路由守衛
兩層權限
src/composables/permissions.js(usePermissions())暴露兩個獨立層,皆從 authStore 經 storeToRefs 響應式取得:
- Member-organization 權限——產品層存取(如
BILLPAY_FULL_ACCESS)。用checkMemberOrganizationPermission(...)檢查。 - Role 權限——細到單一操作的存取權限(如
CARD_CREATE_ALL_CARDS)。用checkRolePermission(...)檢查。
物件層級的檢查有層級:ALL > TEAM(同一條管理線,含自己) > SELF > NONE(permissions.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 | 導向 login | services/auth.js:51 |
authGuardLoggedOutRoute() | 已登入 → 導離 login 頁 | 導 home / 登入後目標 | services/auth.js:413 |
softAuthGuard() | 允許兩種狀態(公開+個人化) | 放行 | services/auth.js:492 |
routeGuardRolePermissions(...) | role 權限白名單 | 提示 + 導 home | routeGuards.js:84 |
routeGuardMemberOrganizationPermissions(...) | org 權限白名單 | 提示 + 導 home | routeGuards.js:47 |
routeGuardRoles(...) | member role 白名單 | 提示 + 導 home | routeGuards.js:104 |
routeGuardProviders(...) | 卡片 provider 允許/拒絕 | 靜默導 home | routeGuards.js:149 |
routeGuardBetaUser(...) | beta 功能旗標 | 靜默導 home | routeGuards.js:129 |
routeGuardProductionOrganizations(...) | 正式環境 org 白名單 | 導 home | routeGuards.js:27 |
routeGuardNitraApp() | 僅 Nitra build(white-label gate) | 導 home | routeGuards.js:189 |
routeGuardSuperMember() | super-member cookie 存在 | 導 home | routeGuards.js:170 |
權限 guards 必須列在 authGuardLoggedInRoute() 之後(同 route 的 beforeEnter 或子路由)。它們要讀 authStore.memberOrganization/rolePermission,而那是 session guard 載入的。順序顛倒就會讀到 undefined。規則記在 routeGuards.js:45 註解。
權限在路由邊界強制,不是散落的 v-if。你能開的頁面就是你被允許開的頁面;v-if/canCreateCard 之後只是細化頁面內的操作可見性。
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 進 theme、extendTheme、shortcuts(uno.config.js:2,24,43,45)。用對應 token 的 UnoCSS utility class 設計樣式(bg-primary、text-gray-600、gap-4);罕見的自訂 class 用 BEM(§14)。
White-labeling(一份程式碼,五個品牌)
品牌在 build time 由 APP_NAME 決定(預設 nitra,另有 trueaesthetics、premiereaesthetics、tomo、exponent——constants.js:245-251)。不能 runtime 切換。它切換的東西:
| 切換什麼 | 機制 | file:line |
|---|---|---|
顯示名稱 / <title> | appDisplayName.mjs → quasar.config.js htmlVariables | appDisplayName.mjs:1、quasar.config.js:313 |
| Quasar SCSS 顏色 | scripts/css-variables.js 在 build 前把 src/css/<brand>.variables.scss 複製成 quasar.variables.scss | scripts/css-variables.js:27 |
| 聯絡資訊、卡片圖 | useAppContact()、useAppCardImages() 依 useAppName() 取值 | appSettings.js:213,170 |
| 功能 gating | NITRA_PRODUCTS_NOT_AVAILABLE + useProductAvailable() | constants.js:119、appSettings.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 變壞。
跨元件溝通
事件匯流排
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_DIALOG、CLOSE_GLOBAL_BASIC_DIALOG)。src/components/DialogComponents/GlobalDialogs.js(BasicDialogGlobal())監聽並渲染那唯一一個 dialog。App.vue掛載一次<BasicDialogGlobal />,旁邊還有AutoLogoutDialog、PageLinearLoader、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。
Composables 與 Helpers
界線
- Composable(
src/composables/)= 響應式邏輯——用ref/computed/watch/生命週期。函式名帶use前綴(useNotifyFailed);檔名不帶(notify.js)。 - Helper(
src/helpers/)= 純、非響應式工具(logic.js、constants.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() | 角色檢查(isAdmin、inRoles()、label) | composables/role.js:145 |
usePagination() | 前端分頁,含邊界 clamp | composables/usePagination.js:94 |
useSocket() | Socket.io client,含 token 過期重連 | composables/useSocket.js:12 |
LOGIC.*(helper) | 幣別/日期/大小寫 formatter;guards 用的 normalizeArgs | helpers/logic.js |
| constants | APP_NAME、MEMBER_ROLE、ROLE_PERMISSION、NITRA_PRODUCT、DATE_FORMAT… | helpers/constants.js |
useNotifySuccess() 這些是直接呼叫的函式,不是會「回傳物件」的 composable。照既有 pattern 走,別硬把它們改成回傳物件的形式。
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 打 $API(MembersPages/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.vue(onMounted)→ membersStore.js(getMembers)→ $API.members.query() → 用 t('members[…]') 響應式渲染——正是 §5 的路徑。
要做你自己的
同樣形狀:新 src/pages/<Group>/ 配一個 routes.js(component 選 layout;authGuardLoggedInRoute() 後接權限 guards),spread 進 src/router/routes.js 且在 errorPagesRoutes 之前;一個在 onMounted 呼叫 store action 的頁面;一個 action 會打 $API 的 store(全域或模組層);以及 spread 進 src/i18n/en-US/index.js 的模組 i18n。
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。它的syncCLI(scripts/cli.js→scripts/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 sync(package.json:21)。nitra-ui-library提供共用元件與設計 token,發布到 GitHub Packages。本 app 從@Nitra-Finance/nitra-ui-libraryimport 元件(UiButton、UiModal…)、從@Nitra-Finance/nitra-ui-library/unocssimport 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 元件映射。
慣例速查
技術棧(不可違反)
Vue 3 Composition API + <script setup> only;app 程式碼只用 JavaScript(無 TS);Pinia(setup 風格);UnoCSS(自訂 class 用 BEM);只用 Yarn;ESLint 9 flat + StandardJS(無 Prettier);測試用 Cypress。
命名
- 元件
PascalCase(BaseButton.vue);頁面*Page.vue;群組*Group//*Pages/;storecamelCase+Store;composable 檔名camelCase無use前綴;composable 函式use*。 - Props/變數/函式
camelCase;template 事件kebab-case;布林is/has/can/should;template ref*Ref;常數UPPER_SNAKE_CASE;route namekebab-case;CSSBEM 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。
附錄
術語表(Glossary)
| 術語 | 意義 |
|---|---|
| Page group | src/pages/ 下的資料夾,擁有一個產品領域的 routes + 頁面 + 模組元件。 |
| Layout | 頁面渲染所在的外殼(側欄/標頭);由 route 的 component 選定。 |
| Guard | beforeEnter[] 裡的 (to,from,next) 函式,放行或導向一次導航。 |
$API | 唯一的 axios HTTP service(src/services/api.js)。 |
| Boot file | src/boot/ 的一次性啟動註冊,順序由 quasar.config.js 決定。 |
| Provider | org 所屬的發卡方 / 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.js | boot 順序、plugins、build env、white-label Vite plugins |
Troubleshooting — 常見坑(皆上文驗證過)
- 共用 guidelines 不見 /
docs-nitra-fe是空的 → 跑yarn sync:docs(它是 git-ignored)。 yarn install在@Nitra-Finance/*401 →GITHUB_TOKEN/.npmrc沒設好;跑yarn setup-npmrc。- 品牌顏色錯 →
scripts/css-variables.js沒在 Quasar 前跑;用yarn dev/buildscript。 - 權限 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。歡迎加入。 ↑ 回到頂部