前端開發指引

文件資訊

  • 版本: 1.0.0
  • 建立日期: 2025-08-11
  • 適用專案: 大型金融級 Web 專案
  • 技術棧: Vue 3.x + TypeScript + Tailwind CSS

目錄

  1. 專案目錄與檔案結構規範
  2. 命名規範
  3. 程式撰寫風格與 Lint 設定
  4. 元件開發規範
  5. 樣式與 Tailwind CSS 規範
  6. API 串接與資料存取規範
  7. 狀態管理規範
  8. 多語系處理規範
  9. 測試規範
  10. 安全性考量
  11. 效能優化規範
  12. 無障礙設計規範
  13. 版本控制與分支策略
  14. 專案建置與部署流程
  15. 程式碼審查規範
  16. 常見錯誤處理與 Debug 流程
  17. 開發工具與環境設定
  18. 團隊協作與溝通規範

1. 專案目錄與檔案結構規範

1.1 標準專案結構

frontend-project/
├── public/                     # 靜態資源
│   ├── favicon.ico
│   ├── index.html
│   └── manifest.json
├── src/                        # 原始碼
│   ├── api/                    # API 相關
│   │   ├── modules/            # 依功能模組分類
│   │   │   ├── auth.ts
│   │   │   └── user.ts
│   │   ├── interceptors/       # 攔截器
│   │   └── types/              # API 型別定義
│   ├── assets/                 # 靜態資源
│   │   ├── images/
│   │   ├── icons/
│   │   └── fonts/
│   ├── components/             # 共用元件
│   │   ├── base/               # 基礎元件
│   │   │   ├── BaseButton.vue
│   │   │   ├── BaseInput.vue
│   │   │   └── BaseModal.vue
│   │   ├── business/           # 業務元件
│   │   └── layout/             # 版面元件
│   │       ├── Header.vue
│   │       ├── Sidebar.vue
│   │       └── Footer.vue
│   ├── composables/            # Vue 3 Composition API
│   │   ├── useAuth.ts
│   │   ├── useApi.ts
│   │   └── useLocalStorage.ts
│   ├── constants/              # 常數定義
│   │   ├── api.ts
│   │   ├── routes.ts
│   │   └── config.ts
│   ├── directives/             # 自定義指令
│   ├── i18n/                   # 多語系
│   │   ├── locales/
│   │   │   ├── zh-TW.json
│   │   │   ├── en-US.json
│   │   │   └── ja-JP.json
│   │   └── index.ts
│   ├── layouts/                # 版面配置
│   │   ├── DefaultLayout.vue
│   │   ├── AuthLayout.vue
│   │   └── EmptyLayout.vue
│   ├── middleware/             # 中間件
│   │   ├── auth.ts
│   │   └── permission.ts
│   ├── pages/                  # 頁面元件
│   │   ├── auth/
│   │   │   ├── Login.vue
│   │   │   └── Register.vue
│   │   ├── dashboard/
│   │   └── user/
│   ├── plugins/                # 插件
│   │   ├── axios.ts
│   │   ├── i18n.ts
│   │   └── router.ts
│   ├── router/                 # 路由設定
│   │   ├── modules/            # 路由模組
│   │   │   ├── auth.ts
│   │   │   └── dashboard.ts
│   │   └── index.ts
│   ├── stores/                 # Pinia 狀態管理
│   │   ├── modules/
│   │   │   ├── auth.ts
│   │   │   └── user.ts
│   │   └── index.ts
│   ├── styles/                 # 樣式檔案
│   │   ├── globals.css
│   │   ├── variables.css
│   │   └── components/
│   ├── types/                  # TypeScript 型別定義
│   │   ├── api.ts
│   │   ├── auth.ts
│   │   └── global.ts
│   ├── utils/                  # 工具函式
│   │   ├── format.ts
│   │   ├── validation.ts
│   │   └── storage.ts
│   ├── App.vue                 # 根元件
│   └── main.ts                 # 應用程式進入點
├── tests/                      # 測試檔案
│   ├── unit/                   # 單元測試
│   ├── e2e/                    # E2E 測試
│   └── __mocks__/              # Mock 檔案
├── .env                        # 環境變數
├── .env.development
├── .env.production
├── .eslintrc.js                # ESLint 設定
├── .prettierrc                 # Prettier 設定
├── tailwind.config.js          # Tailwind CSS 設定
├── vite.config.ts              # Vite 設定
├── tsconfig.json               # TypeScript 設定
└── package.json                # 套件管理

1.2 檔案命名原則

檔案類型對應命名方式

  • Vue 元件: PascalCase (如 UserProfile.vue)
  • TypeScript 檔案: camelCase (如 userService.ts)
  • CSS/SCSS 檔案: kebab-case (如 user-profile.scss)
  • 測試檔案: 與被測檔案同名 + .test.spec (如 UserProfile.test.ts)
  • 型別定義檔案: camelCase + .d.ts (如 userTypes.d.ts)

特殊檔案命名

  • 頁面元件: PascalCase,通常以頁面功能命名 (如 UserManagement.vue)
  • Layout 元件: PascalCase + Layout 後綴 (如 DashboardLayout.vue)
  • Store 檔案: camelCase,以業務領域命名 (如 userStore.ts)
  • API 檔案: camelCase,以 API 服務命名 (如 userApi.ts)

2. 命名規範

2.1 檔案與資料夾命名

資料夾命名

  • 使用 kebab-case (小寫字母 + 連字號)
  • 名稱應簡潔且具描述性
✅ 正確範例
user-management/
auth-service/
api-client/

❌ 錯誤範例
UserManagement/
authService/
API_Client/

檔案命名

  • Vue 元件檔案: PascalCase
  • JavaScript/TypeScript 檔案: camelCase
  • 樣式檔案: kebab-case
  • 設定檔案: kebab-case 或 camelCase (依慣例)
// ✅ 正確範例
UserProfile.vue
userService.ts
user-profile.scss
vite.config.ts

// ❌ 錯誤範例
userprofile.vue
UserService.ts
user_profile.scss
vite_config.ts

2.2 變數與函式命名

JavaScript/TypeScript 變數

  • 使用 camelCase
  • 常數使用 UPPER_SNAKE_CASE
  • 私有變數以 _ 開頭
  • 布林值變數使用 ishascanshould 等前綴
// ✅ 正確範例
const userName = 'John Doe';
const API_BASE_URL = 'https://api.example.com';
const _privateVariable = 'private';
const isLoggedIn = true;
const hasPermission = false;
const canEdit = true;
const shouldUpdate = false;

// ❌ 錯誤範例
const user_name = 'John Doe';
const apiBaseUrl = 'https://api.example.com'; // 常數應使用大寫
const privateVariable = 'private'; // 私有變數缺少前綴
const loggedIn = true; // 布林值缺少前綴

函式命名

  • 使用 camelCase
  • 動詞開頭,描述函式的動作
  • 事件處理器使用 handle 前綴
  • 取得資料使用 getfetch 前綴
  • 設定資料使用 setupdate 前綴
// ✅ 正確範例
function getUserData() { }
function handleButtonClick() { }
function validateEmail() { }
function formatCurrency() { }
function updateUserProfile() { }
function fetchUserList() { }

// ❌ 錯誤範例
function userData() { } // 缺少動詞
function buttonClick() { } // 事件處理器缺少 handle 前綴
function email() { } // 不明確的命名
function currency() { } // 不明確的命名

2.3 Vue 元件命名

元件名稱

  • 使用 PascalCase
  • 多個單字組合,避免單一單字
  • 基礎元件使用 Base 前綴
  • 業務元件使用具體的業務領域命名
<!-- ✅ 正確範例 -->
<script setup lang="ts">
// 元件檔案: UserProfile.vue
</script>

<script setup lang="ts">
// 元件檔案: BaseButton.vue
</script>

<script setup lang="ts">
// 元件檔案: PaymentForm.vue
</script>

<!-- ❌ 錯誤範例 -->
<script setup lang="ts">
// 元件檔案: user.vue - 命名太簡短
</script>

<script setup lang="ts">
// 元件檔案: button.vue - 應使用 BaseButton
</script>

Props 命名

  • 定義時使用 camelCase
  • HTML 模板中使用 kebab-case
<script setup lang="ts">
// ✅ 正確範例
interface Props {
  userName: string;
  isVisible: boolean;
  maxLength?: number;
}

const props = withDefaults(defineProps<Props>(), {
  maxLength: 100
});
</script>

<template>
  <!-- HTML 模板中使用 kebab-case -->
  <UserProfile
    :user-name="currentUser"
    :is-visible="showProfile"
    :max-length="200"
  />
</template>

Event 命名

  • 使用 kebab-case
  • 動詞開頭,描述事件的動作
<script setup lang="ts">
// ✅ 正確範例
const emit = defineEmits<{
  'update:modelValue': [value: string];
  'user-created': [user: User];
  'form-submitted': [data: FormData];
  'item-selected': [item: Item];
}>();

// 觸發事件
emit('user-created', newUser);
emit('form-submitted', formData);
</script>

3. 程式撰寫風格與 Lint 設定

3.1 ESLint 設定

eslint.config.js 範例

import { defineConfig } from 'eslint-define-config';
import vue from 'eslint-plugin-vue';
import typescript from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import prettier from 'eslint-plugin-prettier';

export default defineConfig([
  {
    files: ['**/*.{js,ts,vue}'],
    languageOptions: {
      parser: typescriptParser,
      parserOptions: {
        ecmaVersion: 2022,
        sourceType: 'module',
        extraFileExtensions: ['.vue']
      }
    },
    plugins: {
      vue,
      '@typescript-eslint': typescript,
      prettier
    },
    rules: {
      // Vue 規則
      'vue/multi-word-component-names': 'error',
      'vue/component-name-in-template-casing': ['error', 'PascalCase'],
      'vue/no-unused-vars': 'error',
      'vue/no-multiple-template-root': 'off', // Vue 3 支援多個根元素
      'vue/script-setup-uses-vars': 'error',
      
      // TypeScript 規則
      '@typescript-eslint/no-unused-vars': 'error',
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/explicit-function-return-type': 'off',
      '@typescript-eslint/no-non-null-assertion': 'warn',
      
      // 通用規則
      'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
      'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
      'prefer-const': 'error',
      'no-var': 'error',
      'object-shorthand': 'error',
      'prefer-template': 'error',
      
      // Prettier 規則
      'prettier/prettier': 'error'
    }
  }
]);

3.2 Prettier 設定

.prettierrc 範例

{
  "semi": true,
  "trailingComma": "es5",
  "singleQuote": true,
  "printWidth": 80,
  "tabWidth": 2,
  "useTabs": false,
  "bracketSpacing": true,
  "bracketSameLine": false,
  "arrowParens": "avoid",
  "endOfLine": "lf",
  "vueIndentScriptAndStyle": true,
  "htmlWhitespaceSensitivity": "css"
}

3.3 TypeScript 撰寫規範

型別定義規範

// ✅ 正確範例 - 使用 interface 定義物件型別
interface User {
  id: number;
  name: string;
  email: string;
  isActive: boolean;
  createdAt: Date;
  updatedAt?: Date; // 選擇性屬性
}

// ✅ 正確範例 - 使用 type 定義聯合型別
type Status = 'pending' | 'approved' | 'rejected';
type Theme = 'light' | 'dark' | 'auto';

// ✅ 正確範例 - 泛型使用
interface ApiResponse<T> {
  data: T;
  message: string;
  success: boolean;
  code: number;
}

// ✅ 正確範例 - 函式型別定義
type EventHandler<T = Event> = (event: T) => void;
type AsyncFunction<T> = () => Promise<T>;

函式撰寫規範

// ✅ 正確範例 - 明確的參數和回傳型別
async function fetchUserData(userId: number): Promise<User | null> {
  try {
    const response = await api.get<ApiResponse<User>>(`/users/${userId}`);
    return response.data.data;
  } catch (error) {
    console.error('Failed to fetch user data:', error);
    return null;
  }
}

// ✅ 正確範例 - 箭頭函式與型別推斷
const formatCurrency = (amount: number, currency = 'TWD'): string => {
  return new Intl.NumberFormat('zh-TW', {
    style: 'currency',
    currency,
  }).format(amount);
};

// ✅ 正確範例 - 高階函式
const createValidator = <T>(
  validator: (value: T) => boolean,
  errorMessage: string
) => {
  return (value: T): ValidationResult => ({
    isValid: validator(value),
    message: validator(value) ? '' : errorMessage,
  });
};

3.4 Vue 3 Composition API 撰寫規範

<script setup> 結構規範

<script setup lang="ts">
// 1. 導入 Vue 相關 API
import { ref, computed, watch, onMounted } from 'vue';

// 2. 導入 Composables
import { useAuth } from '@/composables/useAuth';
import { useApi } from '@/composables/useApi';

// 3. 導入其他模組
import { formatDate } from '@/utils/format';
import type { User } from '@/types/user';

// 4. 定義 Props 介面
interface Props {
  userId: number;
  isEditable?: boolean;
}

// 5. 定義 Emits 介面
interface Emits {
  'user-updated': [user: User];
  'error': [error: string];
}

// 6. 宣告 Props 和 Emits
const props = withDefaults(defineProps<Props>(), {
  isEditable: false,
});

const emit = defineEmits<Emits>();

// 7. 響應式資料
const user = ref<User | null>(null);
const loading = ref(false);
const error = ref<string>('');

// 8. 計算屬性
const displayName = computed(() => {
  return user.value ? `${user.value.firstName} ${user.value.lastName}` : '';
});

const canEdit = computed(() => {
  return props.isEditable && user.value?.isActive;
});

// 9. 監聽器
watch(
  () => props.userId,
  async (newUserId) => {
    if (newUserId) {
      await loadUser();
    }
  },
  { immediate: true }
);

// 10. 方法
const loadUser = async (): Promise<void> => {
  loading.value = true;
  error.value = '';
  
  try {
    const userData = await fetchUserData(props.userId);
    user.value = userData;
  } catch (err) {
    error.value = '載入使用者資料失敗';
    emit('error', error.value);
  } finally {
    loading.value = false;
  }
};

const updateUser = async (userData: Partial<User>): Promise<void> => {
  if (!user.value) return;
  
  try {
    const updatedUser = await updateUserData(user.value.id, userData);
    user.value = updatedUser;
    emit('user-updated', updatedUser);
  } catch (err) {
    error.value = '更新使用者資料失敗';
    emit('error', error.value);
  }
};

// 11. 生命週期鉤子
onMounted(() => {
  console.log('Component mounted');
});

// 12. 暴露給父元件的方法/屬性 (如需要)
defineExpose({
  loadUser,
  updateUser,
});
</script>

3.5 程式碼品質規範

註解撰寫規範

/**
 * 使用者資料服務類別
 * 
 * 提供使用者相關的 API 操作方法,包含:
 * - 取得使用者資料
 * - 更新使用者資料
 * - 刪除使用者
 * 
 * @example
 * ```typescript
 * const userService = new UserService();
 * const user = await userService.getUser(123);
 * ```
 */
export class UserService {
  /**
   * 根據 ID 取得使用者資料
   * 
   * @param userId - 使用者 ID
   * @returns 使用者資料,如果找不到則回傳 null
   * @throws {ApiError} 當 API 請求失敗時拋出錯誤
   * 
   * @example
   * ```typescript
   * const user = await userService.getUser(123);
   * if (user) {
   *   console.log(user.name);
   * }
   * ```
   */
  async getUser(userId: number): Promise<User | null> {
    // TODO: 實作快取機制
    // FIXME: 處理網路錯誤重試邏輯
    
    try {
      const response = await this.api.get(`/users/${userId}`);
      return response.data;
    } catch (error) {
      // 記錄錯誤但不拋出,讓呼叫方決定如何處理
      console.error(`Failed to fetch user ${userId}:`, error);
      return null;
    }
  }
}

錯誤處理規範

// ✅ 正確範例 - 統一的錯誤處理
export class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public code?: string
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

// ✅ 正確範例 - 錯誤邊界處理
const handleApiError = (error: unknown): never => {
  if (error instanceof ApiError) {
    switch (error.status) {
      case 401:
        // 重新導向到登入頁面
        router.push('/login');
        break;
      case 403:
        // 顯示權限不足訊息
        showErrorMessage('您沒有權限執行此操作');
        break;
      case 500:
        // 顯示伺服器錯誤訊息
        showErrorMessage('伺服器發生錯誤,請稍後再試');
        break;
      default:
        showErrorMessage(error.message);
    }
  } else {
    // 未知錯誤
    console.error('Unexpected error:', error);
    showErrorMessage('發生未知錯誤');
  }
  
  throw error;
};

4. 元件開發規範

4.1 Vue 單檔元件 (SFC) 結構

標準 SFC 結構順序

<!-- 1. 模板區域 -->
<template>
  <div class="user-profile">
    <!-- 內容 -->
  </div>
</template>

<!-- 2. 邏輯區域 -->
<script setup lang="ts">
// 邏輯代碼
</script>

<!-- 3. 樣式區域 -->
<style scoped lang="scss">
// 樣式代碼
</style>

完整元件範例

<template>
  <div class="user-card" :class="cardClasses">
    <!-- 頭像區域 -->
    <div class="user-card__avatar">
      <img
        :src="user.avatar || defaultAvatar"
        :alt="`${user.name} 的頭像`"
        class="user-card__avatar-img"
        @error="handleImageError"
      />
      <div v-if="showStatus" class="user-card__status" :class="statusClass">
        {{ statusText }}
      </div>
    </div>

    <!-- 使用者資訊 -->
    <div class="user-card__content">
      <h3 class="user-card__name">{{ user.name }}</h3>
      <p class="user-card__email">{{ user.email }}</p>
      
      <!-- 標籤 -->
      <div v-if="user.tags?.length" class="user-card__tags">
        <span
          v-for="tag in user.tags"
          :key="tag"
          class="user-card__tag"
        >
          {{ tag }}
        </span>
      </div>

      <!-- 動作按鈕 -->
      <div class="user-card__actions">
        <BaseButton
          variant="primary"
          size="small"
          :disabled="loading"
          @click="handleEdit"
        >
          編輯
        </BaseButton>
        <BaseButton
          variant="secondary"
          size="small"
          :disabled="loading"
          @click="handleView"
        >
          查看
        </BaseButton>
      </div>
    </div>

    <!-- 載入狀態 -->
    <div v-if="loading" class="user-card__loading">
      <LoadingSpinner size="small" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import BaseButton from '@/components/base/BaseButton.vue';
import LoadingSpinner from '@/components/base/LoadingSpinner.vue';
import { useI18n } from 'vue-i18n';
import type { User } from '@/types/user';

// Props 定義
interface Props {
  user: User;
  variant?: 'default' | 'compact' | 'detailed';
  showStatus?: boolean;
  interactive?: boolean;
}

// Emits 定義
interface Emits {
  edit: [user: User];
  view: [user: User];
  'avatar-error': [user: User];
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'default',
  showStatus: true,
  interactive: true,
});

const emit = defineEmits<Emits>();

// Composables
const { t } = useI18n();

// 響應式資料
const loading = ref(false);
const defaultAvatar = '/images/default-avatar.png';

// 計算屬性
const cardClasses = computed(() => ({
  [`user-card--${props.variant}`]: true,
  'user-card--interactive': props.interactive,
  'user-card--loading': loading.value,
}));

const statusClass = computed(() => ({
  'user-card__status--online': props.user.isOnline,
  'user-card__status--offline': !props.user.isOnline,
}));

const statusText = computed(() => {
  return props.user.isOnline ? t('common.online') : t('common.offline');
});

// 方法
const handleEdit = (): void => {
  if (!props.interactive || loading.value) return;
  emit('edit', props.user);
};

const handleView = (): void => {
  if (!props.interactive || loading.value) return;
  emit('view', props.user);
};

const handleImageError = (): void => {
  emit('avatar-error', props.user);
};
</script>

<style scoped lang="scss">
.user-card {
  @apply bg-white rounded-lg shadow-md p-4 transition-all duration-200;

  &--interactive {
    @apply hover:shadow-lg cursor-pointer;
  }

  &--loading {
    @apply opacity-50 pointer-events-none;
  }

  &__avatar {
    @apply relative flex-shrink-0;
  }

  &__avatar-img {
    @apply w-12 h-12 rounded-full object-cover;
  }

  &__status {
    @apply absolute -bottom-1 -right-1 px-2 py-1 text-xs rounded-full text-white;

    &--online {
      @apply bg-green-500;
    }

    &--offline {
      @apply bg-gray-400;
    }
  }

  &__content {
    @apply flex-1 ml-4;
  }

  &__name {
    @apply text-lg font-semibold text-gray-900 mb-1;
  }

  &__email {
    @apply text-sm text-gray-600 mb-2;
  }

  &__tags {
    @apply flex flex-wrap gap-1 mb-3;
  }

  &__tag {
    @apply px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded;
  }

  &__actions {
    @apply flex gap-2;
  }

  &__loading {
    @apply absolute inset-0 flex items-center justify-center bg-white bg-opacity-75;
  }

  // 變體樣式
  &--compact {
    @apply p-2;

    .user-card__avatar-img {
      @apply w-8 h-8;
    }

    .user-card__name {
      @apply text-base;
    }
  }

  &--detailed {
    @apply p-6;

    .user-card__avatar-img {
      @apply w-16 h-16;
    }
  }
}
</style>

4.2 Props 設計規範

Props 型別定義

// ✅ 正確範例 - 完整的 Props 介面
interface ButtonProps {
  // 必要屬性
  label: string;
  
  // 選擇性屬性with default values
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  loading?: boolean;
  
  // 複雜型別
  icon?: {
    name: string;
    position: 'left' | 'right';
  };
  
  // 函式型別
  onClick?: (event: MouseEvent) => void;
}

const props = withDefaults(defineProps<ButtonProps>(), {
  variant: 'primary',
  size: 'medium',
  disabled: false,
  loading: false,
});

Props 驗證

// ✅ 正確範例 - 執行時驗證
interface FormInputProps {
  modelValue: string;
  type?: 'text' | 'email' | 'password' | 'number';
  placeholder?: string;
  required?: boolean;
  maxLength?: number;
  pattern?: string;
  validator?: (value: string) => boolean | string;
}

const props = withDefaults(defineProps<FormInputProps>(), {
  type: 'text',
  required: false,
});

