UI/UX 開發指引 目錄 文件概述 設計原則 1.1 銀行系統的安全性與一致性要求 1.2 清晰的資訊層次與導航設計 1.3 金融資料顯示與重點突顯規範 1.4 無障礙設計(WCAG 2.1 AA 級) 1.5 多語系支援 UI 元件規範 2.1 樣式系統 (Design System) 2.2 響應式設計規範 2.3 常用元件設計 UX 流程規範 3.1 表單驗證與錯誤回饋 3.2 使用者引導 (Onboarding) 3.3 搜尋、篩選與排序互動設計 3.4 資料載入與狀態設計 設計資產與交付 4.1 設計 Token 系統 4.2 元件庫架構 Vue 3 + Tailwind CSS 最佳實踐 5.1 元件命名與結構規範 5.2 Tailwind CSS 自定義配置 檢測與驗證 6.1 視覺回歸測試 6.2 無障礙檢測 6.3 使用性測試清單 性能優化指引 設計協作流程 維護與版本控制 附錄 文件概述 本指引適用於大型金融共用平台的前端 UI/UX 開發,涵蓋設計原則、UI 元件規範、UX 流程、設計資產交付等完整內容。
專案技術堆疊 前端架構: 微前端 + SPA (Single-Page App— 第一部分完成,接下來我將繼續撰寫第二部分:無障礙設計和多語系支援。
1.4 無障礙設計(WCAG 2.1 AA 級) 色彩對比度要求 正常文字: 對比度至少 4.5:1 大文字 (18pt 以上): 對比度至少 3:1 互動元件: 對比度至少 3:1 /* Tailwind CSS 無障礙色彩配置 */ .text-primary { @apply text-gray-900; /* 對比度 21:1 */ } .text-secondary { @apply text-gray-700; /* 對比度 9.2:1 */ } .bg-interactive { @apply bg-blue-600 hover:bg-blue-700 focus:bg-blue-700; } .bg-interactive:focus { @apply ring-2 ring-blue-500 ring-offset-2; } 鍵盤操作支援 Tab 順序: 邏輯性的焦點移動順序 快捷鍵: 主要功能提供鍵盤快捷鍵 焦點指示: 清晰的焦點視覺回饋 <template> <!-- 無障礙表單範例 --> <form @submit.prevent="handleSubmit" class="space-y-6"> <div> <label for="account-number" class="block text-sm font-medium text-gray-700"> 帳戶號碼 <span class="text-red-500" aria-label="必填欄位">*</span> </label> <input id="account-number" v-model="accountNumber" type="text" required aria-describedby="account-help account-error" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" :aria-invalid="hasError" /> <p id="account-help" class="mt-2 text-sm text-gray-500"> 請輸入 12 位數字帳戶號碼 </p> <p id="account-error" v-if="errorMessage" class="mt-2 text-sm text-red-600" role="alert"> {{ errorMessage }} </p> </div> </form> </template> 螢幕閱讀器支援 語義化 HTML: 使用適當的 HTML 語義標籤 ARIA 標籤: 補充無障礙資訊 替代文字: 圖片提供有意義的 alt 文字 <template> <!-- 無障礙數據表格 --> <table role="table" aria-label="帳戶交易記錄"> <caption class="sr-only"> 最近 10 筆交易記錄,包含日期、摘要、金額和餘額 </caption> <thead> <tr> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> 交易日期 </th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> 摘要 </th> <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> 金額 </th> </tr> </thead> <tbody> <tr v-for="transaction in transactions" :key="transaction.id"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> {{ formatDate(transaction.date) }} </td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> {{ transaction.description }} </td> <td class="px-6 py-4 whitespace-nowrap text-sm text-right"> <span :class="transaction.amount >= 0 ? 'text-green-600' : 'text-red-600'"> {{ formatCurrency(transaction.amount) }} </span> </td> </tr> </tbody> </table> </template> 1.5 多語系支援 語言切換機制 // i18n 配置 import { createI18n } from 'vue-i18n'; interface Messages { 'zh-TW': Record<string, any>; 'en-US': Record<string, any>; 'ja-JP': Record<string, any>; } const messages: Messages = { 'zh-TW': { common: { confirm: '確認', cancel: '取消', save: '儲存', delete: '刪除', loading: '載入中...' }, account: { balance: '帳戶餘額', transfer: '轉帳', history: '交易記錄' } }, 'en-US': { common: { confirm: 'Confirm', cancel: 'Cancel', save: 'Save', delete: 'Delete', loading: 'Loading...' }, account: { balance: 'Account Balance', transfer: 'Transfer', history: 'Transaction History' } } }; export const i18n = createI18n({ locale: 'zh-TW', fallbackLocale: 'en-US', messages }); 文字方向支援 (RTL/LTR) <template> <div :dir="currentLocale.dir" class="app-container"> <!-- 語言切換器 --> <div class="language-selector"> <select v-model="currentLanguage" @change="changeLanguage" class="block w-32 rounded-md border-gray-300" :aria-label="$t('common.selectLanguage')" > <option value="zh-TW">繁體中文</option> <option value="en-US">English</option> <option value="ar-SA">العربية</option> </select> </div> </div> </template> <script setup lang="ts"> import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; const { locale, t } = useI18n(); const localeConfig = { 'zh-TW': { dir: 'ltr', name: '繁體中文' }, 'en-US': { dir: 'ltr', name: 'English' }, 'ar-SA': { dir: 'rtl', name: 'العربية' } }; const currentLocale = computed(() => localeConfig[locale.value]); </script> <style> /* RTL 支援樣式 */ [dir="rtl"] .text-left { text-align: right; } [dir="rtl"] .text-right { text-align: left; } [dir="rtl"] .ml-4 { margin-left: 0; margin-right: 1rem; } </style> 數字與日期本地化 // 本地化格式化工具 export class LocaleFormatter { private locale: string; constructor(locale: string) { this.locale = locale; } // 金額格式化 formatCurrency(amount: number, currency: string = 'TWD'): string { const currencyMap = { 'zh-TW': 'TWD', 'en-US': 'USD', 'ja-JP': 'JPY', 'ko-KR': 'KRW' }; return new Intl.NumberFormat(this.locale, { style: 'currency', currency: currencyMap[this.locale] || currency, minimumFractionDigits: currency === 'JPY' ? 0 : 2 }).format(amount); } // 日期格式化 formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string { const defaultOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }; return new Intl.DateTimeFormat(this.locale, options || defaultOptions).format(date); } // 時間格式化 formatTime(date: Date): string { return new Intl.DateTimeFormat(this.locale, { hour: '2-digit', minute: '2-digit', hour12: this.locale === 'en-US' }).format(date); } // 百分比格式化 formatPercentage(value: number): string { return new Intl.NumberFormat(this.locale, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); } } 進階無障礙設計實作 <template> <!-- 高對比模式切換 --> <div class="accessibility-controls fixed top-4 right-4 z-50"> <button @click="toggleHighContrast" :aria-pressed="isHighContrast" class="p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" :class="isHighContrast ? 'bg-yellow-400 text-black' : 'bg-white text-gray-700 shadow-md'" aria-label="切換高對比模式" > <EyeIcon class="h-5 w-5" /> </button> <!-- 字體大小調整 --> <div class="mt-2 flex flex-col space-y-1"> <button @click="increaseFontSize" class="p-1 text-xs bg-white rounded shadow-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500" aria-label="增大字體" > A+ </button> <button @click="decreaseFontSize" class="p-1 text-xs bg-white rounded shadow-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500" aria-label="縮小字體" > A- </button> <button @click="resetFontSize" class="p-1 text-xs bg-white rounded shadow-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500" aria-label="重設字體大小" > A </button> </div> </div> <!-- 跳至主要內容連結 --> <a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-blue-600 text-white px-4 py-2 rounded-md z-50" > 跳至主要內容 </a> <!-- 焦點陷阱對話框範例 --> <div v-if="showModal" class="fixed inset-0 z-10 overflow-y-auto" role="dialog" aria-modal="true" :aria-labelledby="modalTitleId" @keydown.esc="closeModal" > <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <!-- 背景遮罩 --> <div class="fixed inset-0 transition-opacity" aria-hidden="true" @click="closeModal" > <div class="absolute inset-0 bg-gray-500 opacity-75"></div> </div> <!-- 對話框內容 --> <div ref="modalContent" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" @keydown="handleModalKeydown" > <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <h3 :id="modalTitleId" class="text-lg leading-6 font-medium text-gray-900 mb-4"> 確認操作 </h3> <p class="text-sm text-gray-500 mb-4"> 您確定要執行此操作嗎?此動作無法復原。 </p> <!-- 焦點陷阱內的可互動元素 --> <div class="flex justify-end space-x-3"> <button ref="cancelButton" @click="closeModal" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500" > 取消 </button> <button ref="confirmButton" @click="handleConfirm" class="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500" > 確認 </button> </div> </div> </div> </div> </div> </template> <script setup lang="ts"> import { ref, nextTick, onMounted, onUnmounted } from 'vue'; import { EyeIcon } from '@heroicons/vue/24/outline'; // 無障礙狀態管理 const isHighContrast = ref(false); const fontSize = ref(16); const showModal = ref(false); const modalTitleId = ref(`modal-title-${Math.random().toString(36).substr(2, 9)}`); // 高對比模式 const toggleHighContrast = () => { isHighContrast.value = !isHighContrast.value; document.documentElement.classList.toggle('high-contrast', isHighContrast.value); // 儲存使用者偏好 localStorage.setItem('high-contrast', isHighContrast.value.toString()); // 通知螢幕閱讀器 announceToScreenReader( isHighContrast.value ? '已開啟高對比模式' : '已關閉高對比模式' ); }; // 字體大小調整 const increaseFontSize = () => { if (fontSize.value < 24) { fontSize.value += 2; updateFontSize(); } }; const decreaseFontSize = () => { if (fontSize.value > 12) { fontSize.value -= 2; updateFontSize(); } }; const resetFontSize = () => { fontSize.value = 16; updateFontSize(); }; const updateFontSize = () => { document.documentElement.style.fontSize = `${fontSize.value}px`; localStorage.setItem('font-size', fontSize.value.toString()); announceToScreenReader(`字體大小已調整為 ${fontSize.value} 像素`); }; // 螢幕閱讀器公告 const announceToScreenReader = (message: string) => { const announcement = document.createElement('div'); announcement.setAttribute('aria-live', 'polite'); announcement.setAttribute('aria-atomic', 'true'); announcement.className = 'sr-only'; announcement.textContent = message; document.body.appendChild(announcement); setTimeout(() => { document.body.removeChild(announcement); }, 1000); }; // 焦點陷阱管理 const modalContent = ref<HTMLElement>(); const cancelButton = ref<HTMLButtonElement>(); const confirmButton = ref<HTMLButtonElement>(); const focusableElements: HTMLElement[] = []; let previousFocusedElement: HTMLElement | null = null; const openModal = async () => { previousFocusedElement = document.activeElement as HTMLElement; showModal.value = true; await nextTick(); // 收集可聚焦元素 const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; focusableElements.splice(0); focusableElements.push( ...Array.from(modalContent.value?.querySelectorAll(selector) || []) ); // 聚焦第一個元素 if (focusableElements.length > 0) { focusableElements[0].focus(); } }; const closeModal = () => { showModal.value = false; // 恢復之前的焦點 if (previousFocusedElement) { previousFocusedElement.focus(); } }; const handleModalKeydown = (event: KeyboardEvent) => { if (event.key === 'Tab') { const currentIndex = focusableElements.indexOf(event.target as HTMLElement); if (event.shiftKey) { // Shift + Tab (向後) if (currentIndex <= 0) { event.preventDefault(); focusableElements[focusableElements.length - 1].focus(); } } else { // Tab (向前) if (currentIndex >= focusableElements.length - 1) { event.preventDefault(); focusableElements[0].focus(); } } } }; const handleConfirm = () => { // 處理確認邏輯 announceToScreenReader('操作已確認'); closeModal(); }; // 鍵盤快捷鍵 const handleGlobalKeydown = (event: KeyboardEvent) => { // Alt + H: 開啟高對比模式 if (event.altKey && event.key === 'h') { event.preventDefault(); toggleHighContrast(); } // Alt + +: 增大字體 if (event.altKey && event.key === '+') { event.preventDefault(); increaseFontSize(); } // Alt + -: 縮小字體 if (event.altKey && event.key === '-') { event.preventDefault(); decreaseFontSize(); } }; // 初始化無障礙設定 onMounted(() => { // 載入儲存的偏好設定 const savedHighContrast = localStorage.getItem('high-contrast'); if (savedHighContrast === 'true') { isHighContrast.value = true; document.documentElement.classList.add('high-contrast'); } const savedFontSize = localStorage.getItem('font-size'); if (savedFontSize) { fontSize.value = parseInt(savedFontSize); document.documentElement.style.fontSize = `${fontSize.value}px`; } // 監聽全域鍵盤事件 document.addEventListener('keydown', handleGlobalKeydown); // 檢測用戶偏好的配色方案 if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { document.documentElement.classList.add('dark-mode'); } // 檢測用戶偏好的動畫設定 if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { document.documentElement.classList.add('reduce-motion'); } }); onUnmounted(() => { document.removeEventListener('keydown', handleGlobalKeydown); }); </script> <style> /* 高對比模式樣式 */ .high-contrast { filter: contrast(150%) saturate(200%); } .high-contrast button { border: 2px solid currentColor !important; } .high-contrast a { text-decoration: underline !important; } /* 減少動畫模式 */ .reduce-motion *, .reduce-motion *::before, .reduce-motion *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } /* 深色模式支援 */ .dark-mode { color-scheme: dark; } /* 螢幕閱讀器專用隱藏 */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .sr-only.focus:focus { position: static; width: auto; height: auto; padding: inherit; margin: inherit; overflow: visible; clip: auto; white-space: normal; } </style> 多語系內容管理策略 // 多語系內容管理 interface TranslationKey { key: string; context?: string; pluralization?: boolean; interpolation?: string[]; } interface TranslationContent { [locale: string]: { [namespace: string]: { [key: string]: string | object; }; }; } // 翻譯內容結構化管理 export const translationStructure: TranslationContent = { 'zh-TW': { common: { // 通用操作 actions: { save: '儲存', cancel: '取消', confirm: '確認', delete: '刪除', edit: '編輯', add: '新增', search: '搜尋', filter: '篩選', sort: '排序', refresh: '重新整理', loading: '載入中...', noData: '無資料', error: '發生錯誤' }, // 表單相關 form: { required: '此欄位為必填', invalid: '格式不正確', tooShort: '長度不足', tooLong: '長度過長', emailInvalid: '請輸入有效的電子郵件地址', phoneInvalid: '請輸入有效的手機號碼', passwordMismatch: '密碼不一致' }, // 時間相關 time: { just_now: '剛剛', minutes_ago: '{count} 分鐘前', hours_ago: '{count} 小時前', days_ago: '{count} 天前', weeks_ago: '{count} 週前', months_ago: '{count} 個月前', years_ago: '{count} 年前' } }, // 金融業務相關 banking: { account: { balance: '帳戶餘額', available: '可用餘額', accountNumber: '帳戶號碼', accountType: '帳戶類型', status: '狀態', openDate: '開戶日期', lastTransaction: '最近交易' }, transaction: { transfer: '轉帳', deposit: '存款', withdrawal: '提款', payment: '付款', refund: '退款', fee: '手續費', amount: '金額', recipient: '收款人', description: '摘要', reference: '參考號碼', date: '交易日期', status: '交易狀態' }, status: { active: '啟用', inactive: '停用', pending: '處理中', completed: '已完成', failed: '失敗', cancelled: '已取消', expired: '已過期' } } }, 'en-US': { common: { actions: { save: 'Save', cancel: 'Cancel', confirm: 'Confirm', delete: 'Delete', edit: 'Edit', add: 'Add', search: 'Search', filter: 'Filter', sort: 'Sort', refresh: 'Refresh', loading: 'Loading...', noData: 'No Data', error: 'Error Occurred' }, form: { required: 'This field is required', invalid: 'Invalid format', tooShort: 'Too short', tooLong: 'Too long', emailInvalid: 'Please enter a valid email address', phoneInvalid: 'Please enter a valid phone number', passwordMismatch: 'Passwords do not match' }, time: { just_now: 'Just now', minutes_ago: '{count} minutes ago', hours_ago: '{count} hours ago', days_ago: '{count} days ago', weeks_ago: '{count} weeks ago', months_ago: '{count} months ago', years_ago: '{count} years ago' } }, banking: { account: { balance: 'Account Balance', available: 'Available Balance', accountNumber: 'Account Number', accountType: 'Account Type', status: 'Status', openDate: 'Open Date', lastTransaction: 'Last Transaction' }, transaction: { transfer: 'Transfer', deposit: 'Deposit', withdrawal: 'Withdrawal', payment: 'Payment', refund: 'Refund', fee: 'Fee', amount: 'Amount', recipient: 'Recipient', description: 'Description', reference: 'Reference Number', date: 'Transaction Date', status: 'Transaction Status' }, status: { active: 'Active', inactive: 'Inactive', pending: 'Pending', completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired' } } } }; // 進階翻譯函數 export class I18nManager { private currentLocale: string = 'zh-TW'; private fallbackLocale: string = 'en-US'; // 設定當前語言 setLocale(locale: string) { this.currentLocale = locale; document.documentElement.setAttribute('lang', locale); // 更新頁面方向 const direction = this.getTextDirection(locale); document.documentElement.setAttribute('dir', direction); // 觸發語言變更事件 window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale, direction } })); } // 取得文字方向 private getTextDirection(locale: string): 'ltr' | 'rtl' { const rtlLocales = ['ar', 'he', 'fa', 'ur']; return rtlLocales.some(rtl => locale.startsWith(rtl)) ? 'rtl' : 'ltr'; } // 翻譯函數 translate(key: string, options?: { interpolation?: Record<string, any>; count?: number; defaultValue?: string; }): string { const keys = key.split('.'); let value = this.getNestedValue(translationStructure[this.currentLocale], keys); // 如果找不到翻譯,嘗試備用語言 if (!value) { value = this.getNestedValue(translationStructure[this.fallbackLocale], keys); } // 如果還是找不到,返回預設值或 key if (!value) { return options?.defaultValue || key; } // 處理插值 if (options?.interpolation) { Object.entries(options.interpolation).forEach(([placeholder, replacement]) => { value = value.replace(new RegExp(`{${placeholder}}`, 'g'), replacement); }); } // 處理複數形式 if (options?.count !== undefined) { value = this.handlePluralization(value, options.count); } return value; } // 取得巢狀物件值 private getNestedValue(obj: any, keys: string[]): string | null { return keys.reduce((current, key) => current?.[key], obj) || null; } // 處理複數形式 private handlePluralization(value: string, count: number): string { // 簡化的複數處理,實際應用中可能需要更複雜的邏輯 if (this.currentLocale === 'en-US') { if (count === 1) { return value.replace(/\{count\}/, count.toString()); } else { // 處理英文複數規則 return value.replace(/\{count\}/, count.toString()); } } return value.replace(/\{count\}/, count.toString()); } // 格式化相對時間 formatRelativeTime(date: Date): string { const now = new Date(); const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); if (diffInSeconds < 60) { return this.translate('common.time.just_now'); } else if (diffInSeconds < 3600) { const minutes = Math.floor(diffInSeconds / 60); return this.translate('common.time.minutes_ago', { interpolation: { count: minutes.toString() } }); } else if (diffInSeconds < 86400) { const hours = Math.floor(diffInSeconds / 3600); return this.translate('common.time.hours_ago', { interpolation: { count: hours.toString() } }); } else if (diffInSeconds < 604800) { const days = Math.floor(diffInSeconds / 86400); return this.translate('common.time.days_ago', { interpolation: { count: days.toString() } }); } else if (diffInSeconds < 2419200) { const weeks = Math.floor(diffInSeconds / 604800); return this.translate('common.time.weeks_ago', { interpolation: { count: weeks.toString() } }); } else if (diffInSeconds < 29030400) { const months = Math.floor(diffInSeconds / 2419200); return this.translate('common.time.months_ago', { interpolation: { count: months.toString() } }); } else { const years = Math.floor(diffInSeconds / 29030400); return this.translate('common.time.years_ago', { interpolation: { count: years.toString() } }); } } } // 全域 i18n 實例 export const i18nManager = new I18nManager(); // Vue 組合函數 export function useI18n() { const t = (key: string, options?: any) => i18nManager.translate(key, options); const setLocale = (locale: string) => i18nManager.setLocale(locale); const formatRelativeTime = (date: Date) => i18nManager.formatRelativeTime(date); return { t, setLocale, formatRelativeTime }; } 無障礙設計和多語系支援擴充完成,接下來我將繼續撰寫第三部分:UI 元件規範。
...