前端開發指引
文件資訊
- 版本: 1.0.0
- 建立日期: 2025-08-11
- 適用專案: 大型金融級 Web 專案
- 技術棧: Vue 3.x + TypeScript + Tailwind CSS
目錄
- 專案目錄與檔案結構規範
- 命名規範
- 程式撰寫風格與 Lint 設定
- 元件開發規範
- 樣式與 Tailwind CSS 規範
- API 串接與資料存取規範
- 狀態管理規範
- 多語系處理規範
- 測試規範
- 安全性考量
- 效能優化規範
- 無障礙設計規範
- 版本控制與分支策略
- 專案建置與部署流程
- 程式碼審查規範
- 常見錯誤處理與 Debug 流程
- 開發工具與環境設定
- 團隊協作與溝通規範
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.ts2.2 變數與函式命名
JavaScript/TypeScript 變數
- 使用 camelCase
- 常數使用 UPPER_SNAKE_CASE
- 私有變數以
_開頭 - 布林值變數使用
is、has、can、should等前綴
// ✅ 正確範例
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前綴 - 取得資料使用
get、fetch前綴 - 設定資料使用
set、update前綴
// ✅ 正確範例
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;
}
}總結
本前端開發指引提供了完整的開發規範,涵蓋了專案結構、命名規範、程式撰寫風格、元件開發和樣式設計等核心面向。請開發團隊嚴格遵循這些規範,以確保程式碼品質和專案的可維護性。
重要提醒
- 保持一致性: 整個團隊都應遵循相同的規範
- 定期更新: 隨著技術發展和專案需求變化,適時更新規範
- 程式碼審查: 在 Pull Request 中確保所有程式碼都符合規範
- 工具輔助: 善用 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-betaCommit 訊息規範
# 格式: <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.411.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 技術棧的完整開發流程,包括:
核心內容
- 專案結構 - 模組化的目錄組織和檔案命名規範
- 命名規範 - 統一的命名慣例,提升程式碼可讀性
- 程式碼風格 - ESLint + Prettier 自動化程式碼品質控制
- 組件開發 - Vue 3 SFC 組件設計模式和最佳實務
- 樣式指引 - Tailwind CSS 使用規範和響應式設計
- API 整合 - Axios 設定、錯誤處理和資料管理
- 狀態管理 - Pinia Store 架構設計和模組化管理
- 多語系 - Vue I18n 完整國際化解決方案
品質保證
- 測試規範 - 單元測試、整合測試、E2E 測試完整覆蓋
- 安全規範 - 前端安全最佳實務和防護措施
- 效能優化 - 程式碼分割、懶載入和快取策略
- 無障礙設計 - WCAG 標準遵循和包容性設計原則
開發流程
- 版本控制 - Git 工作流程和程式碼審查規範
- 建置部署 - 自動化建置、測試和部署管道
- 程式碼審查 - 審查流程和品質檢查標準
- 除錯優化 - 效能監控、錯誤追蹤和除錯技巧
團隊協作
- 開發工具 - 統一的開發環境和工具鏈設定
- 團隊溝通 - 協作流程、文件撰寫和知識分享
應用建議
- 專案啟動時參考 專案結構 和 命名規範
- 開發過程中遵循 程式碼風格 和 組件開發 指引
- 確保 安全性 和 無障礙性 要求
- 持續進行 效能優化 和 測試覆蓋
- 團隊協作採用 版本控制 和 程式碼審查 流程
本指引將隨著技術發展和專案需求持續更新,確保開發團隊始終採用最佳實務進行前端開發。
附錄
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. 疑難排解指南
常見編譯錯誤
TypeScript 型別錯誤
- 檢查型別定義檔案
- 確認 import/export 語法
- 更新 @types 套件
Vue 組件渲染問題
- 檢查響應式資料設定
- 確認生命週期使用
- 檢查 props 型別定義
樣式載入問題
- 確認 CSS 檔案路徑
- 檢查 Tailwind 設定
- 驗證 scoped 樣式語法
效能問題診斷
載入緩慢
- 使用 Lighthouse 分析
- 檢查 bundle 大小
- 分析網路請求
記憶體洩漏
- 檢查事件監聽器清理
- 確認定時器清除
- 監控組件銷毀
渲染效能差
- 使用 Vue DevTools 分析
- 檢查不必要的重渲染
- 優化計算屬性
文件變更記錄
| 版本 | 日期 | 變更內容 | 作者 |
|---|---|---|---|
| 1.0.0 | 2025-08-11 | 初始版本建立 | 開發團隊 |
| 1.1.0 | 2025-08-29 | 新增安全性、效能優化、無障礙設計規範 | 開發團隊 |
| 1.1.1 | 2025-08-29 | 補充版本控制、工具設定、團隊協作規範 | 開發團隊 |
版權聲明: 本文件為內部開發指引,僅供團隊成員參考使用。
如有任何疑問或建議,請與技術架構團隊聯繫。