// 自定義驗證邏輯
const isValid = computed(() => {
  if (props.required && !props.modelValue) {
    return false;
  }
  
  if (props.maxLength && props.modelValue.length > props.maxLength) {
    return false;
  }
  
  if (props.pattern && !new RegExp(props.pattern).test(props.modelValue)) {
    return false;
  }
  
  if (props.validator) {
    const result = props.validator(props.modelValue);
    return result === true;
  }
  
  return true;
});

4.3 Emits 事件規範

事件定義與觸發

// ✅ 正確範例 - 型別安全的事件定義
interface FormEmits {
  // v-model 雙向綁定
  'update:modelValue': [value: string];
  
  // 表單事件
  submit: [data: FormData];
  cancel: [];
  
  // 驗證事件
  'validation-error': [errors: ValidationError[]];
  'validation-success': [];
  
  // 使用者互動事件
  'field-focus': [fieldName: string];
  'field-blur': [fieldName: string, value: string];
}

const emit = defineEmits<FormEmits>();

// 事件觸發範例
const handleSubmit = (formData: FormData): void => {
  // 驗證表單
  const errors = validateForm(formData);
  
  if (errors.length > 0) {
    emit('validation-error', errors);
    return;
  }
  
  emit('validation-success');
  emit('submit', formData);
};

const handleCancel = (): void => {
  emit('cancel');
};

// v-model 實作
const updateValue = (newValue: string): void => {
  emit('update:modelValue', newValue);
};

4.4 Slots 使用規範

具名插槽設計

<template>
  <div class="card">
    <!-- 標題插槽 -->
    <header v-if="$slots.header" class="card__header">
      <slot name="header" :title="title" :subtitle="subtitle" />
    </header>

    <!-- 預設內容插槽 -->
    <main class="card__content">
      <slot :data="data" :loading="loading" />
    </main>

    <!-- 動作按鈕插槽 -->
    <footer v-if="$slots.actions" class="card__actions">
      <slot
        name="actions"
        :save="handleSave"
        :cancel="handleCancel"
        :canSave="canSave"
      />
    </footer>

    <!-- 條件式插槽 -->
    <div v-if="$slots.sidebar" class="card__sidebar">
      <slot name="sidebar" />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Props {
  title?: string;
  subtitle?: string;
  data: any;
  loading?: boolean;
}

const props = defineProps<Props>();

// 計算屬性
const canSave = computed(() => {
  return !props.loading && isDataValid(props.data);
});

// 提供給插槽的方法
const handleSave = (): void => {
  // 儲存邏輯
};

const handleCancel = (): void => {
  // 取消邏輯
};
</script>

插槽使用範例

<template>
  <Card :data="userData" :loading="loading">
    <!-- 標題插槽 -->
    <template #header="{ title, subtitle }">
      <h2>{{ title || '使用者資料' }}</h2>
      <p v-if="subtitle">{{ subtitle }}</p>
    </template>

    <!-- 預設內容插槽 -->
    <template #default="{ data, loading }">
      <div v-if="!loading">
        <UserForm :user="data" @update="handleUserUpdate" />
      </div>
      <LoadingSpinner v-else />
    </template>

    <!-- 動作插槽 -->
    <template #actions="{ save, cancel, canSave }">
      <BaseButton
        variant="primary"
        :disabled="!canSave"
        @click="save"
      >
        儲存
      </BaseButton>
      <BaseButton
        variant="secondary"
        @click="cancel"
      >
        取消
      </BaseButton>
    </template>
  </Card>
</template>

4.5 元件組合與複用

高階元件 (HOC) 模式

<!-- withLoading.vue - 高階元件 -->
<template>
  <div class="with-loading">
    <div v-if="loading" class="loading-overlay">
      <LoadingSpinner :size="loadingSize" />
      <p v-if="loadingText">{{ loadingText }}</p>
    </div>
    
    <div :class="{ 'is-loading': loading }">
      <slot :loading="loading" />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Props {
  loading: boolean;
  loadingText?: string;
  loadingSize?: 'small' | 'medium' | 'large';
}

withDefaults(defineProps<Props>(), {
  loadingSize: 'medium',
});
</script>

組合式元件使用

<template>
  <WithLoading :loading="isLoading" loading-text="載入使用者資料中...">
    <UserProfile
      :user="userData"
      @edit="handleEdit"
      @delete="handleDelete"
    />
  </WithLoading>
</template>

<script setup lang="ts">
import WithLoading from '@/components/hoc/WithLoading.vue';
import UserProfile from '@/components/UserProfile.vue';

const isLoading = ref(false);
const userData = ref(null);

const handleEdit = (user: User): void => {
  // 編輯邏輯
};

const handleDelete = (user: User): void => {
  // 刪除邏輯
};
</script>

5. 樣式與 Tailwind CSS 規範

5.1 Tailwind CSS 設定

tailwind.config.js 範例

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    './index.html',
    './src/**/*.{vue,js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {
      // 顏色系統
      colors: {
        primary: {
          50: '#eff6ff',
          100: '#dbeafe',
          200: '#bfdbfe',
          300: '#93c5fd',
          400: '#60a5fa',
          500: '#3b82f6', // 主要品牌色
          600: '#2563eb',
          700: '#1d4ed8',
          800: '#1e40af',
          900: '#1e3a8a',
          950: '#172554',
        },
        secondary: {
          50: '#f8fafc',
          500: '#64748b',
          900: '#0f172a',
        },
        success: {
          50: '#f0fdf4',
          500: '#22c55e',
          900: '#14532d',
        },
        warning: {
          50: '#fffbeb',
          500: '#f59e0b',
          900: '#78350f',
        },
        danger: {
          50: '#fef2f2',
          500: '#ef4444',
          900: '#7f1d1d',
        },
      },
      
      // 字型設定
      fontFamily: {
        sans: [
          'Noto Sans TC',
          'Microsoft JhengHei',
          'PingFang TC',
          'Helvetica Neue',
          'Arial',
          'sans-serif',
        ],
      },
      
      // 響應式斷點
      screens: {
        'xs': '475px',
        'sm': '640px',
        'md': '768px',
        'lg': '1024px',
        'xl': '1280px',
        '2xl': '1536px',
      },
    },
  },
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
  ],
};

5.2 RWD 響應式設計原則

Mobile-First 設計策略

<template>
  <!-- 響應式網格系統 -->
  <div class="container mx-auto px-4">
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
      <div v-for="item in items" :key="item.id" class="card">
        <!-- 響應式圖片 -->
        <img
          :src="item.image"
          :alt="item.title"
          class="w-full h-32 sm:h-40 lg:h-48 object-cover"
        />
        
        <!-- 響應式文字 -->
        <div class="p-4">
          <h3 class="text-lg sm:text-xl lg:text-2xl font-semibold">
            {{ item.title }}
          </h3>
          <p class="text-sm sm:text-base text-gray-600">
            {{ item.description }}
          </p>
        </div>
      </div>
    </div>
  </div>

  <!-- 響應式導航 -->
  <nav class="bg-white shadow">
    <!-- 桌面版導航 -->
    <div class="hidden lg:flex items-center space-x-8 px-6 py-4">
      <a v-for="link in navLinks" :key="link.path" :href="link.path">
        {{ link.title }}
      </a>
    </div>
    
    <!-- 行動版選單 -->
    <div class="lg:hidden">
      <button @click="toggleMobileMenu" class="p-4">
        <MenuIcon class="w-6 h-6" />
      </button>
    </div>
  </nav>
</template>

5.3 顏色與主題設計

CSS 變數主題系統

:root {
  /* 品牌色 */
  --color-primary: #3b82f6;
  --color-secondary: #64748b;
  
  /* 語意化顏色 */
  --color-success: #22c55e;
  --color-warning: #f59e0b;
  --color-danger: #ef4444;
  
  /* 背景色 */
  --bg-primary: #ffffff;
  --bg-secondary: #f8fafc;
  
  /* 文字色 */
  --text-primary: #111827;
  --text-secondary: #4b5563;
}

/* 暗色主題 */
[data-theme="dark"] {
  --bg-primary: #111827;
  --bg-secondary: #1f2937;
  --text-primary: #f9fafb;
  --text-secondary: #d1d5db;
}

5.4 共用樣式元件

基礎元件樣式

@layer components {
  /* 按鈕樣式 */
  .btn {
    @apply inline-flex items-center px-4 py-2 text-sm font-medium 
           rounded-md transition-colors focus:outline-none focus:ring-2;
  }
  
  .btn-primary {
    @apply btn bg-primary-500 text-white hover:bg-primary-600;
  }
  
  .btn-secondary {
    @apply btn bg-gray-200 text-gray-900 hover:bg-gray-300;
  }
  
  /* 表單樣式 */
  .form-input {
    @apply w-full px-3 py-2 border border-gray-300 rounded-md 
           focus:ring-1 focus:ring-primary-500 focus:border-primary-500;
  }
  
  /* 卡片樣式 */
  .card {
    @apply bg-white rounded-lg shadow-md p-4;
  }
}

總結

本前端開發指引提供了完整的開發規範,涵蓋了專案結構、命名規範、程式撰寫風格、元件開發和樣式設計等核心面向。請開發團隊嚴格遵循這些規範,以確保程式碼品質和專案的可維護性。

重要提醒

  1. 保持一致性: 整個團隊都應遵循相同的規範
  2. 定期更新: 隨著技術發展和專案需求變化,適時更新規範
  3. 程式碼審查: 在 Pull Request 中確保所有程式碼都符合規範
  4. 工具輔助: 善用 ESLint、Prettier 等工具自動化檢查

6. API 串接與資料存取規範

6.1 Axios 設定與實例化

基礎 Axios 設定

// src/api/client.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { useAuthStore } from '@/stores/auth';
import { showErrorMessage } from '@/utils/message';

// API 基礎設定
const API_CONFIG = {
  baseURL: import.meta.env.VITE_API_BASE_URL || 'https://api.example.com',
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
};

// 建立 Axios 實例
const apiClient: AxiosInstance = axios.create(API_CONFIG);

// 請求攔截器
apiClient.interceptors.request.use(
  (config) => {
    // 添加認證 Token
    const authStore = useAuthStore();
    if (authStore.token) {
      config.headers.Authorization = `Bearer ${authStore.token}`;
    }

    // 添加請求 ID (用於追蹤)
    config.headers['X-Request-ID'] = generateRequestId();

    // 記錄請求資訊
    console.log(`🚀 API Request: ${config.method?.toUpperCase()} ${config.url}`, {
      params: config.params,
      data: config.data,
    });

    return config;
  },
  (error) => {
    console.error('❌ Request Error:', error);
    return Promise.reject(error);
  }
);

// 回應攔截器
apiClient.interceptors.response.use(
  (response: AxiosResponse) => {
    // 記錄成功回應
    console.log(`✅ API Response: ${response.config.method?.toUpperCase()} ${response.config.url}`, {
      status: response.status,
      data: response.data,
    });

    return response;
  },
  async (error) => {
    const { config, response } = error;

    // 記錄錯誤回應
    console.error(`❌ API Error: ${config?.method?.toUpperCase()} ${config?.url}`, {
      status: response?.status,
      data: response?.data,
    });

    // 統一錯誤處理
    if (response?.status === 401) {
      // Token 過期,重新登入
      const authStore = useAuthStore();
      await authStore.logout();
      window.location.href = '/login';
      return Promise.reject(new Error('登入已過期,請重新登入'));
    }

    if (response?.status === 403) {
      showErrorMessage('您沒有權限執行此操作');
      return Promise.reject(new Error('權限不足'));
    }

    if (response?.status >= 500) {
      showErrorMessage('伺服器發生錯誤,請稍後再試');
      return Promise.reject(new Error('伺服器錯誤'));
    }

    if (response?.status === 422) {
      // 表單驗證錯誤
      const validationErrors = response.data.errors || {};
      return Promise.reject(new ValidationError('表單驗證失敗', validationErrors));
    }

    // 其他錯誤
    const message = response?.data?.message || error.message || '網路連線失敗';
    showErrorMessage(message);
    return Promise.reject(new Error(message));
  }
);

export default apiClient;

自定義錯誤類別

// src/api/errors.ts
export class ApiError extends Error {
  constructor(
    message: string,
    public status?: number,
    public code?: string,
    public data?: any
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

export class ValidationError extends Error {
  constructor(
    message: string,
    public errors: Record<string, string[]>
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

export class NetworkError extends Error {
  constructor(message: string = '網路連線失敗') {
    super(message);
    this.name = 'NetworkError';
  }
}

6.2 API 模組化設計

基礎 API 服務類別

// src/api/base.ts
import apiClient from './client';
import type { AxiosResponse } from 'axios';

export interface ApiResponse<T = any> {
  data: T;
  message: string;
  success: boolean;
  code: number;
  meta?: {
    current_page: number;
    last_page: number;
    per_page: number;
    total: number;
  };
}

export interface PaginationParams {
  page?: number;
  per_page?: number;
  sort_by?: string;
  sort_order?: 'asc' | 'desc';
}

export abstract class BaseApiService {
  protected abstract baseURL: string;

  protected async get<T>(
    endpoint: string,
    params?: Record<string, any>
  ): Promise<ApiResponse<T>> {
    const response: AxiosResponse<ApiResponse<T>> = await apiClient.get(
      `${this.baseURL}${endpoint}`,
      { params }
    );
    return response.data;
  }

  protected async post<T>(
    endpoint: string,
    data?: any
  ): Promise<ApiResponse<T>> {
    const response: AxiosResponse<ApiResponse<T>> = await apiClient.post(
      `${this.baseURL}${endpoint}`,
      data
    );
    return response.data;
  }

  protected async put<T>(
    endpoint: string,
    data?: any
  ): Promise<ApiResponse<T>> {
    const response: AxiosResponse<ApiResponse<T>> = await apiClient.put(
      `${this.baseURL}${endpoint}`,
      data
    );
    return response.data;
  }

  protected async patch<T>(
    endpoint: string,
    data?: any
  ): Promise<ApiResponse<T>> {
    const response: AxiosResponse<ApiResponse<T>> = await apiClient.patch(
      `${this.baseURL}${endpoint}`,
      data
    );
    return response.data;
  }

  protected async delete<T>(
    endpoint: string
  ): Promise<ApiResponse<T>> {
    const response: AxiosResponse<ApiResponse<T>> = await apiClient.delete(
      `${this.baseURL}${endpoint}`
    );
    return response.data;
  }

  protected buildQueryParams(params: Record<string, any>): string {
    const searchParams = new URLSearchParams();
    
    Object.entries(params).forEach(([key, value]) => {
      if (value !== null && value !== undefined && value !== '') {
        if (Array.isArray(value)) {
          value.forEach(item => searchParams.append(`${key}[]`, item));
        } else {
          searchParams.append(key, value.toString());
        }
      }
    });

    return searchParams.toString();
  }
}

使用者 API 服務範例

// src/api/modules/user.ts
import { BaseApiService } from '../base';
import type { ApiResponse, PaginationParams } from '../base';

export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
  role: string;
  is_active: boolean;
  created_at: string;
  updated_at: string;
}

export interface CreateUserDto {
  name: string;
  email: string;
  password: string;
  role: string;
}

export interface UpdateUserDto {
  name?: string;
  email?: string;
  avatar?: string;
  role?: string;
  is_active?: boolean;
}

export interface UserListParams extends PaginationParams {
  search?: string;
  role?: string;
  is_active?: boolean;
}

export class UserApiService extends BaseApiService {
  protected baseURL = '/users';

  /**
   * 取得使用者列表
   */
  async getUsers(params: UserListParams = {}): Promise<ApiResponse<User[]>> {
    return this.get<User[]>('', params);
  }

  /**
   * 根據 ID 取得使用者詳細資料
   */
  async getUser(id: number): Promise<ApiResponse<User>> {
    return this.get<User>(`/${id}`);
  }

  /**
   * 建立新使用者
   */
  async createUser(userData: CreateUserDto): Promise<ApiResponse<User>> {
    return this.post<User>('', userData);
  }

  /**
   * 更新使用者資料
   */
  async updateUser(id: number, userData: UpdateUserDto): Promise<ApiResponse<User>> {
    return this.put<User>(`/${id}`, userData);
  }

  /**
   * 刪除使用者
   */
  async deleteUser(id: number): Promise<ApiResponse<void>> {
    return this.delete<void>(`/${id}`);
  }

  /**
   * 上傳使用者頭像
   */
  async uploadAvatar(id: number, file: File): Promise<ApiResponse<{ avatar_url: string }>> {
    const formData = new FormData();
    formData.append('avatar', file);

    return this.post<{ avatar_url: string }>(`/${id}/avatar`, formData);
  }

  /**
   * 批次操作使用者
   */
  async batchUpdateUsers(
    userIds: number[],
    action: 'activate' | 'deactivate' | 'delete'
  ): Promise<ApiResponse<void>> {
    return this.post<void>('/batch', {
      user_ids: userIds,
      action,
    });
  }

  /**
   * 匯出使用者資料
   */
  async exportUsers(params: UserListParams = {}): Promise<Blob> {
    const queryString = this.buildQueryParams(params);
    const response = await apiClient.get(`${this.baseURL}/export?${queryString}`, {
      responseType: 'blob',
    });
    return response.data;
  }
}

// 匯出單例實例
export const userApi = new UserApiService();

6.3 Composables 資料管理

useApi Composable

// src/composables/useApi.ts
import { ref, computed, type Ref } from 'vue';
import type { ApiResponse } from '@/api/base';

export interface UseApiOptions<T> {
  immediate?: boolean;
  initialData?: T;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
}

export interface UseApiReturn<T> {
  data: Ref<T | null>;
  loading: Ref<boolean>;
  error: Ref<Error | null>;
  execute: (...args: any[]) => Promise<T>;
  refresh: () => Promise<T>;
  reset: () => void;
}

export function useApi<T>(
  apiFunction: (...args: any[]) => Promise<ApiResponse<T>>,
  options: UseApiOptions<T> = {}
): UseApiReturn<T> {
  const {
    immediate = false,
    initialData = null,
    onSuccess,
    onError,
  } = options;

  const data = ref<T | null>(initialData);
  const loading = ref(false);
  const error = ref<Error | null>(null);
  const lastArgs = ref<any[]>([]);

  const execute = async (...args: any[]): Promise<T> => {
    try {
      loading.value = true;
      error.value = null;
      lastArgs.value = args;

      const response = await apiFunction(...args);
      data.value = response.data;

      onSuccess?.(response.data);
      return response.data;
    } catch (err) {
      const errorInstance = err instanceof Error ? err : new Error(String(err));
      error.value = errorInstance;
      onError?.(errorInstance);
      throw errorInstance;
    } finally {
      loading.value = false;
    }
  };

  const refresh = (): Promise<T> => {
    return execute(...lastArgs.value);
  };

  const reset = (): void => {
    data.value = initialData;
    loading.value = false;
    error.value = null;
    lastArgs.value = [];
  };

  // 立即執行
  if (immediate) {
    execute();
  }

  return {
    data,
    loading,
    error,
    execute,
    refresh,
    reset,
  };
}

使用者資料管理 Composable

// src/composables/useUsers.ts
import { ref, computed } from 'vue';
import { userApi, type User, type UserListParams } from '@/api/modules/user';
import { useApi } from './useApi';

export function useUsers() {
  const users = ref<User[]>([]);
  const currentUser = ref<User | null>(null);
  const pagination = ref({
    current_page: 1,
    last_page: 1,
    per_page: 10,
    total: 0,
  });

  // 取得使用者列表
  const {
    data: userListData,
    loading: loadingUsers,
    error: usersError,
    execute: fetchUsers,
  } = useApi(userApi.getUsers, {
    onSuccess: (response) => {
      users.value = response.data;
      if (response.meta) {
        pagination.value = response.meta;
      }
    },
  });

  // 取得單一使用者
  const {
    data: userData,
    loading: loadingUser,
    error: userError,
    execute: fetchUser,
  } = useApi(userApi.getUser, {
    onSuccess: (data) => {
      currentUser.value = data;
    },
  });

  // 建立使用者
  const {
    loading: creatingUser,
    error: createError,
    execute: createUser,
  } = useApi(userApi.createUser, {
    onSuccess: (data) => {
      users.value.unshift(data);
      pagination.value.total += 1;
    },
  });

  // 更新使用者
  const {
    loading: updatingUser,
    error: updateError,
    execute: updateUser,
  } = useApi(userApi.updateUser, {
    onSuccess: (data) => {
      const index = users.value.findIndex(u => u.id === data.id);
      if (index !== -1) {
        users.value[index] = data;
      }
      if (currentUser.value?.id === data.id) {
        currentUser.value = data;
      }
    },
  });

  // 刪除使用者
  const {
    loading: deletingUser,
    error: deleteError,
    execute: deleteUser,
  } = useApi(userApi.deleteUser, {
    onSuccess: (_, userId: number) => {
      users.value = users.value.filter(u => u.id !== userId);
      pagination.value.total -= 1;
      if (currentUser.value?.id === userId) {
        currentUser.value = null;
      }
    },
  });

  // 計算屬性
  const activeUsers = computed(() => 
    users.value.filter(user => user.is_active)
  );

  const totalPages = computed(() => pagination.value.last_page);

  const isLoading = computed(() => 
    loadingUsers.value || loadingUser.value || 
    creatingUser.value || updatingUser.value || deletingUser.value
  );

  const hasError = computed(() => 
    usersError.value || userError.value || 
    createError.value || updateError.value || deleteError.value
  );

  // 搜尋使用者
  const searchUsers = async (params: UserListParams): Promise<void> => {
    await fetchUsers(params);
  };

  // 重新整理使用者列表
  const refreshUsers = async (): Promise<void> => {
    await fetchUsers();
  };

  // 清除錯誤
  const clearErrors = (): void => {
    usersError.value = null;
    userError.value = null;
    createError.value = null;
    updateError.value = null;
    deleteError.value = null;
  };

  return {
    // 狀態
    users,
    currentUser,
    pagination,
    activeUsers,
    totalPages,
    isLoading,
    hasError,

    // 方法
    fetchUsers,
    fetchUser,
    createUser,
    updateUser,
    deleteUser,
    searchUsers,
    refreshUsers,
    clearErrors,

    // 個別載入狀態
    loadingUsers,
    loadingUser,
    creatingUser,
    updatingUser,
    deletingUser,

    // 個別錯誤狀態
    usersError,
    userError,
    createError,
    updateError,
    deleteError,
  };
}

6.4 快取策略

簡單記憶體快取

// src/utils/cache.ts
interface CacheItem<T> {
  data: T;
  timestamp: number;
  ttl: number;
}

class MemoryCache {
  private cache = new Map<string, CacheItem<any>>();

  set<T>(key: string, data: T, ttl: number = 300000): void { // 預設 5 分鐘
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl,
    });
  }

  get<T>(key: string): T | null {
    const item = this.cache.get(key);
    
    if (!item) {
      return null;
    }

    const now = Date.now();
    if (now - item.timestamp > item.ttl) {
      this.cache.delete(key);
      return null;
    }

    return item.data;
  }

  has(key: string): boolean {
    return this.get(key) !== null;
  }

  delete(key: string): void {
    this.cache.delete(key);
  }

  clear(): void {
    this.cache.clear();
  }

  size(): number {
    // 清理過期項目
    const now = Date.now();
    for (const [key, item] of this.cache.entries()) {
      if (now - item.timestamp > item.ttl) {
        this.cache.delete(key);
      }
    }
    return this.cache.size;
  }
}

export const memoryCache = new MemoryCache();

帶快取的 API Composable

// src/composables/useCachedApi.ts
import { useApi, type UseApiOptions } from './useApi';
import { memoryCache } from '@/utils/cache';
import type { ApiResponse } from '@/api/base';

export interface UseCachedApiOptions<T> extends UseApiOptions<T> {
  cacheKey?: string;
  cacheTTL?: number;
  skipCache?: boolean;
}

export function useCachedApi<T>(
  apiFunction: (...args: any[]) => Promise<ApiResponse<T>>,
  options: UseCachedApiOptions<T> = {}
) {
  const {
    cacheKey,
    cacheTTL = 300000, // 5 分鐘
    skipCache = false,
    ...apiOptions
  } = options;

  const originalApiFunction = apiFunction;

  // 包裝 API 函式以支援快取
  const cachedApiFunction = async (...args: any[]): Promise<ApiResponse<T>> => {
    const key = cacheKey || `${apiFunction.name}_${JSON.stringify(args)}`;

    // 如果不跳過快取且有快取資料,則返回快取
    if (!skipCache && memoryCache.has(key)) {
      const cachedData = memoryCache.get<T>(key);
      return {
        data: cachedData,
        message: 'success',
        success: true,
        code: 200,
      };
    }

    // 呼叫原始 API
    const response = await originalApiFunction(...args);

    // 儲存到快取
    if (!skipCache) {
      memoryCache.set(key, response.data, cacheTTL);
    }

    return response;
  };

  const api = useApi(cachedApiFunction, apiOptions);

  // 清除快取的方法
  const clearCache = (): void => {
    if (cacheKey) {
      memoryCache.delete(cacheKey);
    }
  };

  return {
    ...api,
    clearCache,
  };
}

7. 狀態管理規範

7.1 Pinia Store 結構設計

基礎 Store 模板

// src/stores/base.ts
import { defineStore } from 'pinia';
import { ref, computed, type Ref } from 'vue';

export interface BaseState {
  loading: boolean;
  error: string | null;
  lastUpdated: Date | null;
}

export interface BaseActions {
  setLoading: (loading: boolean) => void;
  setError: (error: string | null) => void;
  clearError: () => void;
  reset: () => void;
}

export function createBaseStore(storeName: string) {
  return defineStore(storeName, () => {
    // 基礎狀態
    const loading = ref(false);
    const error = ref<string | null>(null);
    const lastUpdated = ref<Date | null>(null);

    // 基礎 Getters
    const isLoading = computed(() => loading.value);
    const hasError = computed(() => error.value !== null);
    const isInitialized = computed(() => lastUpdated.value !== null);

    // 基礎 Actions
    const setLoading = (value: boolean): void => {
      loading.value = value;
    };

    const setError = (errorMessage: string | null): void => {
      error.value = errorMessage;
      loading.value = false;
    };

    const clearError = (): void => {
      error.value = null;
    };

    const updateTimestamp = (): void => {
      lastUpdated.value = new Date();
    };

    const reset = (): void => {
      loading.value = false;
      error.value = null;
      lastUpdated.value = null;
    };

    return {
      // State
      loading,
      error,
      lastUpdated,

      // Getters
      isLoading,
      hasError,
      isInitialized,

      // Actions
      setLoading,
      setError,
      clearError,
      updateTimestamp,
      reset,
    };
  });
}

使用者認證 Store

// src/stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { authApi, type LoginCredentials, type User } from '@/api/modules/auth';
import { router } from '@/router';

export interface AuthState {
  user: User | null;
  token: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  permissions: string[];
  lastLoginTime: Date | null;
}

export const useAuthStore = defineStore('auth', () => {
  // State
  const user = ref<User | null>(null);
  const token = ref<string | null>(localStorage.getItem('auth_token'));
  const refreshToken = ref<string | null>(localStorage.getItem('refresh_token'));
  const permissions = ref<string[]>([]);
  const lastLoginTime = ref<Date | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);

  // Getters
  const isAuthenticated = computed(() => Boolean(token.value && user.value));
  const userRole = computed(() => user.value?.role);
  const userName = computed(() => user.value?.name);
  const userEmail = computed(() => user.value?.email);
  const hasPermission = computed(() => (permission: string) => 
    permissions.value.includes(permission) || permissions.value.includes('*')
  );

  // Actions
  const login = async (credentials: LoginCredentials): Promise<void> => {
    try {
      loading.value = true;
      error.value = null;

      const response = await authApi.login(credentials);
      const { user: userData, token: accessToken, refresh_token, permissions: userPermissions } = response.data;

      // 更新狀態
      user.value = userData;
      token.value = accessToken;
      refreshToken.value = refresh_token;
      permissions.value = userPermissions;
      lastLoginTime.value = new Date();

      // 儲存到 localStorage
      localStorage.setItem('auth_token', accessToken);
      localStorage.setItem('refresh_token', refresh_token);
      localStorage.setItem('user_data', JSON.stringify(userData));

      // 重定向到首頁
      await router.push('/dashboard');
    } catch (err) {
      error.value = err instanceof Error ? err.message : '登入失敗';
      throw err;
    } finally {
      loading.value = false;
    }
  };

  const logout = async (): Promise<void> => {
    try {
      loading.value = true;

      // 呼叫登出 API
      if (token.value) {
        await authApi.logout();
      }
    } catch (err) {
      console.error('登出 API 呼叫失敗:', err);
    } finally {
      // 清除狀態
      user.value = null;
      token.value = null;
      refreshToken.value = null;
      permissions.value = [];
      lastLoginTime.value = null;
      error.value = null;

      // 清除 localStorage
      localStorage.removeItem('auth_token');
      localStorage.removeItem('refresh_token');
      localStorage.removeItem('user_data');

      loading.value = false;

      // 重定向到登入頁
      await router.push('/login');
    }
  };

  const refreshAccessToken = async (): Promise<string> => {
    if (!refreshToken.value) {
      throw new Error('沒有 refresh token');
    }

    try {
      const response = await authApi.refreshToken(refreshToken.value);
      const { token: newToken, refresh_token: newRefreshToken } = response.data;

      token.value = newToken;
      refreshToken.value = newRefreshToken;

      localStorage.setItem('auth_token', newToken);
      localStorage.setItem('refresh_token', newRefreshToken);

      return newToken;
    } catch (err) {
      // Refresh token 無效,需要重新登入
      await logout();
      throw new Error('登入已過期,請重新登入');
    }
  };

  const updateProfile = async (profileData: Partial<User>): Promise<void> => {
    if (!user.value) {
      throw new Error('使用者未登入');
    }

    try {
      loading.value = true;
      error.value = null;

      const response = await authApi.updateProfile(profileData);
      user.value = response.data;

      // 更新 localStorage
      localStorage.setItem('user_data', JSON.stringify(user.value));
    } catch (err) {
      error.value = err instanceof Error ? err.message : '更新個人資料失敗';
      throw err;
    } finally {
      loading.value = false;
    }
  };

  const initializeFromStorage = (): void => {
    const storedUser = localStorage.getItem('user_data');
    const storedToken = localStorage.getItem('auth_token');
    const storedRefreshToken = localStorage.getItem('refresh_token');

    if (storedUser && storedToken) {
      try {
        user.value = JSON.parse(storedUser);
        token.value = storedToken;
        refreshToken.value = storedRefreshToken;
      } catch (err) {
        console.error('解析儲存的使用者資料失敗:', err);
        logout();
      }
    }
  };

  const checkPermission = (permission: string): boolean => {
    return hasPermission.value(permission);
  };

  const hasAnyPermission = (permissionList: string[]): boolean => {
    return permissionList.some(permission => checkPermission(permission));
  };

  const hasAllPermissions = (permissionList: string[]): boolean => {
    return permissionList.every(permission => checkPermission(permission));
  };

  // 初始化
  initializeFromStorage();

  return {
    // State
    user,
    token,
    refreshToken,
    permissions,
    lastLoginTime,
    loading,
    error,

    // Getters
    isAuthenticated,
    userRole,
    userName,
    userEmail,
    hasPermission,

    // Actions
    login,
    logout,
    refreshAccessToken,
    updateProfile,
    initializeFromStorage,
    checkPermission,
    hasAnyPermission,
    hasAllPermissions,
  };
});

應用程式設定 Store

// src/stores/app.ts
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';

export type Theme = 'light' | 'dark' | 'auto';
export type Language = 'zh-TW' | 'zh-CN' | 'en-US';

export interface AppSettings {
  theme: Theme;
  language: Language;
  sidebarCollapsed: boolean;
  pageSize: number;
  dateFormat: string;
  timeFormat: string;
  currency: string;
  timezone: string;
}

export const useAppStore = defineStore('app', () => {
  // State
  const theme = ref<Theme>('auto');
  const language = ref<Language>('zh-TW');
  const sidebarCollapsed = ref(false);
  const pageSize = ref(10);
  const dateFormat = ref('YYYY-MM-DD');
  const timeFormat = ref('HH:mm:ss');
  const currency = ref('TWD');
  const timezone = ref('Asia/Taipei');
  const isOnline = ref(navigator.onLine);
  const windowWidth = ref(window.innerWidth);
  const windowHeight = ref(window.innerHeight);

  // Getters
  const currentTheme = computed(() => {
    if (theme.value === 'auto') {
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    }
    return theme.value;
  });

  const isMobile = computed(() => windowWidth.value < 768);
  const isTablet = computed(() => windowWidth.value >= 768 && windowWidth.value < 1024);
  const isDesktop = computed(() => windowWidth.value >= 1024);

  const deviceType = computed(() => {
    if (isMobile.value) return 'mobile';
    if (isTablet.value) return 'tablet';
    return 'desktop';
  });

  // Actions
  const setTheme = (newTheme: Theme): void => {
    theme.value = newTheme;
    applyTheme();
    saveToStorage();
  };

  const setLanguage = (newLanguage: Language): void => {
    language.value = newLanguage;
    saveToStorage();
  };

  const toggleSidebar = (): void => {
    sidebarCollapsed.value = !sidebarCollapsed.value;
    saveToStorage();
  };

  const setSidebarCollapsed = (collapsed: boolean): void => {
    sidebarCollapsed.value = collapsed;
    saveToStorage();
  };

  const updateSettings = (settings: Partial<AppSettings>): void => {
    if (settings.theme !== undefined) theme.value = settings.theme;
    if (settings.language !== undefined) language.value = settings.language;
    if (settings.sidebarCollapsed !== undefined) sidebarCollapsed.value = settings.sidebarCollapsed;
    if (settings.pageSize !== undefined) pageSize.value = settings.pageSize;
    if (settings.dateFormat !== undefined) dateFormat.value = settings.dateFormat;
    if (settings.timeFormat !== undefined) timeFormat.value = settings.timeFormat;
    if (settings.currency !== undefined) currency.value = settings.currency;
    if (settings.timezone !== undefined) timezone.value = settings.timezone;

    applyTheme();
    saveToStorage();
  };

  const applyTheme = (): void => {
    const root = document.documentElement;
    root.setAttribute('data-theme', currentTheme.value);
    
    if (currentTheme.value === 'dark') {
      root.classList.add('dark');
    } else {
      root.classList.remove('dark');
    }
  };

  const saveToStorage = (): void => {
    const settings: AppSettings = {
      theme: theme.value,
      language: language.value,
      sidebarCollapsed: sidebarCollapsed.value,
      pageSize: pageSize.value,
      dateFormat: dateFormat.value,
      timeFormat: timeFormat.value,
      currency: currency.value,
      timezone: timezone.value,
    };

    localStorage.setItem('app_settings', JSON.stringify(settings));
  };

  const loadFromStorage = (): void => {
    const stored = localStorage.getItem('app_settings');
    if (stored) {
      try {
        const settings: AppSettings = JSON.parse(stored);
        updateSettings(settings);
      } catch (err) {
        console.error('解析應用程式設定失敗:', err);
      }
    }
  };

  const updateWindowSize = (): void => {
    windowWidth.value = window.innerWidth;
    windowHeight.value = window.innerHeight;
  };

  const setOnlineStatus = (online: boolean): void => {
    isOnline.value = online;
  };

  const resetSettings = (): void => {
    theme.value = 'auto';
    language.value = 'zh-TW';
    sidebarCollapsed.value = false;
    pageSize.value = 10;
    dateFormat.value = 'YYYY-MM-DD';
    timeFormat.value = 'HH:mm:ss';
    currency.value = 'TWD';
    timezone.value = 'Asia/Taipei';

    applyTheme();
    saveToStorage();
  };

  // 監聽視窗大小變化
  window.addEventListener('resize', updateWindowSize);

  // 監聽網路狀態
  window.addEventListener('online', () => setOnlineStatus(true));
  window.addEventListener('offline', () => setOnlineStatus(false));

  // 監聽系統主題變化
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  mediaQuery.addEventListener('change', () => {
    if (theme.value === 'auto') {
      applyTheme();
    }
  });

  // 自動儲存設定
  watch(
    [theme, language, sidebarCollapsed, pageSize, dateFormat, timeFormat, currency, timezone],
    () => {
      saveToStorage();
    },
    { deep: true }
  );

  // 初始化
  loadFromStorage();
  applyTheme();

  return {
    // State
    theme,
    language,
    sidebarCollapsed,
    pageSize,
    dateFormat,
    timeFormat,
    currency,
    timezone,
    isOnline,
    windowWidth,
    windowHeight,

    // Getters
    currentTheme,
    isMobile,
    isTablet,
    isDesktop,
    deviceType,

    // Actions
    setTheme,
    setLanguage,
    toggleSidebar,
    setSidebarCollapsed,
    updateSettings,
    resetSettings,
    updateWindowSize,
    setOnlineStatus,
    loadFromStorage,
    saveToStorage,
  };
});

7.2 模組化 Store 管理

Store 工廠函式

// src/stores/factory.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { ApiResponse } from '@/api/base';

export interface CrudState<T> {
  items: T[];
  currentItem: T | null;
  loading: boolean;
  error: string | null;
  pagination: {
    current_page: number;
    last_page: number;
    per_page: number;
    total: number;
  };
}

export interface CrudApi<T, CreateDto, UpdateDto> {
  getList: (params?: any) => Promise<ApiResponse<T[]>>;
  getById: (id: number) => Promise<ApiResponse<T>>;
  create: (data: CreateDto) => Promise<ApiResponse<T>>;
  update: (id: number, data: UpdateDto) => Promise<ApiResponse<T>>;
  delete: (id: number) => Promise<ApiResponse<void>>;
}

export function createCrudStore<T extends { id: number }, CreateDto, UpdateDto>(
  storeName: string,
  api: CrudApi<T, CreateDto, UpdateDto>
) {
  return defineStore(storeName, () => {
    // State
    const items = ref<T[]>([]);
    const currentItem = ref<T | null>(null);
    const loading = ref(false);
    const error = ref<string | null>(null);
    const pagination = ref({
      current_page: 1,
      last_page: 1,
      per_page: 10,
      total: 0,
    });

    // Getters
    const itemCount = computed(() => items.value.length);
    const hasItems = computed(() => items.value.length > 0);
    const isLoading = computed(() => loading.value);
    const hasError = computed(() => error.value !== null);

    // Actions
    const setLoading = (value: boolean): void => {
      loading.value = value;
    };

    const setError = (errorMessage: string | null): void => {
      error.value = errorMessage;
    };

    const clearError = (): void => {
      error.value = null;
    };

    const fetchItems = async (params?: any): Promise<void> => {
      try {
        setLoading(true);
        clearError();

        const response = await api.getList(params);
        items.value = response.data;

        if (response.meta) {
          pagination.value = response.meta;
        }
      } catch (err) {
        setError(err instanceof Error ? err.message : '取得資料失敗');
        throw err;
      } finally {
        setLoading(false);
      }
    };

    const fetchItem = async (id: number): Promise<void> => {
      try {
        setLoading(true);
        clearError();

        const response = await api.getById(id);
        currentItem.value = response.data;
      } catch (err) {
        setError(err instanceof Error ? err.message : '取得資料失敗');
        throw err;
      } finally {
        setLoading(false);
      }
    };

    const createItem = async (data: CreateDto): Promise<T> => {
      try {
        setLoading(true);
        clearError();

        const response = await api.create(data);
        items.value.unshift(response.data);
        pagination.value.total += 1;

        return response.data;
      } catch (err) {
        setError(err instanceof Error ? err.message : '建立資料失敗');
        throw err;
      } finally {
        setLoading(false);
      }
    };

    const updateItem = async (id: number, data: UpdateDto): Promise<T> => {
      try {
        setLoading(true);
        clearError();

        const response = await api.update(id, data);
        const index = items.value.findIndex(item => item.id === id);

        if (index !== -1) {
          items.value[index] = response.data;
        }

        if (currentItem.value?.id === id) {
          currentItem.value = response.data;
        }

        return response.data;
      } catch (err) {
        setError(err instanceof Error ? err.message : '更新資料失敗');
        throw err;
      } finally {
        setLoading(false);
      }
    };

    const deleteItem = async (id: number): Promise<void> => {
      try {
        setLoading(true);
        clearError();

        await api.delete(id);
        items.value = items.value.filter(item => item.id !== id);
        pagination.value.total -= 1;

        if (currentItem.value?.id === id) {
          currentItem.value = null;
        }
      } catch (err) {
        setError(err instanceof Error ? err.message : '刪除資料失敗');
        throw err;
      } finally {
        setLoading(false);
      }
    };

    const findItemById = (id: number): T | undefined => {
      return items.value.find(item => item.id === id);
    };

    const reset = (): void => {
      items.value = [];
      currentItem.value = null;
      loading.value = false;
      error.value = null;
      pagination.value = {
        current_page: 1,
        last_page: 1,
        per_page: 10,
        total: 0,
      };
    };

    return {
      // State
      items,
      currentItem,
      loading,
      error,
      pagination,

      // Getters
      itemCount,
      hasItems,
      isLoading,
      hasError,

      // Actions
      fetchItems,
      fetchItem,
      createItem,
      updateItem,
      deleteItem,
      findItemById,
      setLoading,
      setError,
      clearError,
      reset,
    };
  });
}

使用工廠函式建立特定 Store

// src/stores/product.ts
import { createCrudStore } from './factory';
import { productApi, type Product, type CreateProductDto, type UpdateProductDto } from '@/api/modules/product';

export const useProductStore = createCrudStore<Product, CreateProductDto, UpdateProductDto>(
  'product',
  productApi
);

// 擴展特定功能
export const useEnhancedProductStore = defineStore('enhancedProduct', () => {
  const baseStore = useProductStore();

  // 擴展的計算屬性
  const activeProducts = computed(() => 
    baseStore.items.filter(product => product.is_active)
  );

  const productsByCategory = computed(() => {
    const groups: Record<string, Product[]> = {};
    baseStore.items.forEach(product => {
      if (!groups[product.category]) {
        groups[product.category] = [];
      }
      groups[product.category].push(product);
    });
    return groups;
  });

  // 擴展的方法
  const toggleProductStatus = async (id: number): Promise<void> => {
    const product = baseStore.findItemById(id);
    if (product) {
      await baseStore.updateItem(id, { is_active: !product.is_active });
    }
  };

  const searchProducts = async (query: string): Promise<void> => {
    await baseStore.fetchItems({ search: query });
  };

  return {
    ...baseStore,
    activeProducts,
    productsByCategory,
    toggleProductStatus,
    searchProducts,
  };
});

7.3 Store 之間的通訊

跨 Store 通訊模式

// src/stores/notification.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';

export interface Notification {
  id: string;
  type: 'success' | 'error' | 'warning' | 'info';
  title: string;
  message: string;
  duration?: number;
  actions?: Array<{
    label: string;
    action: () => void;
  }>;
  timestamp: Date;
}

export const useNotificationStore = defineStore('notification', () => {
  const notifications = ref<Notification[]>([]);

  const addNotification = (notification: Omit<Notification, 'id' | 'timestamp'>): string => {
    const id = `notification_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
    const newNotification: Notification = {
      ...notification,
      id,
      timestamp: new Date(),
      duration: notification.duration ?? 5000,
    };

    notifications.value.push(newNotification);

    // 自動移除通知
    if (newNotification.duration && newNotification.duration > 0) {
      setTimeout(() => {
        removeNotification(id);
      }, newNotification.duration);
    }

    return id;
  };

  const removeNotification = (id: string): void => {
    const index = notifications.value.findIndex(n => n.id === id);
    if (index !== -1) {
      notifications.value.splice(index, 1);
    }
  };

  const clearAll = (): void => {
    notifications.value = [];
  };

  // 便利方法
  const success = (title: string, message: string, options?: Partial<Notification>): string => {
    return addNotification({ ...options, type: 'success', title, message });
  };

  const error = (title: string, message: string, options?: Partial<Notification>): string => {
    return addNotification({ ...options, type: 'error', title, message });
  };

  const warning = (title: string, message: string, options?: Partial<Notification>): string => {
    return addNotification({ ...options, type: 'warning', title, message });
  };

  const info = (title: string, message: string, options?: Partial<Notification>): string => {
    return addNotification({ ...options, type: 'info', title, message });
  };

  return {
    notifications,
    addNotification,
    removeNotification,
    clearAll,
    success,
    error,
    warning,
    info,
  };
});

Store 組合模式

// src/stores/dashboard.ts
import { defineStore } from 'pinia';
import { computed } from 'vue';
import { useAuthStore } from './auth';
import { useUserStore } from './user';
import { useProductStore } from './product';
import { useNotificationStore } from './notification';

export const useDashboardStore = defineStore('dashboard', () => {
  const authStore = useAuthStore();
  const userStore = useUserStore();
  const productStore = useProductStore();
  const notificationStore = useNotificationStore();

  // 組合多個 store 的資料
  const dashboardData = computed(() => ({
    user: authStore.user,
    totalUsers: userStore.itemCount,
    totalProducts: productStore.itemCount,
    recentNotifications: notificationStore.notifications.slice(0, 5),
  }));

  const initializeDashboard = async (): Promise<void> => {
    try {
      // 並行載入多個資源
      await Promise.all([
        userStore.fetchItems({ per_page: 5, sort_by: 'created_at', sort_order: 'desc' }),
        productStore.fetchItems({ per_page: 10, is_active: true }),
      ]);

      notificationStore.success('儀表板', '資料載入完成');
    } catch (error) {
      notificationStore.error('儀表板', '資料載入失敗');
      throw error;
    }
  };

  const refreshDashboard = async (): Promise<void> => {
    await initializeDashboard();
  };

  return {
    dashboardData,
    initializeDashboard,
    refreshDashboard,
  };
});

8. 多語系處理規範

8.1 Vue I18n 設定

基礎 i18n 設定

// src/i18n/index.ts
import { createI18n } from 'vue-i18n';
import { useAppStore } from '@/stores/app';

// 語言模組
import zhTW from './locales/zh-TW.json';
import zhCN from './locales/zh-CN.json';
import enUS from './locales/en-US.json';

export type MessageSchema = typeof zhTW;

export const SUPPORTED_LOCALES = [
  { code: 'zh-TW', name: '繁體中文', flag: '🇹🇼' },
  { code: 'zh-CN', name: '简体中文', flag: '🇨🇳' },
  { code: 'en-US', name: 'English', flag: '🇺🇸' },
] as const;

export type SupportedLocale = typeof SUPPORTED_LOCALES[number]['code'];

// 創建 i18n 實例
export const i18n = createI18n<[MessageSchema], SupportedLocale>({
  legacy: false, // 使用 Composition API
  locale: 'zh-TW', // 預設語言
  fallbackLocale: 'zh-TW', // 回退語言
  globalInjection: true, // 全域注入 $t
  messages: {
    'zh-TW': zhTW,
    'zh-CN': zhCN,
    'en-US': enUS,
  },
  numberFormats: {
    'zh-TW': {
      currency: {
        style: 'currency',
        currency: 'TWD',
        notation: 'standard',
      },
      decimal: {
        style: 'decimal',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      },
      percent: {
        style: 'percent',
        useGrouping: false,
      },
    },
    'zh-CN': {
      currency: {
        style: 'currency',
        currency: 'CNY',
        notation: 'standard',
      },
      decimal: {
        style: 'decimal',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      },
      percent: {
        style: 'percent',
        useGrouping: false,
      },
    },
    'en-US': {
      currency: {
        style: 'currency',
        currency: 'USD',
        notation: 'standard',
      },
      decimal: {
        style: 'decimal',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      },
      percent: {
        style: 'percent',
        useGrouping: false,
      },
    },
  },
  datetimeFormats: {
    'zh-TW': {
      short: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
      },
      long: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
        weekday: 'short',
        hour: 'numeric',
        minute: 'numeric',
      },
      date: {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
      },
      time: {
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
      },
    },
    'zh-CN': {
      short: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
      },
      long: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
        weekday: 'short',
        hour: 'numeric',
        minute: 'numeric',
      },
      date: {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
      },
      time: {
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
      },
    },
    'en-US': {
      short: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
      },
      long: {
        year: 'numeric',
        month: 'short',
        day: 'numeric',
        weekday: 'short',
        hour: 'numeric',
        minute: 'numeric',
      },
      date: {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
      },
      time: {
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
      },
    },
  },
});

// 設定語言切換函式
export const setLocale = (locale: SupportedLocale): void => {
  i18n.global.locale.value = locale;
  document.documentElement.lang = locale;
  
  // 更新 store 中的語言設定
  const appStore = useAppStore();
  appStore.setLanguage(locale);
};

// 取得瀏覽器偏好語言
export const getBrowserLocale = (): SupportedLocale => {
  const navigatorLocale = navigator.language;
  const supportedCodes = SUPPORTED_LOCALES.map(locale => locale.code);
  
  if (supportedCodes.includes(navigatorLocale as SupportedLocale)) {
    return navigatorLocale as SupportedLocale;
  }

  // 檢查語言前綴
  const langPrefix = navigatorLocale.split('-')[0];
  const matchedLocale = supportedCodes.find(code => code.startsWith(langPrefix));
  
  return (matchedLocale as SupportedLocale) || 'zh-TW';
};

// 初始化語言設定
export const initializeLocale = (): void => {
  const appStore = useAppStore();
  const savedLocale = appStore.language;
  
  if (savedLocale && SUPPORTED_LOCALES.some(locale => locale.code === savedLocale)) {
    setLocale(savedLocale);
  } else {
    const browserLocale = getBrowserLocale();
    setLocale(browserLocale);
  }
};

8.2 翻譯檔案結構

繁體中文翻譯檔案

// src/i18n/locales/zh-TW.json
{
  "common": {
    "ok": "確定",
    "cancel": "取消",
    "save": "儲存",
    "edit": "編輯",
    "delete": "刪除",
    "add": "新增",
    "search": "搜尋",
    "loading": "載入中...",
    "noData": "暫無資料",
    "confirm": "確認",
    "back": "返回",
    "next": "下一步",
    "previous": "上一步",
    "submit": "提交",
    "reset": "重設",
    "close": "關閉",
    "view": "檢視",
    "download": "下載",
    "upload": "上傳",
    "refresh": "重新整理",
    "filter": "篩選",
    "sort": "排序",
    "export": "匯出",
    "import": "匯入"
  },
  "auth": {
    "login": "登入",
    "logout": "登出",
    "register": "註冊",
    "forgotPassword": "忘記密碼",
    "resetPassword": "重設密碼",
    "changePassword": "變更密碼",
    "email": "電子郵件",
    "password": "密碼",
    "confirmPassword": "確認密碼",
    "rememberMe": "記住我",
    "loginSuccess": "登入成功",
    "loginFailed": "登入失敗",
    "logoutSuccess": "登出成功",
    "invalidCredentials": "帳號或密碼錯誤",
    "passwordTooWeak": "密碼強度不足",
    "emailExists": "電子郵件已存在",
    "accountNotFound": "找不到此帳號",
    "accountDisabled": "帳號已被停用"
  },
  "nav": {
    "dashboard": "儀表板",
    "users": "使用者管理",
    "products": "產品管理",
    "orders": "訂單管理",
    "reports": "報表分析",
    "settings": "系統設定",
    "profile": "個人資料",
    "help": "說明",
    "about": "關於我們"
  },
  "user": {
    "title": "使用者管理",
    "name": "姓名",
    "email": "電子郵件",
    "role": "角色",
    "status": "狀態",
    "active": "啟用",
    "inactive": "停用",
    "createdAt": "建立時間",
    "updatedAt": "更新時間",
    "lastLogin": "最後登入",
    "actions": "操作",
    "addUser": "新增使用者",
    "editUser": "編輯使用者",
    "deleteUser": "刪除使用者",
    "userDetails": "使用者詳情",
    "avatar": "頭像",
    "permissions": "權限",
    "assignRole": "指派角色",
    "changeStatus": "變更狀態",
    "resetPassword": "重設密碼",
    "sendInvitation": "發送邀請",
    "bulkActions": "批次操作",
    "selectedUsers": "已選擇 {count} 位使用者",
    "confirmDelete": "確定要刪除這位使用者嗎?",
    "deleteSuccess": "使用者刪除成功",
    "createSuccess": "使用者建立成功",
    "updateSuccess": "使用者更新成功"
  },
  "product": {
    "title": "產品管理",
    "name": "產品名稱",
    "category": "分類",
    "price": "價格",
    "stock": "庫存",
    "description": "描述",
    "images": "圖片",
    "status": "狀態",
    "featured": "精選",
    "published": "已發布",
    "draft": "草稿",
    "outOfStock": "缺貨",
    "lowStock": "庫存不足",
    "sku": "商品編號",
    "weight": "重量",
    "dimensions": "尺寸",
    "tags": "標籤",
    "seo": "SEO 設定",
    "metaTitle": "標題",
    "metaDescription": "描述",
    "addProduct": "新增產品",
    "editProduct": "編輯產品",
    "deleteProduct": "刪除產品",
    "duplicateProduct": "複製產品",
    "viewProduct": "檢視產品",
    "manageInventory": "庫存管理",
    "priceHistory": "價格歷史",
    "salesReport": "銷售報表"
  },
  "form": {
    "validation": {
      "required": "此欄位為必填",
      "email": "請輸入有效的電子郵件地址",
      "minLength": "最少需要 {min} 個字元",
      "maxLength": "最多只能 {max} 個字元",
      "min": "最小值為 {min}",
      "max": "最大值為 {max}",
      "pattern": "格式不正確",
      "sameAs": "輸入不一致",
      "phone": "請輸入有效的電話號碼",
      "url": "請輸入有效的網址",
      "numeric": "請輸入數字",
      "alpha": "只能輸入字母",
      "alphaNum": "只能輸入字母和數字"
    },
    "placeholder": {
      "search": "請輸入搜尋關鍵字...",
      "email": "請輸入電子郵件",
      "password": "請輸入密碼",
      "name": "請輸入姓名",
      "phone": "請輸入電話號碼",
      "address": "請輸入地址",
      "remark": "請輸入備註"
    }
  },
  "message": {
    "success": {
      "saved": "儲存成功",
      "updated": "更新成功",
      "deleted": "刪除成功",
      "uploaded": "上傳成功",
      "sent": "發送成功",
      "copied": "複製成功"
    },
    "error": {
      "general": "操作失敗,請稍後再試",
      "network": "網路連線失敗",
      "timeout": "請求逾時",
      "unauthorized": "您沒有權限執行此操作",
      "notFound": "找不到請求的資源",
      "serverError": "伺服器發生錯誤",
      "validationFailed": "資料驗證失敗",
      "fileUploadFailed": "檔案上傳失敗",
      "fileTooLarge": "檔案大小超過限制",
      "unsupportedFileType": "不支援的檔案類型"
    },
    "confirm": {
      "delete": "確定要刪除嗎?此操作無法復原。",
      "logout": "確定要登出嗎?",
      "unsavedChanges": "您有未儲存的變更,確定要離開嗎?",
      "resetForm": "確定要重設表單嗎?",
      "clearData": "確定要清除所有資料嗎?"
    }
  },
  "date": {
    "today": "今天",
    "yesterday": "昨天",
    "tomorrow": "明天",
    "thisWeek": "本週",
    "lastWeek": "上週",
    "thisMonth": "本月",
    "lastMonth": "上月",
    "thisYear": "今年",
    "lastYear": "去年",
    "custom": "自訂範圍",
    "selectDate": "選擇日期",
    "selectTime": "選擇時間",
    "selectDateTime": "選擇日期時間"
  },
  "pagination": {
    "showing": "顯示第 {from} 到 {to} 筆,共 {total} 筆記錄",
    "itemsPerPage": "每頁顯示",
    "firstPage": "第一頁",
    "lastPage": "最後一頁",
    "previousPage": "上一頁",
    "nextPage": "下一頁",
    "page": "第 {page} 頁",
    "of": "共 {total} 頁"
  }
}

英文翻譯檔案範例

// src/i18n/locales/en-US.json
{
  "common": {
    "ok": "OK",
    "cancel": "Cancel",
    "save": "Save",
    "edit": "Edit",
    "delete": "Delete",
    "add": "Add",
    "search": "Search",
    "loading": "Loading...",
    "noData": "No data available",
    "confirm": "Confirm",
    "back": "Back",
    "next": "Next",
    "previous": "Previous",
    "submit": "Submit",
    "reset": "Reset",
    "close": "Close",
    "view": "View",
    "download": "Download",
    "upload": "Upload",
    "refresh": "Refresh",
    "filter": "Filter",
    "sort": "Sort",
    "export": "Export",
    "import": "Import"
  },
  "auth": {
    "login": "Login",
    "logout": "Logout",
    "register": "Register",
    "forgotPassword": "Forgot Password",
    "resetPassword": "Reset Password",
    "changePassword": "Change Password",
    "email": "Email",
    "password": "Password",
    "confirmPassword": "Confirm Password",
    "rememberMe": "Remember Me",
    "loginSuccess": "Login successful",
    "loginFailed": "Login failed",
    "logoutSuccess": "Logout successful",
    "invalidCredentials": "Invalid email or password",
    "passwordTooWeak": "Password is too weak",
    "emailExists": "Email already exists",
    "accountNotFound": "Account not found",
    "accountDisabled": "Account has been disabled"
  }
  // ... 其他翻譯內容
}

8.3 Composables 多語系功能

useI18n Composable

// src/composables/useI18n.ts
import { computed } from 'vue';
import { useI18n as useVueI18n } from 'vue-i18n';
import { useAppStore } from '@/stores/app';
import { setLocale, type SupportedLocale, SUPPORTED_LOCALES } from '@/i18n';

export function useI18n() {
  const { t, n, d, tm, rt, locale } = useVueI18n();
  const appStore = useAppStore();

  // 當前語言資訊
  const currentLocale = computed(() => locale.value);
  const currentLanguage = computed(() => 
    SUPPORTED_LOCALES.find(lang => lang.code === locale.value)
  );

  // 可用語言列表
  const availableLocales = computed(() => SUPPORTED_LOCALES);

  // 切換語言
  const switchLocale = (newLocale: SupportedLocale): void => {
    setLocale(newLocale);
  };

  // 格式化日期時間
  const formatDate = (date: Date | string | number, format: string = 'date'): string => {
    const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
    return d(dateObj, format);
  };

  const formatDateTime = (date: Date | string | number): string => {
    return formatDate(date, 'long');
  };

  const formatTime = (date: Date | string | number): string => {
    return formatDate(date, 'time');
  };

  // 格式化數字
  const formatNumber = (value: number, format: string = 'decimal'): string => {
    return n(value, format);
  };

  const formatCurrency = (value: number): string => {
    return formatNumber(value, 'currency');
  };

  const formatPercent = (value: number): string => {
    return formatNumber(value / 100, 'percent');
  };

  // 複數形式處理
  const plural = (count: number, key: string): string => {
    return t(key, count);
  };

  // 翻譯選項列表
  const translateOptions = (options: Array<{ value: any; labelKey: string }>): Array<{ value: any; label: string }> => {
    return options.map(option => ({
      value: option.value,
      label: t(option.labelKey),
    }));
  };

  // 取得翻譯物件
  const getTranslations = (key: string): any => {
    return tm(key);
  };

  // 檢查翻譯鍵是否存在
  const hasTranslation = (key: string): boolean => {
    try {
      const translation = t(key);
      return translation !== key && translation !== '';
    } catch {
      return false;
    }
  };

  // 動態載入翻譯
  const loadLocaleMessages = async (locale: SupportedLocale): Promise<void> => {
    try {
      const messages = await import(`@/i18n/locales/${locale}.json`);
      const { setLocaleMessage } = useVueI18n();
      setLocaleMessage(locale, messages.default);
    } catch (error) {
      console.error(`Failed to load locale messages for ${locale}:`, error);
    }
  };

  // 批次翻譯
  const translateBatch = (keys: string[]): Record<string, string> => {
    const result: Record<string, string> = {};
    keys.forEach(key => {
      result[key] = t(key);
    });
    return result;
  };

  return {
    // 基礎翻譯功能
    t,
    n,
    d,
    tm,
    rt,

    // 語言資訊
    currentLocale,
    currentLanguage,
    availableLocales,

    // 語言切換
    switchLocale,

    // 格式化功能
    formatDate,
    formatDateTime,
    formatTime,
    formatNumber,
    formatCurrency,
    formatPercent,

    // 進階功能
    plural,
    translateOptions,
    getTranslations,
    hasTranslation,
    loadLocaleMessages,
    translateBatch,
  };
}

useFormValidation 多語系驗證

// src/composables/useFormValidation.ts
import { computed } from 'vue';
import { useI18n } from './useI18n';

export interface ValidationRule {
  required?: boolean;
  minLength?: number;
  maxLength?: number;
  min?: number;
  max?: number;
  pattern?: RegExp;
  email?: boolean;
  phone?: boolean;
  url?: boolean;
  numeric?: boolean;
  alpha?: boolean;
  alphaNum?: boolean;
  sameAs?: string;
  custom?: (value: any) => boolean | string;
}

export function useFormValidation() {
  const { t } = useI18n();

  const createValidator = (rules: ValidationRule) => {
    return (value: any, formData?: Record<string, any>): string | true => {
      // Required 驗證
      if (rules.required && (!value || value === '' || value === null || value === undefined)) {
        return t('form.validation.required');
      }

      // 如果值為空且不是必填,則通過驗證
      if (!value && !rules.required) {
        return true;
      }

      const stringValue = String(value);

      // 最小長度驗證
      if (rules.minLength && stringValue.length < rules.minLength) {
        return t('form.validation.minLength', { min: rules.minLength });
      }

      // 最大長度驗證
      if (rules.maxLength && stringValue.length > rules.maxLength) {
        return t('form.validation.maxLength', { max: rules.maxLength });
      }

      // 最小值驗證
      if (rules.min !== undefined && Number(value) < rules.min) {
        return t('form.validation.min', { min: rules.min });
      }

      // 最大值驗證
      if (rules.max !== undefined && Number(value) > rules.max) {
        return t('form.validation.max', { max: rules.max });
      }

      // 正規表達式驗證
      if (rules.pattern && !rules.pattern.test(stringValue)) {
        return t('form.validation.pattern');
      }

      // 電子郵件驗證
      if (rules.email) {
        const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailPattern.test(stringValue)) {
          return t('form.validation.email');
        }
      }

      // 電話號碼驗證
      if (rules.phone) {
        const phonePattern = /^[\d\s\-\+\(\)]+$/;
        if (!phonePattern.test(stringValue)) {
          return t('form.validation.phone');
        }
      }

      // URL 驗證
      if (rules.url) {
        try {
          new URL(stringValue);
        } catch {
          return t('form.validation.url');
        }
      }

      // 數字驗證
      if (rules.numeric && isNaN(Number(value))) {
        return t('form.validation.numeric');
      }

      // 字母驗證
      if (rules.alpha && !/^[a-zA-Z]+$/.test(stringValue)) {
        return t('form.validation.alpha');
      }

      // 字母數字驗證
      if (rules.alphaNum && !/^[a-zA-Z0-9]+$/.test(stringValue)) {
        return t('form.validation.alphaNum');
      }

      // 相同值驗證
      if (rules.sameAs && formData && value !== formData[rules.sameAs]) {
        return t('form.validation.sameAs');
      }

      // 自定義驗證
      if (rules.custom) {
        const result = rules.custom(value);
        if (result !== true) {
          return typeof result === 'string' ? result : t('form.validation.pattern');
        }
      }

      return true;
    };
  };

  // 常用驗證規則
  const commonRules = computed(() => ({
    required: createValidator({ required: true }),
    email: createValidator({ required: true, email: true }),
    phone: createValidator({ phone: true }),
    password: createValidator({ required: true, minLength: 8 }),
    confirmPassword: (confirmField: string) => createValidator({ 
      required: true, 
      sameAs: confirmField 
    }),
    numeric: createValidator({ numeric: true }),
    positiveNumber: createValidator({ numeric: true, min: 0 }),
    url: createValidator({ url: true }),
  }));

  return {
    createValidator,
    commonRules,
  };
}

8.4 語言切換組件

語言選擇器組件

<!-- src/components/common/LanguageSwitcher.vue -->
<template>
  <div class="language-switcher">
    <AppDropdown
      :model-value="currentLanguage"
      :options="languageOptions"
      :placeholder="$t('nav.selectLanguage')"
      option-label="name"
      option-value="code"
      @update:model-value="handleLanguageChange"
    >
      <template #value="{ value }">
        <div v-if="value" class="flex items-center gap-2">
          <span class="text-lg">{{ getLanguageFlag(value) }}</span>
          <span class="hidden sm:inline">{{ getLanguageName(value) }}</span>
        </div>
      </template>

      <template #option="{ option }">
        <div class="flex items-center gap-2 w-full">
          <span class="text-lg">{{ option.flag }}</span>
          <span>{{ option.name }}</span>
        </div>
      </template>
    </AppDropdown>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import AppDropdown from '@/components/ui/AppDropdown.vue';

const { currentLocale, availableLocales, switchLocale } = useI18n();

// 語言選項
const languageOptions = computed(() => availableLocales.value);

// 當前語言
const currentLanguage = computed(() => currentLocale.value);

// 取得語言旗幟
const getLanguageFlag = (code: string): string => {
  const language = availableLocales.value.find(lang => lang.code === code);
  return language?.flag || '🌐';
};

// 取得語言名稱
const getLanguageName = (code: string): string => {
  const language = availableLocales.value.find(lang => lang.code === code);
  return language?.name || code;
};

// 處理語言變更
const handleLanguageChange = (locale: string): void => {
  switchLocale(locale as any);
};
</script>

<style scoped>
.language-switcher {
  @apply min-w-[120px];
}

@media (max-width: 640px) {
  .language-switcher {
    @apply min-w-[60px];
  }
}
</style>

翻譯輔助組件

<!-- src/components/common/TranslationHelper.vue -->
<template>
  <div v-if="showHelper" class="translation-helper">
    <div class="fixed bottom-4 right-4 z-50">
      <div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border p-4 max-w-sm">
        <div class="flex items-center justify-between mb-2">
          <h3 class="font-semibold text-sm">{{ $t('dev.translationHelper') }}</h3>
          <button
            @click="closeHelper"
            class="text-gray-500 hover:text-gray-700 dark:text-gray-400"
          >
            <XMarkIcon class="h-4 w-4" />
          </button>
        </div>
        
        <div class="space-y-2 text-xs">
          <div>
            <span class="font-medium">{{ $t('dev.currentLocale') }}:</span>
            <span class="ml-1">{{ currentLocale }}</span>
          </div>
          
          <div v-if="missingKeys.length > 0">
            <span class="font-medium text-red-600">{{ $t('dev.missingKeys') }}:</span>
            <ul class="mt-1 space-y-1">
              <li
                v-for="key in missingKeys.slice(0, 5)"
                :key="key"
                class="text-red-600 font-mono break-all"
              >
                {{ key }}
              </li>
              <li v-if="missingKeys.length > 5" class="text-gray-500">
                ... {{ missingKeys.length - 5 }} more
              </li>
            </ul>
          </div>

          <div class="pt-2 border-t">
            <button
              @click="exportMissingKeys"
              class="text-blue-600 hover:text-blue-800 underline"
            >
              {{ $t('dev.exportMissingKeys') }}
            </button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { XMarkIcon } from '@heroicons/vue/24/outline';
import { useI18n } from '@/composables/useI18n';

const { currentLocale } = useI18n();

const showHelper = ref(false);
const missingKeys = ref<string[]>([]);

// 開發模式下才顯示
const isDevelopment = computed(() => import.meta.env.DEV);

// 監聽翻譯錯誤
const handleI18nError = (error: any) => {
  if (error && error.key) {
    if (!missingKeys.value.includes(error.key)) {
      missingKeys.value.push(error.key);
    }
  }
};

// 匯出缺失的翻譯鍵
const exportMissingKeys = (): void => {
  const data = {
    locale: currentLocale.value,
    missingKeys: missingKeys.value,
    timestamp: new Date().toISOString(),
  };

  const blob = new Blob([JSON.stringify(data, null, 2)], {
    type: 'application/json',
  });

  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = `missing-translations-${currentLocale.value}-${Date.now()}.json`;
  link.click();
  URL.revokeObjectURL(url);
};

// 關閉助手
const closeHelper = (): void => {
  showHelper.value = false;
  localStorage.setItem('translation-helper-hidden', 'true');
};

onMounted(() => {
  if (isDevelopment.value) {
    const hidden = localStorage.getItem('translation-helper-hidden');
    showHelper.value = !hidden;

    // 監聽翻譯錯誤(這需要根據實際的 i18n 設定來調整)
    window.addEventListener('i18n-missing', handleI18nError);

    // 快捷鍵開啟助手
    const handleKeydown = (e: KeyboardEvent) => {
      if (e.ctrlKey && e.shiftKey && e.key === 'T') {
        showHelper.value = !showHelper.value;
      }
    };
    window.addEventListener('keydown', handleKeydown);

    onUnmounted(() => {
      window.removeEventListener('i18n-missing', handleI18nError);
      window.removeEventListener('keydown', handleKeydown);
    });
  }
});
</script>

9. 測試規範

9.1 測試環境設定

Vitest 設定

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{js,ts,vue}'],
    exclude: [
      'node_modules',
      'dist',
      'e2e',
      'cypress',
      '**/*.d.ts',
    ],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.{js,ts}',
        '**/index.ts',
        'src/main.ts',
      ],
      thresholds: {
        global: {
          branches: 80,
          functions: 80,
          lines: 80,
          statements: 80,
        },
      },
    },
    deps: {
      inline: ['@vue', '@vueuse'],
    },
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
    },
  },
});

測試設定檔案

// src/test/setup.ts
import { beforeAll, afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/vue';
import '@testing-library/jest-dom';

// 全域設定
beforeAll(() => {
  // Mock IntersectionObserver
  global.IntersectionObserver = vi.fn().mockImplementation(() => ({
    observe: vi.fn(),
    unobserve: vi.fn(),
    disconnect: vi.fn(),
  }));

  // Mock ResizeObserver
  global.ResizeObserver = vi.fn().mockImplementation(() => ({
    observe: vi.fn(),
    unobserve: vi.fn(),
    disconnect: vi.fn(),
  }));

  // Mock matchMedia
  Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: vi.fn().mockImplementation(query => ({
      matches: false,
      media: query,
      onchange: null,
      addListener: vi.fn(),
      removeListener: vi.fn(),
      addEventListener: vi.fn(),
      removeEventListener: vi.fn(),
      dispatchEvent: vi.fn(),
    })),
  });

  // Mock localStorage
  const localStorageMock = {
    getItem: vi.fn(),
    setItem: vi.fn(),
    removeItem: vi.fn(),
    clear: vi.fn(),
  };
  vi.stubGlobal('localStorage', localStorageMock);

  // Mock sessionStorage
  const sessionStorageMock = {
    getItem: vi.fn(),
    setItem: vi.fn(),
    removeItem: vi.fn(),
    clear: vi.fn(),
  };
  vi.stubGlobal('sessionStorage', sessionStorageMock);

  // Mock fetch
  global.fetch = vi.fn();

  // Mock console methods for cleaner test output
  vi.spyOn(console, 'warn').mockImplementation(() => {});
  vi.spyOn(console, 'error').mockImplementation(() => {});
});

// 清理
afterEach(() => {
  cleanup();
  vi.clearAllMocks();
});

測試工具函式

// src/test/utils.ts
import { render, type RenderOptions } from '@testing-library/vue';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { createI18n } from 'vue-i18n';
import type { Component } from 'vue';

// 建立測試用的 i18n 實例
export const createTestI18n = (locale = 'zh-TW', messages = {}) => {
  const defaultMessages = {
    'zh-TW': {
      common: {
        ok: '確定',
        cancel: '取消',
        save: '儲存',
        edit: '編輯',
        delete: '刪除',
      },
      form: {
        validation: {
          required: '此欄位為必填',
          email: '請輸入有效的電子郵件地址',
        },
      },
    },
    'en-US': {
      common: {
        ok: 'OK',
        cancel: 'Cancel',
        save: 'Save',
        edit: 'Edit',
        delete: 'Delete',
      },
      form: {
        validation: {
          required: 'This field is required',
          email: 'Please enter a valid email address',
        },
      },
    },
  };

  return createI18n({
    legacy: false,
    locale,
    messages: { ...defaultMessages, ...messages },
  });
};

// 建立測試用的 Pinia 實例
export const createTestPinia = (options: any = {}) => {
  return createTestingPinia({
    createSpy: vi.fn,
    stubActions: false,
    ...options,
  });
};

// 自定義 render 函式
export const renderWithProviders = (
  component: Component,
  options: RenderOptions & {
    piniaOptions?: any;
    i18nOptions?: any;
    routerOptions?: any;
  } = {}
) => {
  const { piniaOptions, i18nOptions, routerOptions, ...renderOptions } = options;

  const globalPlugins = [];

  // 加入 Pinia
  globalPlugins.push([createTestPinia(piniaOptions), {}]);

  // 加入 i18n
  globalPlugins.push([createTestI18n('zh-TW', i18nOptions), {}]);

  // 加入 Router (如果需要)
  if (routerOptions) {
    const { createRouter, createWebHistory } = require('vue-router');
    const router = createRouter({
      history: createWebHistory(),
      routes: routerOptions.routes || [],
    });
    globalPlugins.push([router, {}]);
  }

  const defaultOptions: RenderOptions = {
    global: {
      plugins: globalPlugins,
      ...renderOptions.global,
    },
    ...renderOptions,
  };

  return render(component, defaultOptions);
};

// Mock API 回應
export const createMockApiResponse = <T>(data: T, options: any = {}) => {
  return {
    data: data,
    message: options.message || 'success',
    success: options.success !== false,
    code: options.code || 200,
    meta: options.meta,
  };
};

// Mock Promise
export const createMockPromise = <T>(data: T, delay = 0, shouldReject = false) => {
  return new Promise<T>((resolve, reject) => {
    setTimeout(() => {
      if (shouldReject) {
        reject(new Error('Mock error'));
      } else {
        resolve(data);
      }
    }, delay);
  });
};

// 等待 DOM 更新
export const waitForNextTick = () => {
  return new Promise(resolve => setTimeout(resolve, 0));
};

// 觸發事件
export const triggerEvent = (element: Element, eventType: string, eventData: any = {}) => {
  const event = new CustomEvent(eventType, {
    bubbles: true,
    cancelable: true,
    detail: eventData,
  });
  element.dispatchEvent(event);
};

// 模擬用戶輸入
export const simulateUserInput = async (element: HTMLInputElement, value: string) => {
  element.focus();
  element.value = value;
  element.dispatchEvent(new Event('input', { bubbles: true }));
  element.dispatchEvent(new Event('change', { bubbles: true }));
  await waitForNextTick();
};

// 模擬檔案上傳
export const createMockFile = (name: string, type: string, size: number = 1024) => {
  const file = new File([''], name, { type });
  Object.defineProperty(file, 'size', { value: size });
  return file;
};

9.2 單元測試規範

組件測試範例

// src/components/ui/AppButton.test.ts
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/vue';
import { renderWithProviders } from '@/test/utils';
import AppButton from './AppButton.vue';

describe('AppButton', () => {
  it('should render with default props', () => {
    const { getByRole } = renderWithProviders(AppButton, {
      slots: {
        default: 'Click me',
      },
    });

    const button = getByRole('button');
    expect(button).toBeInTheDocument();
    expect(button).toHaveTextContent('Click me');
    expect(button).toHaveClass('btn', 'btn-primary');
  });

  it('should render different variants correctly', () => {
    const { getByRole, rerender } = renderWithProviders(AppButton, {
      props: { variant: 'secondary' },
      slots: { default: 'Button' },
    });

    let button = getByRole('button');
    expect(button).toHaveClass('btn-secondary');

    rerender({ variant: 'danger' });
    button = getByRole('button');
    expect(button).toHaveClass('btn-danger');
  });

  it('should handle different sizes', () => {
    const { getByRole } = renderWithProviders(AppButton, {
      props: { size: 'large' },
      slots: { default: 'Large Button' },
    });

    const button = getByRole('button');
    expect(button).toHaveClass('btn-lg');
  });

  it('should be disabled when loading', () => {
    const { getByRole } = renderWithProviders(AppButton, {
      props: { loading: true },
      slots: { default: 'Loading Button' },
    });

    const button = getByRole('button');
    expect(button).toBeDisabled();
    expect(button).toHaveAttribute('aria-busy', 'true');
  });

  it('should emit click event when clicked', async () => {
    const handleClick = vi.fn();
    const { getByRole } = renderWithProviders(AppButton, {
      props: { onClick: handleClick },
      slots: { default: 'Click me' },
    });

    const button = getByRole('button');
    await fireEvent.click(button);

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('should not emit click when disabled', async () => {
    const handleClick = vi.fn();
    const { getByRole } = renderWithProviders(AppButton, {
      props: { 
        disabled: true,
        onClick: handleClick,
      },
      slots: { default: 'Disabled Button' },
    });

    const button = getByRole('button');
    await fireEvent.click(button);

    expect(handleClick).not.toHaveBeenCalled();
  });

  it('should show loading spinner when loading', () => {
    const { container } = renderWithProviders(AppButton, {
      props: { loading: true },
      slots: { default: 'Loading' },
    });

    const spinner = container.querySelector('.loading-spinner');
    expect(spinner).toBeInTheDocument();
  });

  it('should support different button types', () => {
    const { getByRole } = renderWithProviders(AppButton, {
      props: { type: 'submit' },
      slots: { default: 'Submit' },
    });

    const button = getByRole('button');
    expect(button).toHaveAttribute('type', 'submit');
  });

  it('should handle icon slots correctly', () => {
    const { container } = renderWithProviders(AppButton, {
      slots: {
        default: 'Button with icon',
        'icon-left': '<svg data-testid="left-icon"></svg>',
        'icon-right': '<svg data-testid="right-icon"></svg>',
      },
    });

    expect(container.querySelector('[data-testid="left-icon"]')).toBeInTheDocument();
    expect(container.querySelector('[data-testid="right-icon"]')).toBeInTheDocument();
  });
});

Store 測試範例

// src/stores/auth.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useAuthStore } from './auth';
import { authApi } from '@/api/modules/auth';

// Mock API
vi.mock('@/api/modules/auth', () => ({
  authApi: {
    login: vi.fn(),
    logout: vi.fn(),
    refreshToken: vi.fn(),
    updateProfile: vi.fn(),
  },
}));

// Mock router
const mockPush = vi.fn();
vi.mock('@/router', () => ({
  router: {
    push: mockPush,
  },
}));

describe('Auth Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
    vi.clearAllMocks();
    localStorage.clear();
  });

  describe('初始狀態', () => {
    it('should have correct initial state', () => {
      const authStore = useAuthStore();

      expect(authStore.user).toBeNull();
      expect(authStore.token).toBeNull();
      expect(authStore.isAuthenticated).toBe(false);
      expect(authStore.permissions).toEqual([]);
      expect(authStore.loading).toBe(false);
      expect(authStore.error).toBeNull();
    });

    it('should load data from localStorage on initialization', () => {
      const mockUser = { id: 1, name: 'Test User', email: 'test@example.com' };
      const mockToken = 'mock-token';

      localStorage.setItem('user_data', JSON.stringify(mockUser));
      localStorage.setItem('auth_token', mockToken);

      const authStore = useAuthStore();

      expect(authStore.user).toEqual(mockUser);
      expect(authStore.token).toBe(mockToken);
      expect(authStore.isAuthenticated).toBe(true);
    });
  });

  describe('登入流程', () => {
    it('should login successfully', async () => {
      const mockUser = { id: 1, name: 'Test User', email: 'test@example.com' };
      const mockResponse = {
        data: {
          user: mockUser,
          token: 'access-token',
          refresh_token: 'refresh-token',
          permissions: ['read', 'write'],
        },
      };

      vi.mocked(authApi.login).mockResolvedValue(mockResponse);

      const authStore = useAuthStore();
      const credentials = { email: 'test@example.com', password: 'password' };

      await authStore.login(credentials);

      expect(authStore.user).toEqual(mockUser);
      expect(authStore.token).toBe('access-token');
      expect(authStore.refreshToken).toBe('refresh-token');
      expect(authStore.permissions).toEqual(['read', 'write']);
      expect(authStore.isAuthenticated).toBe(true);
      expect(authStore.loading).toBe(false);
      expect(authStore.error).toBeNull();

      // 檢查 localStorage
      expect(localStorage.getItem('auth_token')).toBe('access-token');
      expect(localStorage.getItem('refresh_token')).toBe('refresh-token');
      expect(localStorage.getItem('user_data')).toBe(JSON.stringify(mockUser));

      // 檢查路由重定向
      expect(mockPush).toHaveBeenCalledWith('/dashboard');
    });

    it('should handle login failure', async () => {
      const error = new Error('Invalid credentials');
      vi.mocked(authApi.login).mockRejectedValue(error);

      const authStore = useAuthStore();
      const credentials = { email: 'test@example.com', password: 'wrong-password' };

      await expect(authStore.login(credentials)).rejects.toThrow('Invalid credentials');

      expect(authStore.user).toBeNull();
      expect(authStore.token).toBeNull();
      expect(authStore.isAuthenticated).toBe(false);
      expect(authStore.loading).toBe(false);
      expect(authStore.error).toBe('Invalid credentials');
    });

    it('should set loading state during login', async () => {
      let resolveLogin: (value: any) => void;
      const loginPromise = new Promise(resolve => {
        resolveLogin = resolve;
      });

      vi.mocked(authApi.login).mockReturnValue(loginPromise);

      const authStore = useAuthStore();
      const credentials = { email: 'test@example.com', password: 'password' };

      const loginCall = authStore.login(credentials);

      // 檢查 loading 狀態
      expect(authStore.loading).toBe(true);

      // 完成登入
      resolveLogin!({
        data: {
          user: { id: 1, name: 'Test User' },
          token: 'token',
          refresh_token: 'refresh',
          permissions: [],
        },
      });

      await loginCall;

      expect(authStore.loading).toBe(false);
    });
  });

  describe('登出流程', () => {
    it('should logout successfully', async () => {
      const authStore = useAuthStore();

      // 設定初始登入狀態
      authStore.user = { id: 1, name: 'Test User', email: 'test@example.com' };
      authStore.token = 'access-token';
      authStore.refreshToken = 'refresh-token';
      authStore.permissions = ['read', 'write'];

      localStorage.setItem('auth_token', 'access-token');
      localStorage.setItem('refresh_token', 'refresh-token');
      localStorage.setItem('user_data', JSON.stringify(authStore.user));

      vi.mocked(authApi.logout).mockResolvedValue(undefined as any);

      await authStore.logout();

      expect(authStore.user).toBeNull();
      expect(authStore.token).toBeNull();
      expect(authStore.refreshToken).toBeNull();
      expect(authStore.permissions).toEqual([]);
      expect(authStore.isAuthenticated).toBe(false);

      // 檢查 localStorage 清理
      expect(localStorage.getItem('auth_token')).toBeNull();
      expect(localStorage.getItem('refresh_token')).toBeNull();
      expect(localStorage.getItem('user_data')).toBeNull();

      // 檢查路由重定向
      expect(mockPush).toHaveBeenCalledWith('/login');
    });

    it('should logout even when API call fails', async () => {
      const authStore = useAuthStore();
      authStore.token = 'access-token';

      vi.mocked(authApi.logout).mockRejectedValue(new Error('API Error'));

      await authStore.logout();

      expect(authStore.user).toBeNull();
      expect(authStore.token).toBeNull();
      expect(authStore.isAuthenticated).toBe(false);
    });
  });

  describe('權限檢查', () => {
    it('should check permissions correctly', () => {
      const authStore = useAuthStore();
      authStore.permissions = ['read', 'write', 'admin'];

      expect(authStore.checkPermission('read')).toBe(true);
      expect(authStore.checkPermission('delete')).toBe(false);
      expect(authStore.hasAnyPermission(['read', 'delete'])).toBe(true);
      expect(authStore.hasAnyPermission(['delete', 'modify'])).toBe(false);
      expect(authStore.hasAllPermissions(['read', 'write'])).toBe(true);
      expect(authStore.hasAllPermissions(['read', 'delete'])).toBe(false);
    });

    it('should handle wildcard permissions', () => {
      const authStore = useAuthStore();
      authStore.permissions = ['*'];

      expect(authStore.checkPermission('any-permission')).toBe(true);
    });
  });

  describe('Token 刷新', () => {
    it('should refresh token successfully', async () => {
      const authStore = useAuthStore();
      authStore.refreshToken = 'refresh-token';

      const mockResponse = {
        data: {
          token: 'new-access-token',
          refresh_token: 'new-refresh-token',
        },
      };

      vi.mocked(authApi.refreshToken).mockResolvedValue(mockResponse);

      const newToken = await authStore.refreshAccessToken();

      expect(newToken).toBe('new-access-token');
      expect(authStore.token).toBe('new-access-token');
      expect(authStore.refreshToken).toBe('new-refresh-token');

      expect(localStorage.getItem('auth_token')).toBe('new-access-token');
      expect(localStorage.getItem('refresh_token')).toBe('new-refresh-token');
    });

    it('should logout when refresh token is invalid', async () => {
      const authStore = useAuthStore();
      authStore.refreshToken = 'invalid-refresh-token';

      vi.mocked(authApi.refreshToken).mockRejectedValue(new Error('Invalid refresh token'));

      await expect(authStore.refreshAccessToken()).rejects.toThrow('登入已過期,請重新登入');

      expect(authStore.user).toBeNull();
      expect(authStore.token).toBeNull();
      expect(authStore.isAuthenticated).toBe(false);
    });

    it('should throw error when no refresh token', async () => {
      const authStore = useAuthStore();
      authStore.refreshToken = null;

      await expect(authStore.refreshAccessToken()).rejects.toThrow('沒有 refresh token');
    });
  });
});

Composable 測試範例

// src/composables/useApi.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref } from 'vue';
import { useApi } from './useApi';
import { createMockApiResponse, createMockPromise } from '@/test/utils';

describe('useApi', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should initialize with default state', () => {
    const mockApiFunction = vi.fn();
    const { data, loading, error } = useApi(mockApiFunction);

    expect(data.value).toBeNull();
    expect(loading.value).toBe(false);
    expect(error.value).toBeNull();
  });

  it('should execute API function successfully', async () => {
    const mockData = { id: 1, name: 'Test' };
    const mockResponse = createMockApiResponse(mockData);
    const mockApiFunction = vi.fn().mockResolvedValue(mockResponse);

    const { data, loading, error, execute } = useApi(mockApiFunction);

    const result = await execute('test-param');

    expect(mockApiFunction).toHaveBeenCalledWith('test-param');
    expect(data.value).toEqual(mockData);
    expect(loading.value).toBe(false);
    expect(error.value).toBeNull();
    expect(result).toEqual(mockData);
  });

  it('should handle API function errors', async () => {
    const mockError = new Error('API Error');
    const mockApiFunction = vi.fn().mockRejectedValue(mockError);

    const { data, loading, error, execute } = useApi(mockApiFunction);

    await expect(execute()).rejects.toThrow('API Error');

    expect(data.value).toBeNull();
    expect(loading.value).toBe(false);
    expect(error.value).toEqual(mockError);
  });

  it('should set loading state correctly', async () => {
    let resolvePromise: (value: any) => void;
    const mockPromise = new Promise(resolve => {
      resolvePromise = resolve;
    });
    const mockApiFunction = vi.fn().mockReturnValue(mockPromise);

    const { loading, execute } = useApi(mockApiFunction);

    const executePromise = execute();

    expect(loading.value).toBe(true);

    resolvePromise!(createMockApiResponse({ test: 'data' }));
    await executePromise;

    expect(loading.value).toBe(false);
  });

  it('should call onSuccess callback', async () => {
    const mockData = { id: 1, name: 'Test' };
    const mockResponse = createMockApiResponse(mockData);
    const mockApiFunction = vi.fn().mockResolvedValue(mockResponse);
    const onSuccess = vi.fn();

    const { execute } = useApi(mockApiFunction, { onSuccess });

    await execute();

    expect(onSuccess).toHaveBeenCalledWith(mockData);
  });

  it('should call onError callback', async () => {
    const mockError = new Error('API Error');
    const mockApiFunction = vi.fn().mockRejectedValue(mockError);
    const onError = vi.fn();

    const { execute } = useApi(mockApiFunction, { onError });

    await expect(execute()).rejects.toThrow();

    expect(onError).toHaveBeenCalledWith(mockError);
  });

  it('should execute immediately when immediate option is true', async () => {
    const mockData = { id: 1, name: 'Test' };
    const mockResponse = createMockApiResponse(mockData);
    const mockApiFunction = vi.fn().mockResolvedValue(mockResponse);

    useApi(mockApiFunction, { immediate: true });

    // 等待 Promise 完成
    await new Promise(resolve => setTimeout(resolve, 0));

    expect(mockApiFunction).toHaveBeenCalled();
  });

  it('should refresh with last arguments', async () => {
    const mockData = { id: 1, name: 'Test' };
    const mockResponse = createMockApiResponse(mockData);
    const mockApiFunction = vi.fn().mockResolvedValue(mockResponse);

    const { execute, refresh } = useApi(mockApiFunction);

    await execute('param1', 'param2');
    mockApiFunction.mockClear();

    await refresh();

    expect(mockApiFunction).toHaveBeenCalledWith('param1', 'param2');
  });

  it('should reset state correctly', () => {
    const mockApiFunction = vi.fn();
    const { data, loading, error, reset } = useApi(mockApiFunction, {
      initialData: { test: 'initial' },
    });

    // 設定一些狀態
    data.value = { test: 'modified' };
    loading.value = true;
    error.value = new Error('Test error');

    reset();

    expect(data.value).toEqual({ test: 'initial' });
    expect(loading.value).toBe(false);
    expect(error.value).toBeNull();
  });
});

9.3 整合測試規範

組件整合測試

// src/components/user/UserForm.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/vue';
import { renderWithProviders, simulateUserInput } from '@/test/utils';
import UserForm from './UserForm.vue';
import { userApi } from '@/api/modules/user';

// Mock API
vi.mock('@/api/modules/user', () => ({
  userApi: {
    createUser: vi.fn(),
    updateUser: vi.fn(),
    getUser: vi.fn(),
  },
}));

describe('UserForm Integration', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('建立新使用者', () => {
    it('should create user successfully', async () => {
      const mockUser = {
        id: 1,
        name: 'John Doe',
        email: 'john@example.com',
        role: 'user',
      };

      vi.mocked(userApi.createUser).mockResolvedValue({
        data: mockUser,
        message: 'success',
        success: true,
        code: 200,
      });

      const onSuccess = vi.fn();
      const { getByLabelText, getByRole } = renderWithProviders(UserForm, {
        props: {
          mode: 'create',
          onSuccess,
        },
      });

      // 填寫表單
      const nameInput = getByLabelText('姓名') as HTMLInputElement;
      const emailInput = getByLabelText('電子郵件') as HTMLInputElement;
      const roleSelect = getByLabelText('角色') as HTMLSelectElement;

      await simulateUserInput(nameInput, 'John Doe');
      await simulateUserInput(emailInput, 'john@example.com');
      await fireEvent.change(roleSelect, { target: { value: 'user' } });

      // 提交表單
      const submitButton = getByRole('button', { name: '儲存' });
      await fireEvent.click(submitButton);

      await waitFor(() => {
        expect(userApi.createUser).toHaveBeenCalledWith({
          name: 'John Doe',
          email: 'john@example.com',
          role: 'user',
        });
      });

      expect(onSuccess).toHaveBeenCalledWith(mockUser);
    });

    it('should show validation errors for invalid input', async () => {
      const { getByLabelText, getByRole, findByText } = renderWithProviders(UserForm, {
        props: { mode: 'create' },
      });

      // 提交空表單
      const submitButton = getByRole('button', { name: '儲存' });
      await fireEvent.click(submitButton);

      // 檢查驗證錯誤
      await findByText('此欄位為必填');
      expect(userApi.createUser).not.toHaveBeenCalled();
    });

    it('should handle API errors gracefully', async () => {
      vi.mocked(userApi.createUser).mockRejectedValue(new Error('Server error'));

      const { getByLabelText, getByRole, findByText } = renderWithProviders(UserForm, {
        props: { mode: 'create' },
      });

      // 填寫表單
      const nameInput = getByLabelText('姓名') as HTMLInputElement;
      const emailInput = getByLabelText('電子郵件') as HTMLInputElement;

      await simulateUserInput(nameInput, 'John Doe');
      await simulateUserInput(emailInput, 'john@example.com');

      // 提交表單
      const submitButton = getByRole('button', { name: '儲存' });
      await fireEvent.click(submitButton);

      // 檢查錯誤訊息
      await findByText('Server error');
    });
  });

  describe('編輯現有使用者', () => {
    it('should load existing user data', async () => {
      const existingUser = {
        id: 1,
        name: 'Jane Doe',
        email: 'jane@example.com',
        role: 'admin',
      };

      vi.mocked(userApi.getUser).mockResolvedValue({
        data: existingUser,
        message: 'success',
        success: true,
        code: 200,
      });

      const { getByDisplayValue } = renderWithProviders(UserForm, {
        props: {
          mode: 'edit',
          userId: 1,
        },
      });

      await waitFor(() => {
        expect(getByDisplayValue('Jane Doe')).toBeInTheDocument();
        expect(getByDisplayValue('jane@example.com')).toBeInTheDocument();
      });

      expect(userApi.getUser).toHaveBeenCalledWith(1);
    });

    it('should update user successfully', async () => {
      const existingUser = {
        id: 1,
        name: 'Jane Doe',
        email: 'jane@example.com',
        role: 'admin',
      };

      const updatedUser = {
        ...existingUser,
        name: 'Jane Smith',
      };

      vi.mocked(userApi.getUser).mockResolvedValue({
        data: existingUser,
        message: 'success',
        success: true,
        code: 200,
      });

      vi.mocked(userApi.updateUser).mockResolvedValue({
        data: updatedUser,
        message: 'success',
        success: true,
        code: 200,
      });

      const onSuccess = vi.fn();
      const { getByDisplayValue, getByRole } = renderWithProviders(UserForm, {
        props: {
          mode: 'edit',
          userId: 1,
          onSuccess,
        },
      });

      // 等待資料載入
      await waitFor(() => {
        expect(getByDisplayValue('Jane Doe')).toBeInTheDocument();
      });

      // 修改姓名
      const nameInput = getByDisplayValue('Jane Doe') as HTMLInputElement;
      await simulateUserInput(nameInput, 'Jane Smith');

      // 提交表單
      const submitButton = getByRole('button', { name: '儲存' });
      await fireEvent.click(submitButton);

      await waitFor(() => {
        expect(userApi.updateUser).toHaveBeenCalledWith(1, {
          name: 'Jane Smith',
          email: 'jane@example.com',
          role: 'admin',
        });
      });

      expect(onSuccess).toHaveBeenCalledWith(updatedUser);
    });
  });

  describe('表單驗證', () => {
    it('should validate email format', async () => {
      const { getByLabelText, getByRole, findByText } = renderWithProviders(UserForm, {
        props: { mode: 'create' },
      });

      const emailInput = getByLabelText('電子郵件') as HTMLInputElement;
      await simulateUserInput(emailInput, 'invalid-email');

      const submitButton = getByRole('button', { name: '儲存' });
      await fireEvent.click(submitButton);

      await findByText('請輸入有效的電子郵件地址');
    });

    it('should validate required fields', async () => {
      const { getByLabelText, getByRole, findAllByText } = renderWithProviders(UserForm, {
        props: { mode: 'create' },
      });

      const submitButton = getByRole('button', { name: '儲存' });
      await fireEvent.click(submitButton);

      const errorMessages = await findAllByText('此欄位為必填');
      expect(errorMessages.length).toBeGreaterThan(0);
    });
  });

  describe('表單狀態管理', () => {
    it('should show loading state during submission', async () => {
      let resolveCreate: (value: any) => void;
      const createPromise = new Promise(resolve => {
        resolveCreate = resolve;
      });

      vi.mocked(userApi.createUser).mockReturnValue(createPromise);

      const { getByLabelText, getByRole, queryByText } = renderWithProviders(UserForm, {
        props: { mode: 'create' },
      });

      // 填寫表單
      const nameInput = getByLabelText('姓名') as HTMLInputElement;
      await simulateUserInput(nameInput, 'John Doe');

      const emailInput = getByLabelText('電子郵件') as HTMLInputElement;
      await simulateUserInput(emailInput, 'john@example.com');

      // 提交表單
      const submitButton = getByRole('button', { name: '儲存' });
      await fireEvent.click(submitButton);

      // 檢查載入狀態
      expect(queryByText('載入中...')).toBeInTheDocument();
      expect(submitButton).toBeDisabled();

      // 完成請求
      resolveCreate!({
        data: { id: 1, name: 'John Doe', email: 'john@example.com' },
        message: 'success',
        success: true,
        code: 200,
      });

      await waitFor(() => {
        expect(queryByText('載入中...')).not.toBeInTheDocument();
        expect(submitButton).not.toBeDisabled();
      });
    });

    it('should disable form during loading', async () => {
      vi.mocked(userApi.getUser).mockReturnValue(new Promise(() => {})); // 永不解決的 Promise

      const { getByLabelText } = renderWithProviders(UserForm, {
        props: {
          mode: 'edit',
          userId: 1,
        },
      });

      // 等待組件渲染
      await new Promise(resolve => setTimeout(resolve, 0));

      const nameInput = getByLabelText('姓名') as HTMLInputElement;
      expect(nameInput).toBeDisabled();
    });
  });
});

9.4 端到端測試規範

Cypress 設定

// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:5173',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: true,
    screenshotOnRunFailure: true,
    defaultCommandTimeout: 10000,
    requestTimeout: 15000,
    responseTimeout: 15000,
    supportFile: 'cypress/support/e2e.ts',
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
    setupNodeEvents(on, config) {
      // 這裡可以加入自定義插件
      return config;
    },
  },
  component: {
    devServer: {
      framework: 'vue',
      bundler: 'vite',
    },
    supportFile: 'cypress/support/component.ts',
    specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
  },
});

Cypress 支援檔案

// cypress/support/e2e.ts
import './commands';

// 全域設定
Cypress.on('uncaught:exception', (err, runnable) => {
  // 忽略某些非關鍵錯誤
  if (err.message.includes('ResizeObserver loop limit exceeded')) {
    return false;
  }
  return true;
});

// 在每個測試前清理
beforeEach(() => {
  // 清理 localStorage 和 sessionStorage
  cy.clearLocalStorage();
  cy.clearAllSessionStorage();
  
  // 清理 cookies
  cy.clearCookies();
  
  // 重設網路攔截
  cy.intercept('GET', '**/api/**', { fixture: 'empty.json' }).as('apiCall');
});

自定義 Cypress 命令

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      /**
       * 登入用戶
       */
      login(email?: string, password?: string): Chainable<void>;
      
      /**
       * 登出用戶
       */
      logout(): Chainable<void>;
      
      /**
       * 等待 API 請求完成
       */
      waitForApi(alias?: string): Chainable<void>;
      
      /**
       * 填寫表單欄位
       */
      fillForm(formData: Record<string, string>): Chainable<void>;
      
      /**
       * 檢查通知訊息
       */
      checkNotification(type: 'success' | 'error' | 'warning' | 'info', message?: string): Chainable<void>;
      
      /**
       * 檢查表格內容
       */
      checkTableData(selector: string, expectedData: string[][]): Chainable<void>;
      
      /**
       * 模擬檔案上傳
       */
      uploadFile(selector: string, fileName: string, fileType?: string): Chainable<void>;
    }
  }
}

Cypress.Commands.add('login', (email = 'admin@example.com', password = 'password123') => {
  cy.intercept('POST', '**/api/auth/login', {
    statusCode: 200,
    body: {
      data: {
        user: {
          id: 1,
          name: 'Admin User',
          email: email,
          role: 'admin',
        },
        token: 'mock-jwt-token',
        refresh_token: 'mock-refresh-token',
        permissions: ['*'],
      },
      message: 'Login successful',
      success: true,
      code: 200,
    },
  }).as('loginRequest');

  cy.visit('/login');
  cy.get('[data-testid="email-input"]').type(email);
  cy.get('[data-testid="password-input"]').type(password);
  cy.get('[data-testid="login-button"]').click();
  
  cy.wait('@loginRequest');
  cy.url().should('include', '/dashboard');
});

Cypress.Commands.add('logout', () => {
  cy.intercept('POST', '**/api/auth/logout', {
    statusCode: 200,
    body: { message: 'Logout successful', success: true },
  }).as('logoutRequest');

  cy.get('[data-testid="user-menu"]').click();
  cy.get('[data-testid="logout-button"]').click();
  
  cy.wait('@logoutRequest');
  cy.url().should('include', '/login');
});

Cypress.Commands.add('waitForApi', (alias = 'apiCall') => {
  cy.wait(`@${alias}`);
});

Cypress.Commands.add('fillForm', (formData: Record<string, string>) => {
  Object.entries(formData).forEach(([field, value]) => {
    cy.get(`[data-testid="${field}-input"]`).clear().type(value);
  });
});

Cypress.Commands.add('checkNotification', (type: string, message?: string) => {
  cy.get(`[data-testid="notification-${type}"]`).should('be.visible');
  if (message) {
    cy.get(`[data-testid="notification-${type}"]`).should('contain.text', message);
  }
});

Cypress.Commands.add('checkTableData', (selector: string, expectedData: string[][]) => {
  cy.get(selector).within(() => {
    expectedData.forEach((rowData, rowIndex) => {
      cy.get('tbody tr').eq(rowIndex).within(() => {
        rowData.forEach((cellData, cellIndex) => {
          cy.get('td').eq(cellIndex).should('contain.text', cellData);
        });
      });
    });
  });
});

Cypress.Commands.add('uploadFile', (selector: string, fileName: string, fileType = 'image/png') => {
  cy.fixture(fileName, 'base64').then(fileContent => {
    cy.get(selector).selectFile({
      contents: Cypress.Buffer.from(fileContent, 'base64'),
      fileName: fileName,
      mimeType: fileType,
    }, { force: true });
  });
});

E2E 測試範例

// cypress/e2e/user-management.cy.ts
describe('使用者管理', () => {
  beforeEach(() => {
    // 設定 API 攔截
    cy.intercept('GET', '**/api/users', { fixture: 'users.json' }).as('getUsers');
    cy.intercept('POST', '**/api/users', { fixture: 'user-create.json' }).as('createUser');
    cy.intercept('PUT', '**/api/users/*', { fixture: 'user-update.json' }).as('updateUser');
    cy.intercept('DELETE', '**/api/users/*', { statusCode: 200, body: { success: true } }).as('deleteUser');
    
    cy.login();
  });

  describe('使用者列表頁面', () => {
    it('應該顯示使用者列表', () => {
      cy.visit('/users');
      cy.wait('@getUsers');

      // 檢查頁面標題
      cy.get('[data-testid="page-title"]').should('contain.text', '使用者管理');

      // 檢查表格標題
      cy.get('[data-testid="users-table"]').within(() => {
        cy.get('thead tr th').should('contain.text', '姓名');
        cy.get('thead tr th').should('contain.text', '電子郵件');
        cy.get('thead tr th').should('contain.text', '角色');
        cy.get('thead tr th').should('contain.text', '狀態');
        cy.get('thead tr th').should('contain.text', '操作');
      });

      // 檢查表格資料
      cy.checkTableData('[data-testid="users-table"]', [
        ['John Doe', 'john@example.com', '管理員', '啟用'],
        ['Jane Smith', 'jane@example.com', '使用者', '啟用'],
      ]);
    });

    it('應該支援搜尋功能', () => {
      cy.visit('/users');
      cy.wait('@getUsers');

      // 輸入搜尋關鍵字
      cy.get('[data-testid="search-input"]').type('John');
      cy.get('[data-testid="search-button"]').click();

      // 檢查搜尋結果
      cy.get('[data-testid="users-table"] tbody tr').should('have.length', 1);
      cy.get('[data-testid="users-table"] tbody tr').should('contain.text', 'John Doe');
    });

    it('應該支援分頁功能', () => {
      cy.visit('/users');
      cy.wait('@getUsers');

      // 檢查分頁資訊
      cy.get('[data-testid="pagination-info"]').should('contain.text', '顯示第 1 到 10 筆');

      // 點擊下一頁
      cy.get('[data-testid="next-page-button"]').click();
      cy.wait('@getUsers');

      // 檢查頁碼變更
      cy.get('[data-testid="current-page"]').should('contain.text', '2');
    });
  });

  describe('新增使用者', () => {
    it('應該成功建立新使用者', () => {
      cy.visit('/users');
      cy.get('[data-testid="add-user-button"]').click();

      // 檢查是否導向新增頁面
      cy.url().should('include', '/users/create');
      cy.get('[data-testid="page-title"]').should('contain.text', '新增使用者');

      // 填寫表單
      cy.fillForm({
        name: '新使用者',
        email: 'newuser@example.com',
        password: 'password123',
        'confirm-password': 'password123',
      });

      // 選擇角色
      cy.get('[data-testid="role-select"]').select('user');

      // 提交表單
      cy.get('[data-testid="submit-button"]').click();
      cy.wait('@createUser');

      // 檢查成功訊息
      cy.checkNotification('success', '使用者建立成功');

      // 檢查是否導回列表頁
      cy.url().should('include', '/users');
    });

    it('應該驗證表單欄位', () => {
      cy.visit('/users/create');

      // 提交空表單
      cy.get('[data-testid="submit-button"]').click();

      // 檢查驗證錯誤
      cy.get('[data-testid="name-error"]').should('contain.text', '此欄位為必填');
      cy.get('[data-testid="email-error"]').should('contain.text', '此欄位為必填');
      cy.get('[data-testid="password-error"]').should('contain.text', '此欄位為必填');
    });

    it('應該驗證電子郵件格式', () => {
      cy.visit('/users/create');

      cy.get('[data-testid="email-input"]').type('invalid-email');
      cy.get('[data-testid="submit-button"]').click();

      cy.get('[data-testid="email-error"]').should('contain.text', '請輸入有效的電子郵件地址');
    });

    it('應該驗證密碼確認', () => {
      cy.visit('/users/create');

      cy.get('[data-testid="password-input"]').type('password123');
      cy.get('[data-testid="confirm-password-input"]').type('different-password');
      cy.get('[data-testid="submit-button"]').click();

      cy.get('[data-testid="confirm-password-error"]').should('contain.text', '輸入不一致');
    });
  });

  describe('編輯使用者', () => {
    it('應該載入現有使用者資料', () => {
      cy.intercept('GET', '**/api/users/1', { fixture: 'user-detail.json' }).as('getUser');

      cy.visit('/users');
      cy.get('[data-testid="edit-user-1"]').click();

      cy.wait('@getUser');
      cy.url().should('include', '/users/1/edit');

      // 檢查表單預填資料
      cy.get('[data-testid="name-input"]').should('have.value', 'John Doe');
      cy.get('[data-testid="email-input"]').should('have.value', 'john@example.com');
      cy.get('[data-testid="role-select"]').should('have.value', 'admin');
    });

    it('應該成功更新使用者資料', () => {
      cy.intercept('GET', '**/api/users/1', { fixture: 'user-detail.json' }).as('getUser');

      cy.visit('/users/1/edit');
      cy.wait('@getUser');

      // 修改姓名
      cy.get('[data-testid="name-input"]').clear().type('Updated Name');

      // 提交表單
      cy.get('[data-testid="submit-button"]').click();
      cy.wait('@updateUser');

      // 檢查成功訊息
      cy.checkNotification('success', '使用者更新成功');

      // 檢查是否導回列表頁
      cy.url().should('include', '/users');
    });
  });

  describe('刪除使用者', () => {
    it('應該成功刪除使用者', () => {
      cy.visit('/users');
      cy.wait('@getUsers');

      // 點擊刪除按鈕
      cy.get('[data-testid="delete-user-1"]').click();

      // 確認刪除
      cy.get('[data-testid="confirm-dialog"]').should('be.visible');
      cy.get('[data-testid="confirm-delete-button"]').click();

      cy.wait('@deleteUser');

      // 檢查成功訊息
      cy.checkNotification('success', '使用者刪除成功');

      // 檢查使用者從列表中移除
      cy.get('[data-testid="user-row-1"]').should('not.exist');
    });

    it('應該可以取消刪除', () => {
      cy.visit('/users');
      cy.wait('@getUsers');

      // 點擊刪除按鈕
      cy.get('[data-testid="delete-user-1"]').click();

      // 取消刪除
      cy.get('[data-testid="confirm-dialog"]').should('be.visible');
      cy.get('[data-testid="cancel-delete-button"]').click();

      // 檢查對話框關閉
      cy.get('[data-testid="confirm-dialog"]').should('not.exist');

      // 檢查使用者仍在列表中
      cy.get('[data-testid="user-row-1"]').should('exist');
    });
  });

  describe('批次操作', () => {
    it('應該支援批次刪除', () => {
      cy.intercept('POST', '**/api/users/batch', { statusCode: 200, body: { success: true } }).as('batchDelete');

      cy.visit('/users');
      cy.wait('@getUsers');

      // 選擇多個使用者
      cy.get('[data-testid="select-user-1"]').check();
      cy.get('[data-testid="select-user-2"]').check();

      // 檢查批次操作按鈕出現
      cy.get('[data-testid="batch-actions"]').should('be.visible');
      cy.get('[data-testid="selected-count"]').should('contain.text', '已選擇 2 位使用者');

      // 執行批次刪除
      cy.get('[data-testid="batch-delete-button"]').click();
      cy.get('[data-testid="confirm-batch-delete-button"]').click();

      cy.wait('@batchDelete');

      // 檢查成功訊息
      cy.checkNotification('success', '批次刪除成功');
    });
  });

  describe('響應式設計', () => {
    it('應該在手機裝置上正常運作', () => {
      cy.viewport('iphone-x');
      cy.visit('/users');
      cy.wait('@getUsers');

      // 檢查手機版佈局
      cy.get('[data-testid="mobile-menu-button"]').should('be.visible');
      cy.get('[data-testid="desktop-sidebar"]').should('not.be.visible');

      // 檢查表格在手機版的顯示
      cy.get('[data-testid="users-table"]').should('be.visible');
      cy.get('[data-testid="mobile-user-card"]').should('exist');
    });

    it('應該在平板裝置上正常運作', () => {
      cy.viewport('ipad-2');
      cy.visit('/users');
      cy.wait('@getUsers');

      // 檢查平板版佈局
      cy.get('[data-testid="tablet-layout"]').should('be.visible');
      cy.get('[data-testid="users-table"]').should('be.visible');
    });
  });
});

9.5 測試最佳實務

測試覆蓋率設定

// vitest.config.ts(擴展覆蓋率設定)
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      reportsDirectory: './coverage',
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.{js,ts}',
        '**/index.ts',
        'src/main.ts',
        'src/assets/',
        'src/public/',
        '**/*.stories.{js,ts,vue}',
        '**/*.cy.{js,ts}',
      ],
      include: ['src/**/*.{js,ts,vue}'],
      thresholds: {
        global: {
          branches: 80,
          functions: 80,
          lines: 80,
          statements: 80,
        },
        // 特定檔案的覆蓋率要求
        'src/stores/': {
          branches: 90,
          functions: 90,
          lines: 90,
          statements: 90,
        },
        'src/composables/': {
          branches: 85,
          functions: 85,
          lines: 85,
          statements: 85,
        },
      },
    },
  },
});

測試資料管理

// src/test/fixtures/index.ts
export const mockUsers = [
  {
    id: 1,
    name: 'John Doe',
    email: 'john@example.com',
    role: 'admin',
    is_active: true,
    created_at: '2024-01-01T00:00:00Z',
    updated_at: '2024-01-01T00:00:00Z',
  },
  {
    id: 2,
    name: 'Jane Smith',
    email: 'jane@example.com',
    role: 'user',
    is_active: true,
    created_at: '2024-01-02T00:00:00Z',
    updated_at: '2024-01-02T00:00:00Z',
  },
];

export const mockProducts = [
  {
    id: 1,
    name: '產品 A',
    category: '分類 1',
    price: 1000,
    stock: 50,
    is_active: true,
    created_at: '2024-01-01T00:00:00Z',
  },
  {
    id: 2,
    name: '產品 B',
    category: '分類 2',
    price: 2000,
    stock: 30,
    is_active: true,
    created_at: '2024-01-02T00:00:00Z',
  },
];

export const createMockUser = (overrides: Partial<typeof mockUsers[0]> = {}) => ({
  ...mockUsers[0],
  ...overrides,
});

export const createMockProduct = (overrides: Partial<typeof mockProducts[0]> = {}) => ({
  ...mockProducts[0],
  ...overrides,
});

測試腳本設定

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:watch": "vitest --watch",
    "test:e2e": "cypress run",
    "test:e2e:open": "cypress open",
    "test:e2e:ci": "start-server-and-test dev http://localhost:5173 test:e2e",
    "test:all": "npm run test:run && npm run test:e2e:ci",
    "test:lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
    "test:type": "vue-tsc --noEmit"
  }
}

10. 安全規範

10.1 認證與授權

JWT Token 處理

// src/utils/auth.ts
import { jwtDecode } from 'jwt-decode';

export interface TokenPayload {
  sub: string;
  iat: number;
  exp: number;
  role: string;
  permissions: string[];
}

export class TokenManager {
  private static readonly TOKEN_KEY = 'auth_token';
  private static readonly REFRESH_TOKEN_KEY = 'refresh_token';
  private static readonly TOKEN_EXPIRY_BUFFER = 300; // 5 分鐘緩衝時間

  /**
   * 儲存 Token
   */
  static setToken(token: string): void {
    // 只在 HTTPS 環境下或開發模式儲存
    if (this.isSecureContext()) {
      localStorage.setItem(this.TOKEN_KEY, token);
    } else {
      console.warn('Token 只能在安全環境下儲存');
    }
  }

  /**
   * 取得 Token
   */
  static getToken(): string | null {
    return localStorage.getItem(this.TOKEN_KEY);
  }

  /**
   * 移除 Token
   */
  static removeToken(): void {
    localStorage.removeItem(this.TOKEN_KEY);
    localStorage.removeItem(this.REFRESH_TOKEN_KEY);
  }

  /**
   * 驗證 Token 格式
   */
  static validateTokenFormat(token: string): boolean {
    try {
      const parts = token.split('.');
      if (parts.length !== 3) {
        return false;
      }

      // 驗證每個部分都是有效的 Base64
      parts.forEach(part => {
        atob(part.replace(/-/g, '+').replace(/_/g, '/'));
      });

      return true;
    } catch {
      return false;
    }
  }

  /**
   * 解碼 Token
   */
  static decodeToken(token: string): TokenPayload | null {
    try {
      if (!this.validateTokenFormat(token)) {
        return null;
      }
      return jwtDecode<TokenPayload>(token);
    } catch (error) {
      console.error('Token 解碼失敗:', error);
      return null;
    }
  }

  /**
   * 檢查 Token 是否過期
   */
  static isTokenExpired(token: string): boolean {
    const payload = this.decodeToken(token);
    if (!payload) {
      return true;
    }

    const currentTime = Math.floor(Date.now() / 1000);
    return payload.exp <= currentTime;
  }

  /**
   * 檢查 Token 是否即將過期(需要刷新)
   */
  static shouldRefreshToken(token: string): boolean {
    const payload = this.decodeToken(token);
    if (!payload) {
      return false;
    }

    const currentTime = Math.floor(Date.now() / 1000);
    return payload.exp <= currentTime + this.TOKEN_EXPIRY_BUFFER;
  }

  /**
   * 檢查是否為安全環境
   */
  private static isSecureContext(): boolean {
    return (
      window.location.protocol === 'https:' ||
      window.location.hostname === 'localhost' ||
      window.location.hostname === '127.0.0.1' ||
      import.meta.env.DEV
    );
  }

  /**
   * 取得 Token 中的使用者權限
   */
  static getPermissions(token: string): string[] {
    const payload = this.decodeToken(token);
    return payload?.permissions || [];
  }

  /**
   * 檢查使用者是否有特定權限
   */
  static hasPermission(token: string, permission: string): boolean {
    const permissions = this.getPermissions(token);
    return permissions.includes('*') || permissions.includes(permission);
  }
}

路由守衛

// src/router/guards.ts
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { TokenManager } from '@/utils/auth';

/**
 * 認證守衛
 */
export const authGuard = (
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
): void => {
  const authStore = useAuthStore();

  // 檢查是否需要認證
  if (to.meta.requiresAuth === false) {
    next();
    return;
  }

  // 檢查是否已登入
  if (!authStore.isAuthenticated) {
    next({
      path: '/login',
      query: { redirect: to.fullPath },
    });
    return;
  }

  // 檢查 Token 是否有效
  const token = authStore.token;
  if (!token || TokenManager.isTokenExpired(token)) {
    authStore.logout();
    next({
      path: '/login',
      query: { redirect: to.fullPath, reason: 'expired' },
    });
    return;
  }

  next();
};

/**
 * 權限守衛
 */
export const permissionGuard = (
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
): void => {
  const authStore = useAuthStore();
  const requiredPermissions = to.meta.permissions as string[] | undefined;

  // 如果沒有設定權限要求,則允許通過
  if (!requiredPermissions || requiredPermissions.length === 0) {
    next();
    return;
  }

  const token = authStore.token;
  if (!token) {
    next({ path: '/403' });
    return;
  }

  // 檢查是否有所需權限
  const hasPermission = requiredPermissions.some(permission =>
    TokenManager.hasPermission(token, permission)
  );

  if (hasPermission) {
    next();
  } else {
    next({ path: '/403' });
  }
};

/**
 * 角色守衛
 */
export const roleGuard = (
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
): void => {
  const authStore = useAuthStore();
  const requiredRoles = to.meta.roles as string[] | undefined;

  if (!requiredRoles || requiredRoles.length === 0) {
    next();
    return;
  }

  const userRole = authStore.userRole;
  if (!userRole) {
    next({ path: '/403' });
    return;
  }

  if (requiredRoles.includes(userRole)) {
    next();
  } else {
    next({ path: '/403' });
  }
};

/**
 * 訪客守衛(只允許未登入用戶訪問)
 */
export const guestGuard = (
  to: RouteLocationNormalized,
  from: RouteLocationNormalized,
  next: NavigationGuardNext
): void => {
  const authStore = useAuthStore();

  if (authStore.isAuthenticated) {
    next({ path: '/dashboard' });
  } else {
    next();
  }
};

10.2 資料驗證與清理

輸入驗證

// src/utils/validation.ts
export class InputValidator {
  /**
   * HTML 標籤清理
   */
  static sanitizeHtml(input: string): string {
    const div = document.createElement('div');
    div.textContent = input;
    return div.innerHTML;
  }

  /**
   * SQL 注入防護
   */
  static sanitizeSql(input: string): string {
    const sqlKeywords = [
      'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER',
      'UNION', 'EXEC', 'EXECUTE', 'SCRIPT', 'DECLARE', '--', ';', '/*', '*/',
    ];

    let sanitized = input;
    sqlKeywords.forEach(keyword => {
      const regex = new RegExp(keyword, 'gi');
      sanitized = sanitized.replace(regex, '');
    });

    return sanitized.trim();
  }

  /**
   * XSS 防護
   */
  static sanitizeXss(input: string): string {
    const xssPatterns = [
      /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
      /<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi,
      /<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi,
      /<embed\b[^<]*>/gi,
      /<link\b[^<]*>/gi,
      /<meta\b[^<]*>/gi,
      /on\w+\s*=\s*"[^"]*"/gi,
      /on\w+\s*=\s*'[^']*'/gi,
      /javascript:/gi,
      /vbscript:/gi,
      /data:text\/html/gi,
    ];

    let sanitized = input;
    xssPatterns.forEach(pattern => {
      sanitized = sanitized.replace(pattern, '');
    });

    return sanitized;
  }

  /**
   * 檔案名稱驗證
   */
  static validateFileName(fileName: string): boolean {
    const invalidChars = /[<>:"/\\|?*]/;
    const reservedNames = [
      'CON', 'PRN', 'AUX', 'NUL',
      'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
      'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
    ];

    if (invalidChars.test(fileName)) {
      return false;
    }

    if (reservedNames.includes(fileName.toUpperCase())) {
      return false;
    }

    return fileName.length > 0 && fileName.length <= 255;
  }

  /**
   * URL 驗證
   */
  static validateUrl(url: string): boolean {
    try {
      const urlObj = new URL(url);
      return ['http:', 'https:'].includes(urlObj.protocol);
    } catch {
      return false;
    }
  }

  /**
   * 電子郵件驗證
   */
  static validateEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email) && email.length <= 254;
  }

  /**
   * 密碼強度驗證
   */
  static validatePasswordStrength(password: string): {
    isValid: boolean;
    score: number;
    feedback: string[];
  } {
    const feedback: string[] = [];
    let score = 0;

    // 長度檢查
    if (password.length < 8) {
      feedback.push('密碼長度至少需要 8 個字元');
    } else {
      score += 1;
    }

    // 包含大寫字母
    if (!/[A-Z]/.test(password)) {
      feedback.push('密碼需要包含至少一個大寫字母');
    } else {
      score += 1;
    }

    // 包含小寫字母
    if (!/[a-z]/.test(password)) {
      feedback.push('密碼需要包含至少一個小寫字母');
    } else {
      score += 1;
    }

    // 包含數字
    if (!/\d/.test(password)) {
      feedback.push('密碼需要包含至少一個數字');
    } else {
      score += 1;
    }

    // 包含特殊字元
    if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\?]/.test(password)) {
      feedback.push('密碼需要包含至少一個特殊字元');
    } else {
      score += 1;
    }

    // 檢查常見密碼
    const commonPasswords = [
      '123456', 'password', '123456789', '12345678', '12345',
      '1234567', '1234567890', 'qwerty', 'abc123', 'password123',
    ];

    if (commonPasswords.includes(password.toLowerCase())) {
      feedback.push('請避免使用常見密碼');
      score = Math.max(0, score - 2);
    }

    return {
      isValid: score >= 4 && feedback.length === 0,
      score,
      feedback,
    };
  }

  /**
   * 手機號碼驗證(台灣)
   */
  static validateTaiwanPhone(phone: string): boolean {
    const phoneRegex = /^09\d{8}$/;
    return phoneRegex.test(phone.replace(/[\s-]/g, ''));
  }

  /**
   * 身分證字號驗證(台灣)
   */
  static validateTaiwanId(id: string): boolean {
    const idRegex = /^[A-Z][12]\d{8}$/;
    if (!idRegex.test(id)) {
      return false;
    }

    const letterMap: Record<string, number> = {
      A: 10, B: 11, C: 12, D: 13, E: 14, F: 15, G: 16, H: 17, I: 34, J: 18,
      K: 19, L: 20, M: 21, N: 22, O: 35, P: 23, Q: 24, R: 25, S: 26, T: 27,
      U: 28, V: 29, W: 32, X: 30, Y: 31, Z: 33,
    };

    const letterValue = letterMap[id[0]];
    const digits = id.substring(1).split('').map(Number);

    const checksum = Math.floor(letterValue / 10) +
      (letterValue % 10) * 9 +
      digits[0] * 8 +
      digits[1] * 7 +
      digits[2] * 6 +
      digits[3] * 5 +
      digits[4] * 4 +
      digits[5] * 3 +
      digits[6] * 2 +
      digits[7] * 1 +
      digits[8];

    return checksum % 10 === 0;
  }
}

Content Security Policy

// src/utils/csp.ts
export class CSPManager {
  /**
   * 設定 Content Security Policy
   */
  static setupCSP(): void {
    // 動態設定 CSP (通常在伺服器端設定)
    const cspDirectives = [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net",
      "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
      "font-src 'self' https://fonts.gstatic.com",
      "img-src 'self' data: https:",
      "connect-src 'self' https://api.example.com",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ];

    const meta = document.createElement('meta');
    meta.httpEquiv = 'Content-Security-Policy';
    meta.content = cspDirectives.join('; ');
    document.head.appendChild(meta);
  }

  /**
   * 檢查是否違反 CSP
   */
  static monitorCSPViolations(): void {
    document.addEventListener('securitypolicyviolation', (event) => {
      console.error('CSP Violation:', {
        directive: event.violatedDirective,
        blockedURI: event.blockedURI,
        lineNumber: event.lineNumber,
        columnNumber: event.columnNumber,
        sourceFile: event.sourceFile,
      });

      // 發送到監控服務
      this.reportCSPViolation(event);
    });
  }

  /**
   * 報告 CSP 違規
   */
  private static reportCSPViolation(event: SecurityPolicyViolationEvent): void {
    fetch('/api/security/csp-report', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        directive: event.violatedDirective,
        blockedURI: event.blockedURI,
        lineNumber: event.lineNumber,
        columnNumber: event.columnNumber,
        sourceFile: event.sourceFile,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent,
      }),
    }).catch(console.error);
  }
}

10.3 檔案上傳安全

安全檔案上傳

// src/utils/fileUpload.ts
export interface FileUploadConfig {
  maxSize: number; // bytes
  allowedTypes: string[];
  allowedExtensions: string[];
  scanForMalware?: boolean;
}

export class SecureFileUpload {
  private static readonly DEFAULT_CONFIG: FileUploadConfig = {
    maxSize: 10 * 1024 * 1024, // 10MB
    allowedTypes: [
      'image/jpeg',
      'image/png',
      'image/gif',
      'image/webp',
      'application/pdf',
      'text/plain',
    ],
    allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.txt'],
    scanForMalware: true,
  };

  /**
   * 驗證檔案
   */
  static async validateFile(
    file: File,
    config: Partial<FileUploadConfig> = {}
  ): Promise<{ isValid: boolean; errors: string[] }> {
    const finalConfig = { ...this.DEFAULT_CONFIG, ...config };
    const errors: string[] = [];

    // 檢查檔案大小
    if (file.size > finalConfig.maxSize) {
      errors.push(`檔案大小不可超過 ${this.formatFileSize(finalConfig.maxSize)}`);
    }

    // 檢查檔案類型
    if (!finalConfig.allowedTypes.includes(file.type)) {
      errors.push(`不支援的檔案類型: ${file.type}`);
    }

    // 檢查檔案副檔名
    const extension = this.getFileExtension(file.name);
    if (!finalConfig.allowedExtensions.includes(extension)) {
      errors.push(`不支援的檔案副檔名: ${extension}`);
    }

    // 檢查檔案名稱
    if (!InputValidator.validateFileName(file.name)) {
      errors.push('檔案名稱包含無效字元');
    }

    // 檢查檔案內容(防止檔案偽造)
    const isValidContent = await this.validateFileContent(file);
    if (!isValidContent) {
      errors.push('檔案內容與副檔名不符');
    }

    // 掃描惡意軟體(如果啟用)
    if (finalConfig.scanForMalware) {
      const isSafe = await this.scanForMalware(file);
      if (!isSafe) {
        errors.push('檔案可能包含惡意內容');
      }
    }

    return {
      isValid: errors.length === 0,
      errors,
    };
  }

  /**
   * 安全上傳檔案
   */
  static async uploadFile(
    file: File,
    uploadUrl: string,
    config: Partial<FileUploadConfig> = {}
  ): Promise<{ success: boolean; url?: string; error?: string }> {
    try {
      // 驗證檔案
      const validation = await this.validateFile(file, config);
      if (!validation.isValid) {
        return {
          success: false,
          error: validation.errors.join(', '),
        };
      }

      // 重新命名檔案(避免路徑遍歷攻擊)
      const safeFileName = this.generateSafeFileName(file.name);

      // 建立 FormData
      const formData = new FormData();
      formData.append('file', file, safeFileName);
      formData.append('originalName', file.name);
      formData.append('size', file.size.toString());
      formData.append('type', file.type);

      // 上傳檔案
      const response = await fetch(uploadUrl, {
        method: 'POST',
        body: formData,
        headers: {
          'X-Requested-With': 'XMLHttpRequest',
          // 不設定 Content-Type,讓瀏覽器自動設定 multipart/form-data
        },
      });

      if (!response.ok) {
        throw new Error(`上傳失敗: ${response.status} ${response.statusText}`);
      }

      const result = await response.json();
      return {
        success: true,
        url: result.url,
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : '上傳失敗',
      };
    }
  }

  /**
   * 取得檔案副檔名
   */
  private static getFileExtension(fileName: string): string {
    const lastDotIndex = fileName.lastIndexOf('.');
    return lastDotIndex >= 0 ? fileName.substring(lastDotIndex).toLowerCase() : '';
  }

  /**
   * 格式化檔案大小
   */
  private static formatFileSize(bytes: number): string {
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    if (bytes === 0) return '0 Bytes';
    const i = Math.floor(Math.log(bytes) / Math.log(1024));
    return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
  }

  /**
   * 驗證檔案內容
   */
  private static async validateFileContent(file: File): Promise<boolean> {
    return new Promise((resolve) => {
      const reader = new FileReader();
      reader.onload = (e) => {
        const arrayBuffer = e.target?.result as ArrayBuffer;
        const bytes = new Uint8Array(arrayBuffer.slice(0, 8));

        // 檢查檔案簽名(魔術數字)
        const signatures: Record<string, number[][]> = {
          'image/jpeg': [[0xFF, 0xD8, 0xFF]],
          'image/png': [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]],
          'image/gif': [[0x47, 0x49, 0x46, 0x38], [0x47, 0x49, 0x46, 0x39]],
          'application/pdf': [[0x25, 0x50, 0x44, 0x46]],
        };

        const expectedSignatures = signatures[file.type];
        if (!expectedSignatures) {
          resolve(true); // 未知類型,允許通過
          return;
        }

        const isValid = expectedSignatures.some(signature =>
          signature.every((byte, index) => bytes[index] === byte)
        );

        resolve(isValid);
      };

      reader.onerror = () => resolve(false);
      reader.readAsArrayBuffer(file.slice(0, 8));
    });
  }

  /**
   * 掃描惡意軟體(模擬)
   */
  private static async scanForMalware(file: File): Promise<boolean> {
    // 這裡應該整合真實的惡意軟體掃描服務
    // 例如 VirusTotal API 或其他防毒引擎

    // 模擬掃描過程
    return new Promise((resolve) => {
      setTimeout(() => {
        // 簡單的啟發式檢查
        const suspiciousPatterns = [
          'eval(',
          'document.write(',
          '<script',
          'javascript:',
          'vbscript:',
        ];

        const reader = new FileReader();
        reader.onload = (e) => {
          const content = e.target?.result as string;
          const isSuspicious = suspiciousPatterns.some(pattern =>
            content.toLowerCase().includes(pattern)
          );
          resolve(!isSuspicious);
        };

        reader.onerror = () => resolve(false);
        reader.readAsText(file);
      }, 1000);
    });
  }

  /**
   * 產生安全的檔案名稱
   */
  private static generateSafeFileName(originalName: string): string {
    const extension = this.getFileExtension(originalName);
    const baseName = originalName.substring(0, originalName.lastIndexOf('.')) || originalName;

    // 移除危險字元
    const safeName = baseName
      .replace(/[^a-zA-Z0-9\-_]/g, '_')
      .substring(0, 50); // 限制長度

    // 加入時間戳避免衝突
    const timestamp = Date.now();
    const randomId = Math.random().toString(36).substring(2, 8);

    return `${safeName}_${timestamp}_${randomId}${extension}`;
  }
}

11. 版本控制與協作規範

11.1 Git 工作流程

分支策略

# 主要分支
main          # 生產環境分支
develop       # 開發環境分支

# 功能分支
feature/*     # 新功能開發
bugfix/*      # 錯誤修復
hotfix/*      # 緊急修復
release/*     # 發布準備

分支命名規範

# 功能開發
feature/user-management
feature/payment-integration
feature/dashboard-redesign

# 錯誤修復
bugfix/login-validation
bugfix/table-pagination

# 緊急修復
hotfix/security-patch
hotfix/payment-gateway-fix

# 發布分支
release/v1.2.0
release/v2.0.0-beta

Commit 訊息規範

# 格式: <type>(<scope>): <subject>

# 類型 (type)
feat      # 新功能
fix       # 錯誤修復
docs      # 文件更新
style     # 代碼格式化
refactor  # 重構
test      # 測試相關
chore     # 建置工具、輔助工具變動

# 範例
feat(auth): 新增使用者登入功能
fix(table): 修復分頁顯示錯誤
docs(api): 更新 API 文件
style(button): 統一按鈕樣式
refactor(store): 重構使用者狀態管理
test(user): 新增使用者組件測試
chore(deps): 升級 Vue 版本到 3.4

11.2 程式碼審查

Pull Request 模板

## 變更描述
<!-- 簡述這次變更的內容 -->

## 變更類型
- [ ] 新功能 (feature)
- [ ] 錯誤修復 (bugfix)
- [ ] 重構 (refactor)
- [ ] 文件更新 (docs)
- [ ] 樣式調整 (style)
- [ ] 測試相關 (test)
- [ ] 建置相關 (chore)

## 測試
- [ ] 單元測試已通過
- [ ] 整合測試已通過
- [ ] E2E 測試已通過
- [ ] 手動測試已完成

## 檢查清單
- [ ] 程式碼符合專案風格指引
- [ ] 已新增/更新相關測試
- [ ] 已新增/更新相關文件
- [ ] 無 console.log 或 debugger
- [ ] 已處理 TypeScript 型別錯誤
- [ ] 已檢查安全性問題

## 截圖/影片
<!-- 如有 UI 變更,請提供截圖或影片 -->

## 相關 Issue
<!-- 關聯的 Issue 編號 -->
Closes #

## 額外資訊
<!-- 其他需要說明的資訊 -->

Code Review 檢查點

// .github/pull_request_template.md
export const CODE_REVIEW_CHECKLIST = {
  功能性: [
    '功能是否按需求正確實作',
    '邊界條件是否妥善處理',
    '錯誤處理是否完整',
    '效能是否符合要求',
  ],
  程式碼品質: [
    '程式碼是否易讀易懂',
    '變數和函式命名是否清楚',
    '是否有重複程式碼',
    '是否遵循 SOLID 原則',
  ],
  安全性: [
    '是否有安全漏洞',
    '輸入驗證是否完整',
    '敏感資料是否妥善處理',
    '權限檢查是否正確',
  ],
  測試: [
    '測試覆蓋率是否足夠',
    '測試案例是否合理',
    '測試是否能正確執行',
    '是否包含邊界條件測試',
  ],
  文件: [
    'JSDoc 是否完整',
    'README 是否需要更新',
    '是否有使用說明',
    'CHANGELOG 是否更新',
  ],
};

11.3 自動化工作流程

GitHub Actions 設定

# .github/workflows/ci.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linting
        run: npm run lint

      - name: Run type checking
        run: npm run type-check

      - name: Run unit tests
        run: npm run test:coverage

      - name: Upload coverage reports
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info

      - name: Run E2E tests
        run: npm run test:e2e:ci

  security:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Run security audit
        run: npm audit --audit-level high

      - name: Run dependency check
        uses: ossf/scorecard-action@v2
        with:
          results_file: results.sarif
          results_format: sarif

  build:
    runs-on: ubuntu-latest
    needs: [test, security]
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build application
        run: npm run build

      - name: Deploy to staging
        if: github.ref == 'refs/heads/develop'
        run: |
          echo "Deploy to staging environment"
          # 部署到測試環境的指令

      - name: Deploy to production
        if: github.ref == 'refs/heads/main'
        run: |
          echo "Deploy to production environment"
          # 部署到生產環境的指令

12. 部署與維運規範

12.1 建置設定

Vite 生產建置設定

// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
    },
  },
  build: {
    target: 'es2015',
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: false, // 生產環境建議關閉
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true, // 移除 console.log
        drop_debugger: true, // 移除 debugger
      },
    },
    rollupOptions: {
      output: {
        manualChunks: {
          vue: ['vue', 'vue-router', 'pinia'],
          ui: ['@headlessui/vue', '@heroicons/vue'],
          utils: ['axios', 'dayjs', 'lodash-es'],
        },
      },
    },
    chunkSizeWarningLimit: 1000,
  },
  server: {
    port: 5173,
    host: true,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        secure: false,
      },
    },
  },
  preview: {
    port: 4173,
    host: true,
  },
});

Docker 設定

# Dockerfile
# 建置階段
FROM node:20-alpine AS builder

WORKDIR /app

# 複製 package files
COPY package*.json ./
RUN npm ci --only=production

# 複製原始碼
COPY . .

# 建置應用程式
RUN npm run build

# 生產階段
FROM nginx:alpine

# 複製自定義 nginx 設定
COPY nginx.conf /etc/nginx/nginx.conf

# 複製建置結果
COPY --from=builder /app/dist /usr/share/nginx/html

# 暴露連接埠
EXPOSE 80

# 啟動 nginx
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # 日誌格式
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;

    # 基本設定
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    # Gzip 壓縮
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/json
        application/javascript
        application/xml+rss
        application/atom+xml
        image/svg+xml;

    # 安全標頭
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy "strict-origin-when-cross-origin";

    server {
        listen 80;
        server_name _;
        root /usr/share/nginx/html;
        index index.html;

        # SPA 路由支援
        location / {
            try_files $uri $uri/ /index.html;
        }

        # API 代理
        location /api/ {
            proxy_pass http://backend:8080/api/;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        # 靜態資源快取
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # 安全設定
        location ~ /\. {
            deny all;
        }
    }
}

12.2 環境變數管理

環境設定檔案

# .env.development
VITE_APP_TITLE=金融管理系統 (開發)
VITE_API_BASE_URL=http://localhost:8080/api
VITE_APP_VERSION=1.0.0-dev
VITE_LOG_LEVEL=debug
VITE_ENABLE_MOCK=true

# .env.staging
VITE_APP_TITLE=金融管理系統 (測試)
VITE_API_BASE_URL=https://api-staging.example.com/api
VITE_APP_VERSION=1.0.0-staging
VITE_LOG_LEVEL=info
VITE_ENABLE_MOCK=false

# .env.production
VITE_APP_TITLE=金融管理系統
VITE_API_BASE_URL=https://api.example.com/api
VITE_APP_VERSION=1.0.0
VITE_LOG_LEVEL=error
VITE_ENABLE_MOCK=false

環境設定管理

// src/config/env.ts
export interface AppConfig {
  app: {
    title: string;
    version: string;
    environment: string;
  };
  api: {
    baseUrl: string;
    timeout: number;
  };
  logging: {
    level: 'debug' | 'info' | 'warn' | 'error';
    enableConsole: boolean;
  };
  features: {
    enableMock: boolean;
    enableAnalytics: boolean;
    enableDebugTools: boolean;
  };
}

const createConfig = (): AppConfig => {
  const env = import.meta.env;

  return {
    app: {
      title: env.VITE_APP_TITLE || '金融管理系統',
      version: env.VITE_APP_VERSION || '1.0.0',
      environment: env.MODE || 'development',
    },
    api: {
      baseUrl: env.VITE_API_BASE_URL || '/api',
      timeout: Number(env.VITE_API_TIMEOUT) || 30000,
    },
    logging: {
      level: (env.VITE_LOG_LEVEL as AppConfig['logging']['level']) || 'info',
      enableConsole: env.MODE === 'development',
    },
    features: {
      enableMock: env.VITE_ENABLE_MOCK === 'true',
      enableAnalytics: env.VITE_ENABLE_ANALYTICS === 'true',
      enableDebugTools: env.MODE === 'development',
    },
  };
};

export const config = createConfig();

// 驗證必要的環境變數
const validateConfig = (): void => {
  const requiredVars = ['VITE_API_BASE_URL'];
  
  for (const varName of requiredVars) {
    if (!import.meta.env[varName]) {
      throw new Error(`Missing required environment variable: ${varName}`);
    }
  }
};

validateConfig();

12.3 監控與日誌

前端監控設定

// src/utils/monitoring.ts
interface ErrorInfo {
  message: string;
  stack?: string;
  url: string;
  line?: number;
  column?: number;
  timestamp: string;
  userAgent: string;
  userId?: string;
  sessionId: string;
}

class MonitoringService {
  private sessionId: string;
  private userId?: string;

  constructor() {
    this.sessionId = this.generateSessionId();
    this.setupErrorHandling();
    this.setupPerformanceMonitoring();
  }

  private generateSessionId(): string {
    return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
  }

  private setupErrorHandling(): void {
    // 全域錯誤處理
    window.addEventListener('error', (event) => {
      this.reportError({
        message: event.message,
        stack: event.error?.stack,
        url: event.filename,
        line: event.lineno,
        column: event.colno,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent,
        userId: this.userId,
        sessionId: this.sessionId,
      });
    });

    // Promise 錯誤處理
    window.addEventListener('unhandledrejection', (event) => {
      this.reportError({
        message: `Unhandled Promise Rejection: ${event.reason}`,
        stack: event.reason?.stack,
        url: window.location.href,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent,
        userId: this.userId,
        sessionId: this.sessionId,
      });
    });
  }

  private setupPerformanceMonitoring(): void {
    // 頁面載入效能
    window.addEventListener('load', () => {
      setTimeout(() => {
        const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
        this.reportPerformance({
          type: 'page_load',
          loadTime: perfData.loadEventEnd - perfData.fetchStart,
          domReady: perfData.domContentLoadedEventEnd - perfData.fetchStart,
          firstByte: perfData.responseStart - perfData.fetchStart,
          url: window.location.href,
          timestamp: new Date().toISOString(),
        });
      }, 1000);
    });

    // Core Web Vitals
    this.observeCoreWebVitals();
  }

  private observeCoreWebVitals(): void {
    // 這裡可以使用 web-vitals 庫
    // import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
  }

  reportError(error: ErrorInfo): void {
    if (config.logging.level === 'error' || config.logging.enableConsole) {
      console.error('Error reported:', error);
    }

    // 發送到監控服務
    fetch('/api/monitoring/errors', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(error),
    }).catch(console.error);
  }

  reportPerformance(data: any): void {
    fetch('/api/monitoring/performance', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    }).catch(console.error);
  }

  setUserId(userId: string): void {
    this.userId = userId;
  }
}

export const monitoring = new MonitoringService();

13. 程式碼審查規範

13.1 審查流程

審查前檢查清單

// 開發者自檢清單
export const DEVELOPER_CHECKLIST = {
  功能完整性: [
    '功能按照需求文件實作完成',
    '所有邊界條件都有處理',
    '錯誤處理機制完整',
    '使用者體驗流暢',
  ],
  程式碼品質: [
    '遵循專案編碼規範',
    '無 TypeScript 編譯錯誤',
    '無 ESLint 警告或錯誤',
    '程式碼可讀性良好',
  ],
  測試完整性: [
    '單元測試覆蓋率達標',
    '整合測試通過',
    'E2E 測試場景覆蓋',
    '手動測試完成',
  ],
  安全性檢查: [
    '輸入驗證完整',
    '無安全漏洞',
    '敏感資料處理得當',
    '權限控制正確',
  ],
  效能優化: [
    '載入速度最佳化',
    '記憶體使用合理',
    '無效能瓶頸',
    '響應式設計適配',
  ],
};

審查者檢查重點

// 程式碼審查重點
export const REVIEW_GUIDELINES = {
  架構設計: {
    組件拆分: '組件職責單一,可重用性高',
    狀態管理: 'Store 設計合理,資料流清晰',
    路由設計: '路由結構清楚,權限控制完整',
    API設計: 'API 呼叫邏輯合理,錯誤處理完善',
  },
  
  程式碼風格: {
    命名規範: '變數、函式、類別命名清楚有意義',
    註解文件: 'JSDoc 註解完整,複雜邏輯有說明',
    程式結構: '程式碼結構清晰,易於理解',
    重複代碼: '避免重複程式碼,適當抽象化',
  },
  
  效能考量: {
    渲染效能: '避免不必要的重新渲染',
    記憶體管理: '適當清理事件監聽器和定時器',
    資源載入: '圖片和資源載入最佳化',
    程式碼分割: '適當的程式碼分割和懶載入',
  },
  
  使用者體驗: {
    互動回饋: '載入狀態和錯誤提示完整',
    響應式設計: '各螢幕尺寸適配良好',
    無障礙支援: '鍵盤操作和螢幕閱讀器支援',
    國際化: '多語系支援完整',
  },
};

13.2 常見問題與解決方案

效能問題

// ❌ 問題:在 computed 中進行複雜計算
const computedValue = computed(() => {
  return expensiveCalculation(props.data); // 每次重新計算
});

// ✅ 解決:使用 memo 優化
const computedValue = computed(() => {
  return useMemo(() => expensiveCalculation(props.data), [props.data]);
});

// ❌ 問題:不必要的響應式物件
const state = reactive({
  largeArray: [], // 大型陣列會影響效能
  config: {},
});

// ✅ 解決:只對需要響應式的資料使用 reactive
const largeArray = ref([]); // 只有陣列本身響應式
const state = reactive({
  config: {},
});

型別安全問題

// ❌ 問題:使用 any 類型
const processData = (data: any) => {
  return data.someProperty; // 沒有型別檢查
};

// ✅ 解決:定義具體型別
interface DataType {
  someProperty: string;
  otherProperty?: number;
}

const processData = (data: DataType): string => {
  return data.someProperty; // 有型別檢查
};

// ❌ 問題:忽略 null/undefined 檢查
const getUserName = (user: User) => {
  return user.profile.name; // 可能出錯
};

// ✅ 解決:安全的屬性存取
const getUserName = (user: User): string => {
  return user.profile?.name ?? '未設定';
};

記憶體洩漏問題

// ❌ 問題:未清理事件監聽器
onMounted(() => {
  window.addEventListener('resize', handleResize);
});

// ✅ 解決:在 unmounted 時清理
onMounted(() => {
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
});

// ❌ 問題:未清理定時器
const timer = setInterval(() => {
  // 一些操作
}, 1000);

// ✅ 解決:清理定時器
const timer = setInterval(() => {
  // 一些操作
}, 1000);

onUnmounted(() => {
  clearInterval(timer);
});

14. 除錯與效能優化規範

14.1 除錯工具與技巧

Vue DevTools 使用

// 開發環境除錯設定
if (import.meta.env.DEV) {
  // 啟用 Vue DevTools
  window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = window.__VUE_DEVTOOLS_GLOBAL_HOOK__ || {};
  
  // 效能追蹤
  app.config.performance = true;
}

瀏覽器除錯技巧

// 除錯工具函式
export const debugUtils = {
  // 組件狀態快照
  takeSnapshot: (componentRef: any) => {
    if (import.meta.env.DEV) {
      console.log('Component Snapshot:', {
        props: componentRef.props,
        data: componentRef.data,
        computed: componentRef.computed,
      });
    }
  },

  // 效能測量
  measurePerformance: (label: string, fn: () => void) => {
    if (import.meta.env.DEV) {
      console.time(label);
      fn();
      console.timeEnd(label);
    } else {
      fn();
    }
  },

  // 記憶體使用監控
  logMemoryUsage: () => {
    if (import.meta.env.DEV && 'memory' in performance) {
      const memory = (performance as any).memory;
      console.log('Memory Usage:', {
        used: `${Math.round(memory.usedJSHeapSize / 1048576)} MB`,
        total: `${Math.round(memory.totalJSHeapSize / 1048576)} MB`,
        limit: `${Math.round(memory.jsHeapSizeLimit / 1048576)} MB`,
      });
    }
  },
};

14.2 效能優化策略

載入效能優化

// 路由懶載入
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
  },
  {
    path: '/users',
    component: () => import('@/views/Users/UserList.vue'),
  },
];

// 組件懶載入
export default defineAsyncComponent({
  loader: () => import('./HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,
  timeout: 3000,
});

渲染效能優化

<!-- 使用 v-memo 優化列表渲染 -->
<template>
  <div v-for="item in list" :key="item.id" v-memo="[item.id, item.name]">
    <UserCard :user="item" />
  </div>
</template>

<!-- 使用 KeepAlive 快取組件 -->
<template>
  <KeepAlive :max="10">
    <component :is="currentComponent" />
  </KeepAlive>
</template>

資源優化

// 圖片懶載入
export const useImageLazyLoad = () => {
  const imageRef = ref<HTMLImageElement>();
  
  onMounted(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting && imageRef.value) {
          const img = imageRef.value;
          img.src = img.dataset.src!;
          observer.unobserve(img);
        }
      });
    });
    
    if (imageRef.value) {
      observer.observe(imageRef.value);
    }
  });
  
  return { imageRef };
};

總結

本前端開發指引涵蓋了 Vue 3.x + TypeScript + Tailwind CSS 技術棧的完整開發流程,包括:

核心內容

  1. 專案結構 - 模組化的目錄組織和檔案命名規範
  2. 命名規範 - 統一的命名慣例,提升程式碼可讀性
  3. 程式碼風格 - ESLint + Prettier 自動化程式碼品質控制
  4. 組件開發 - Vue 3 SFC 組件設計模式和最佳實務
  5. 樣式指引 - Tailwind CSS 使用規範和響應式設計
  6. API 整合 - Axios 設定、錯誤處理和資料管理
  7. 狀態管理 - Pinia Store 架構設計和模組化管理
  8. 多語系 - Vue I18n 完整國際化解決方案

品質保證

  1. 測試規範 - 單元測試、整合測試、E2E 測試完整覆蓋
  2. 安全規範 - 前端安全最佳實務和防護措施
  3. 效能優化 - 程式碼分割、懶載入和快取策略
  4. 無障礙設計 - WCAG 標準遵循和包容性設計原則

開發流程

  1. 版本控制 - Git 工作流程和程式碼審查規範
  2. 建置部署 - 自動化建置、測試和部署管道
  3. 程式碼審查 - 審查流程和品質檢查標準
  4. 除錯優化 - 效能監控、錯誤追蹤和除錯技巧

團隊協作

  1. 開發工具 - 統一的開發環境和工具鏈設定
  2. 團隊溝通 - 協作流程、文件撰寫和知識分享

應用建議

  • 專案啟動時參考 專案結構命名規範
  • 開發過程中遵循 程式碼風格組件開發 指引
  • 確保 安全性無障礙性 要求
  • 持續進行 效能優化測試覆蓋
  • 團隊協作採用 版本控制程式碼審查 流程

本指引將隨著技術發展和專案需求持續更新,確保開發團隊始終採用最佳實務進行前端開發。


附錄

A. 常用 VSCode 擴充套件推薦

{
  "recommendations": [
    // Vue 相關
    "vue.volar",
    "vue.vscode-typescript-vue-plugin",
    
    // TypeScript
    "ms-typescript.vscode-typescript",
    
    // 程式碼品質
    "esbenp.prettier-vscode",
    "dbaeumer.vscode-eslint",
    "ms-vscode.vscode-eslint",
    
    // 樣式相關
    "bradlc.vscode-tailwindcss",
    "stylelint.vscode-stylelint",
    
    // Git
    "eamodio.gitlens",
    "mhutchie.git-graph",
    
    // 測試
    "ms-vscode.test-adapter-converter",
    "hbenl.vscode-test-explorer",
    
    // 開發工具
    "ms-vscode.vscode-json",
    "redhat.vscode-yaml",
    "ms-vscode.live-server",
    
    // 無障礙性
    "deque-systems.vscode-axe-linter",
    
    // 其他實用工具
    "christian-kohler.path-intellisense",
    "formulahendry.auto-rename-tag",
    "ms-vscode.bracket-pair-colorizer"
  ]
}

B. 效能優化檢查清單

🚀 載入效能

  • 使用程式碼分割 (Code Splitting)
  • 實作路由懶載入
  • 優化圖片格式和大小
  • 啟用 Gzip/Brotli 壓縮
  • 使用 CDN 加速靜態資源
  • 最小化 JavaScript 和 CSS 檔案
  • 移除未使用的程式碼

⚡ 執行效能

  • 避免不必要的重新渲染
  • 使用 v-memo 優化列表
  • 實作虛擬滾動 (大數據列表)
  • 使用 Web Workers 處理密集運算
  • 優化狀態管理結構
  • 減少 DOM 操作次數

🎯 使用者體驗

  • 實作載入指示器
  • 提供離線支援 (PWA)
  • 優化首屏載入時間
  • 實作預載入策略
  • 提供適當的錯誤處理
  • 支援鍵盤導航

C. 安全檢查清單

🔒 輸入安全

  • 所有使用者輸入都經過驗證
  • 實作 XSS 防護
  • 避免 innerHTML 直接賦值
  • 使用參數化查詢防止注入
  • 檔案上傳類型限制
  • 實作 CSRF 保護

🛡️ 資料保護

  • 敏感資料加密儲存
  • 使用 HTTPS 傳輸
  • 實作適當的認證機制
  • Token 過期處理
  • 記錄安全事件
  • 定期安全掃描

🔐 存取控制

  • 實作角色權限管理
  • 路由權限檢查
  • API 權限驗證
  • 會話管理安全
  • 最小權限原則
  • 定期權限審查

D. 無障礙檢查清單

♿ WCAG 2.1 AA 合規

  • 所有圖片都有適當的 alt 文字
  • 表單欄位都有標籤
  • 色彩對比度符合 4.5:1 要求
  • 支援鍵盤導航
  • 提供焦點指示器
  • 使用語意化 HTML 標籤

🎯 使用者體驗

  • 支援螢幕閱讀器
  • 提供跳過連結
  • 錯誤訊息清楚明確
  • 支援縮放至 200%
  • 動作不依賴顏色
  • 提供替代輸入方式

E. 疑難排解指南

常見編譯錯誤

  1. TypeScript 型別錯誤

    • 檢查型別定義檔案
    • 確認 import/export 語法
    • 更新 @types 套件
  2. Vue 組件渲染問題

    • 檢查響應式資料設定
    • 確認生命週期使用
    • 檢查 props 型別定義
  3. 樣式載入問題

    • 確認 CSS 檔案路徑
    • 檢查 Tailwind 設定
    • 驗證 scoped 樣式語法

效能問題診斷

  1. 載入緩慢

    • 使用 Lighthouse 分析
    • 檢查 bundle 大小
    • 分析網路請求
  2. 記憶體洩漏

    • 檢查事件監聽器清理
    • 確認定時器清除
    • 監控組件銷毀
  3. 渲染效能差

    • 使用 Vue DevTools 分析
    • 檢查不必要的重渲染
    • 優化計算屬性

文件變更記錄

版本日期變更內容作者
1.0.02025-08-11初始版本建立開發團隊
1.1.02025-08-29新增安全性、效能優化、無障礙設計規範開發團隊
1.1.12025-08-29補充版本控制、工具設定、團隊協作規範開發團隊

版權聲明: 本文件為內部開發指引,僅供團隊成員參考使用。

如有任何疑問或建議,請與技術架構團隊聯繫。