dramaling-app/sop/archive/20250910155305_vue-developm...

32 KiB
Raw Permalink Blame History

Vue.js 開發規範和最佳實踐

📋 總體指導原則

核心原則

  1. 可讀性優先: 代碼應該易於理解和維護
  2. 一致性: 整個專案使用統一的編碼風格
  3. 可測試性: 編寫易於測試的代碼
  4. 效能考量: 避免不必要的重複渲染和記憶體洩漏
  5. 類型安全: 充分利用 TypeScript 的類型檢查

技術選型標準

  • Vue 3 + Composition API: 優先使用 Composition API
  • TypeScript: 所有 .vue 檔案使用 <script setup lang="ts">
  • Single File Components: 採用 SFC 結構
  • 組件化設計: 遵循原子設計理念

🏗️ 組件開發規範

組件命名規範

組件檔案命名

# ✅ 正確:使用 PascalCase
UserProfile.vue
VocabularyCard.vue
DialogueInterface.vue
PaymentModal.vue

# ❌ 錯誤
userProfile.vue
vocabulary-card.vue
dialogueInterface.vue

組件註冊和使用

<!--  正確模板中使用 PascalCase -->
<template>
  <UserProfile :user="currentUser" />
  <VocabularyCard v-for="word in words" :key="word.id" :word="word" />
</template>

<!--  錯誤使用 kebab-case -->
<template>
  <user-profile :user="currentUser" />
</template>

組件結構標準

基本 SFC 結構

<template>
  <!-- 模板內容 -->
</template>

<script setup lang="ts">
// 1. 導入 (按順序Vue APIs → 第三方庫 → 內部模組)
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { VocabularyCard } from '@/components'

// 2. 類型定義
interface Props {
  title: string
  items?: string[]
}

interface Emits {
  update: [value: string]
  submit: [data: FormData]
}

// 3. Props 和 Emits 定義
const props = withDefaults(defineProps<Props>(), {
  items: () => []
})

const emit = defineEmits<Emits>()

// 4. 響應式數據
const isLoading = ref(false)
const selectedItem = ref<string | null>(null)

// 5. 計算屬性
const filteredItems = computed(() => {
  return props.items.filter(item => item.length > 0)
})

// 6. 方法
const handleSubmit = () => {
  emit('submit', new FormData())
}

// 7. 生命週期
onMounted(() => {
  console.log('Component mounted')
})

// 8. 暴露給父組件的方法 (如需要)
defineExpose({
  handleSubmit
})
</script>

<style scoped lang="scss">
// 樣式內容
</style>

複雜組件結構範例

<!-- VocabularyPracticeCard.vue -->
<template>
  <q-card class="vocabulary-practice-card" :class="cardClasses">
    <q-card-section class="card-header">
      <div class="word-display">
        <h3 class="word-text">{{ word.text }}</h3>
        <span class="pronunciation">{{ word.pronunciation }}</span>
      </div>
      
      <q-btn
        icon="volume_up"
        flat
        round
        @click="playAudio"
        :loading="audioLoading"
      />
    </q-card-section>
    
    <q-card-section class="card-content">
      <p class="definition">{{ word.definition }}</p>
      
      <div class="practice-options">
        <q-btn-group>
          <q-btn
            v-for="option in practiceOptions"
            :key="option.id"
            :label="option.text"
            :color="getOptionColor(option.id)"
            @click="selectOption(option.id)"
          />
        </q-btn-group>
      </div>
    </q-card-section>
    
    <q-card-actions align="right">
      <q-btn
        label="提交答案"
        color="primary"
        :disabled="!selectedOption"
        @click="submitAnswer"
      />
    </q-card-actions>
  </q-card>
</template>

<script setup lang="ts">
// 類型定義
interface VocabularyWord {
  id: string
  text: string
  pronunciation: string
  definition: string
  audio_url: string
}

interface PracticeOption {
  id: string
  text: string
  is_correct: boolean
}

interface Props {
  word: VocabularyWord
  options: PracticeOption[]
  disabled?: boolean
}

interface Emits {
  answer: [optionId: string, isCorrect: boolean]
  audio: [wordId: string]
}

// Props 和 Emits
const props = withDefaults(defineProps<Props>(), {
  disabled: false
})

const emit = defineEmits<Emits>()

// 組合式函數
const { playAudio: playWordAudio, isLoading: audioLoading } = useAudio()

// 響應式狀態
const selectedOption = ref<string | null>(null)

// 計算屬性
const cardClasses = computed(() => ({
  'card--disabled': props.disabled,
  'card--answered': selectedOption.value !== null
}))

const practiceOptions = computed(() => props.options)

// 方法
const playAudio = async () => {
  await playWordAudio(props.word.audio_url)
  emit('audio', props.word.id)
}

const selectOption = (optionId: string) => {
  if (props.disabled) return
  selectedOption.value = optionId
}

const getOptionColor = (optionId: string): string => {
  if (selectedOption.value === optionId) {
    return 'primary'
  }
  return 'grey'
}

const submitAnswer = () => {
  if (!selectedOption.value) return
  
  const selectedOpt = props.options.find(opt => opt.id === selectedOption.value)
  if (selectedOpt) {
    emit('answer', selectedOption.value, selectedOpt.is_correct)
  }
}

// 重置方法
const reset = () => {
  selectedOption.value = null
}

// 暴露方法給父組件
defineExpose({
  reset
})
</script>

<style scoped lang="scss">
.vocabulary-practice-card {
  max-width: 400px;
  margin: 0 auto;
  
  .card-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 16px;
    
    .word-display {
      flex: 1;
      
      .word-text {
        margin: 0 0 4px 0;
        font-size: 24px;
        font-weight: 600;
        color: var(--q-primary);
      }
      
      .pronunciation {
        color: var(--q-secondary);
        font-style: italic;
      }
    }
  }
  
  .card-content {
    .definition {
      margin-bottom: 16px;
      line-height: 1.5;
    }
    
    .practice-options {
      .q-btn-group {
        width: 100%;
        
        .q-btn {
          flex: 1;
        }
      }
    }
  }
  
  // 狀態樣式
  &.card--disabled {
    opacity: 0.6;
    pointer-events: none;
  }
  
  &.card--answered {
    border-left: 4px solid var(--q-positive);
  }
}

// 響應式設計
@media (max-width: 600px) {
  .vocabulary-practice-card {
    .card-header {
      .word-text {
        font-size: 20px;
      }
    }
    
    .practice-options {
      .q-btn-group {
        flex-direction: column;
        
        .q-btn {
          margin-bottom: 8px;
        }
      }
    }
  }
}
</style>

Props 設計規範

Props 類型定義

// ✅ 正確:明確的類型定義
interface Props {
  // 必需 props
  userId: string
  words: VocabularyWord[]
  
  // 可選 props (使用 ?)
  title?: string
  maxItems?: number
  isLoading?: boolean
  
  // 具有預設值的 props
  pageSize?: number
  sortOrder?: 'asc' | 'desc'
  
  // 複雜物件類型
  filters?: {
    difficulty: number
    category: string[]
  }
  
  // 函數類型 (建議使用 emit)
  onUpdate?: (data: UpdateData) => void
}

// 使用 withDefaults 設定預設值
const props = withDefaults(defineProps<Props>(), {
  title: '詞彙學習',
  maxItems: 10,
  isLoading: false,
  pageSize: 20,
  sortOrder: 'asc',
  filters: () => ({ difficulty: 1, category: [] })
})

Props 驗證

// 複雜驗證邏輯可以使用計算屬性
const isValidProps = computed(() => {
  return props.words.length > 0 && props.userId.length > 0
})

// 在組件內進行運行時檢查
watch([() => props.words, () => props.userId], () => {
  if (!isValidProps.value) {
    console.warn('Invalid props provided to VocabularyCard')
  }
}, { immediate: true })

Emits 設計規範

Emit 事件命名

// ✅ 正確:使用動詞命名,描述發生的動作
interface Emits {
  // 數據更新事件
  'update:modelValue': [value: string]
  'update:filters': [filters: FilterOptions]
  
  // 用戶互動事件
  submit: [formData: FormData]
  cancel: []
  select: [itemId: string]
  
  // 狀態變化事件
  load: []
  error: [error: Error]
  complete: [result: any]
}

// ❌ 錯誤:使用名詞或不清楚的命名
interface BadEmits {
  data: [any]           // 不清楚
  click: []             // 太通用
  thing: [something: any]  // 無意義
}

事件觸發範例

const emit = defineEmits<Emits>()

// 正確的事件觸發方式
const handleFormSubmit = async (formData: FormData) => {
  try {
    emit('load')
    const result = await submitForm(formData)
    emit('submit', formData)
    emit('complete', result)
  } catch (error) {
    emit('error', error as Error)
  }
}

🔧 Composables 開發規範

組合式函數命名和結構

基本 Composable 結構

// composables/useVocabulary.ts
import { ref, computed, type Ref } from 'vue'
import type { VocabularyWord, PracticeResult } from '@/types'

// 選項介面 (如果需要)
interface UseVocabularyOptions {
  autoLoad?: boolean
  cacheEnabled?: boolean
}

// 返回類型定義
interface UseVocabularyReturn {
  // 狀態
  words: Readonly<Ref<VocabularyWord[]>>
  currentWord: Readonly<Ref<VocabularyWord | null>>
  isLoading: Readonly<Ref<boolean>>
  error: Readonly<Ref<string | null>>
  
  // 計算屬性
  wordsCount: ComputedRef<number>
  hasWords: ComputedRef<boolean>
  
  // 方法
  loadWords: (level: number) => Promise<void>
  selectWord: (wordId: string) => void
  submitPractice: (result: PracticeResult) => Promise<void>
  reset: () => void
}

// 主要函數
export function useVocabulary(
  initialLevel: number = 1,
  options: UseVocabularyOptions = {}
): UseVocabularyReturn {
  // 預設選項
  const { autoLoad = true, cacheEnabled = true } = options
  
  // 響應式狀態
  const words = ref<VocabularyWord[]>([])
  const currentWord = ref<VocabularyWord | null>(null)
  const isLoading = ref(false)
  const error = ref<string | null>(null)
  
  // 計算屬性
  const wordsCount = computed(() => words.value.length)
  const hasWords = computed(() => words.value.length > 0)
  
  // 私有方法
  const clearError = () => {
    error.value = null
  }
  
  // 公開方法
  const loadWords = async (level: number) => {
    try {
      isLoading.value = true
      clearError()
      
      const response = await vocabularyApi.getWordsByLevel(level)
      words.value = response.data
      
      if (words.value.length > 0) {
        currentWord.value = words.value[0]
      }
    } catch (err) {
      error.value = '載入詞彙失敗'
      console.error('Failed to load vocabulary:', err)
    } finally {
      isLoading.value = false
    }
  }
  
  const selectWord = (wordId: string) => {
    const word = words.value.find(w => w.id === wordId)
    if (word) {
      currentWord.value = word
    }
  }
  
  const submitPractice = async (result: PracticeResult) => {
    try {
      isLoading.value = true
      await vocabularyApi.submitPracticeResult(result)
      
      // 更新本地狀態
      const wordIndex = words.value.findIndex(w => w.id === result.wordId)
      if (wordIndex !== -1) {
        words.value[wordIndex].practiceCount = (words.value[wordIndex].practiceCount || 0) + 1
      }
    } catch (err) {
      error.value = '提交練習結果失敗'
      throw err
    } finally {
      isLoading.value = false
    }
  }
  
  const reset = () => {
    words.value = []
    currentWord.value = null
    clearError()
  }
  
  // 自動載入 (如果啟用)
  if (autoLoad) {
    loadWords(initialLevel)
  }
  
  return {
    // 只讀狀態
    words: readonly(words),
    currentWord: readonly(currentWord),
    isLoading: readonly(isLoading),
    error: readonly(error),
    
    // 計算屬性
    wordsCount,
    hasWords,
    
    // 方法
    loadWords,
    selectWord,
    submitPractice,
    reset
  }
}

複雜 Composable 範例

// composables/useDialogue.ts
export function useDialogue(scenarioId: string) {
  // 多個狀態管理
  const messages = ref<DialogueMessage[]>([])
  const currentScene = ref<DialogueScene | null>(null)
  const userInput = ref('')
  const isRecording = ref(false)
  const analysisResult = ref<AnalysisResult | null>(null)
  
  // 計算屬性
  const canSubmit = computed(() => userInput.value.trim().length > 0)
  const messageCount = computed(() => messages.value.length)
  const lastMessage = computed(() => {
    return messages.value[messages.value.length - 1] || null
  })
  
  // 組合其他 composables
  const { record, stopRecording } = useSpeechRecognition()
  const { playAudio } = useAudio()
  const { analyzeResponse } = useAI()
  
  // 複雜業務邏輯
  const startDialogue = async () => {
    try {
      const scene = await dialogueApi.getScene(scenarioId)
      currentScene.value = scene
      
      // 添加系統開場白
      messages.value.push({
        id: generateId(),
        type: 'system',
        content: scene.opening,
        timestamp: Date.now()
      })
      
      // 播放開場音頻
      if (scene.opening_audio) {
        await playAudio(scene.opening_audio)
      }
    } catch (error) {
      console.error('Failed to start dialogue:', error)
      throw error
    }
  }
  
  const sendMessage = async (content: string) => {
    // 添加用戶消息
    const userMessage: DialogueMessage = {
      id: generateId(),
      type: 'user',
      content: content.trim(),
      timestamp: Date.now()
    }
    messages.value.push(userMessage)
    
    // 清空輸入
    userInput.value = ''
    
    try {
      // AI 分析和回應
      const analysis = await analyzeResponse(content, currentScene.value)
      analysisResult.value = analysis
      
      // 添加系統回應
      const systemMessage: DialogueMessage = {
        id: generateId(),
        type: 'system',
        content: analysis.response,
        timestamp: Date.now(),
        analysis: analysis
      }
      messages.value.push(systemMessage)
      
      // 播放回應音頻
      if (analysis.audio_url) {
        await playAudio(analysis.audio_url)
      }
      
    } catch (error) {
      // 錯誤處理
      const errorMessage: DialogueMessage = {
        id: generateId(),
        type: 'error',
        content: '抱歉,系統發生錯誤,請稍後再試。',
        timestamp: Date.now()
      }
      messages.value.push(errorMessage)
    }
  }
  
  // 語音輸入處理
  const startVoiceInput = async () => {
    try {
      isRecording.value = true
      const transcript = await record()
      userInput.value = transcript
    } finally {
      isRecording.value = false
    }
  }
  
  const stopVoiceInput = () => {
    stopRecording()
    isRecording.value = false
  }
  
  return {
    // 狀態
    messages: readonly(messages),
    currentScene: readonly(currentScene),
    userInput,
    isRecording: readonly(isRecording),
    analysisResult: readonly(analysisResult),
    
    // 計算屬性
    canSubmit,
    messageCount,
    lastMessage,
    
    // 方法
    startDialogue,
    sendMessage,
    startVoiceInput,
    stopVoiceInput
  }
}

Composable 使用規範

在組件中正確使用

<script setup lang="ts">
// ✅ 正確:在 setup 函數頂層調用
const { words, isLoading, loadWords } = useVocabulary(1, {
  autoLoad: false,
  cacheEnabled: true
})

// ✅ 正確:條件性使用 (但要確保順序一致)
const enableAdvanced = ref(false)
const advancedFeatures = enableAdvanced.value 
  ? useAdvancedVocabulary() 
  : null

// ❌ 錯誤:在條件語句或循環中調用
if (someCondition) {
  const { data } = useVocabulary() // 這會導致錯誤
}

// ❌ 錯誤:在異步函數中調用
onMounted(async () => {
  const { data } = useVocabulary() // 這會導致錯誤
})

🗃️ 狀態管理規範 (Pinia)

Store 設計原則

基本 Store 結構

// stores/vocabularyStore.ts
import { defineStore } from 'pinia'
import type { VocabularyWord, PracticeSession } from '@/types'

// State 介面
interface VocabularyState {
  words: VocabularyWord[]
  currentSession: PracticeSession | null
  userProgress: Map<string, number>
  settings: {
    dailyGoal: number
    difficulty: number
    enableAudio: boolean
  }
}

export const useVocabularyStore = defineStore('vocabulary', () => {
  // State
  const words = ref<VocabularyWord[]>([])
  const currentSession = ref<PracticeSession | null>(null)
  const userProgress = ref(new Map<string, number>())
  const settings = ref({
    dailyGoal: 20,
    difficulty: 1,
    enableAudio: true
  })
  
  // Getters (計算屬性)
  const learnedWordsCount = computed(() => {
    return Array.from(userProgress.value.values())
      .filter(progress => progress >= 80).length
  })
  
  const todaysProgress = computed(() => {
    const today = new Date().toDateString()
    return words.value.filter(word => 
      word.lastPracticed?.toDateString() === today
    ).length
  })
  
  const progressPercentage = computed(() => {
    return Math.round((todaysProgress.value / settings.value.dailyGoal) * 100)
  })
  
  // Actions
  const loadWords = async (level: number): Promise<void> => {
    try {
      const response = await vocabularyApi.getWordsByLevel(level)
      words.value = response.data
    } catch (error) {
      console.error('Failed to load words:', error)
      throw error
    }
  }
  
  const startPracticeSession = (wordIds: string[]): void => {
    currentSession.value = {
      id: generateId(),
      wordIds,
      startTime: Date.now(),
      answers: [],
      isCompleted: false
    }
  }
  
  const updateProgress = (wordId: string, score: number): void => {
    userProgress.value.set(wordId, score)
    
    // 更新單詞的練習記錄
    const wordIndex = words.value.findIndex(w => w.id === wordId)
    if (wordIndex !== -1) {
      words.value[wordIndex].lastPracticed = new Date()
    }
  }
  
  const updateSettings = (newSettings: Partial<typeof settings.value>): void => {
    settings.value = { ...settings.value, ...newSettings }
  }
  
  // 重置功能
  const resetProgress = (): void => {
    userProgress.value.clear()
    currentSession.value = null
  }
  
  return {
    // State (只讀)
    words: readonly(words),
    currentSession: readonly(currentSession),
    userProgress: readonly(userProgress),
    settings: readonly(settings),
    
    // Getters
    learnedWordsCount,
    todaysProgress,
    progressPercentage,
    
    // Actions
    loadWords,
    startPracticeSession,
    updateProgress,
    updateSettings,
    resetProgress
  }
})

Store 組合和模組化

// stores/learningStore.ts - 複合 Store
export const useLearningStore = defineStore('learning', () => {
  // 組合其他 stores
  const vocabularyStore = useVocabularyStore()
  const dialogueStore = useDialogueStore()
  const userStore = useUserStore()
  
  // 跨模組的計算屬性
  const overallProgress = computed(() => {
    const vocabProgress = vocabularyStore.progressPercentage
    const dialogueProgress = dialogueStore.progressPercentage
    return Math.round((vocabProgress + dialogueProgress) / 2)
  })
  
  // 跨模組的 actions
  const initializeUser = async (userId: string) => {
    await Promise.all([
      userStore.loadUser(userId),
      vocabularyStore.loadWords(userStore.currentLevel),
      dialogueStore.loadScenarios(userStore.currentLevel)
    ])
  }
  
  return {
    overallProgress,
    initializeUser
  }
})

Store 使用最佳實踐

組件中正確使用 Store

<script setup lang="ts">
// ✅ 正確:解構響應式狀態
const vocabularyStore = useVocabularyStore()
const { words, isLoading } = storeToRefs(vocabularyStore)
const { loadWords, updateProgress } = vocabularyStore

// ✅ 正確:直接使用 store 的計算屬性
const progressPercentage = computed(() => vocabularyStore.progressPercentage)

// ❌ 錯誤:直接解構會失去響應性
const { words, isLoading } = useVocabularyStore() // 失去響應性

// ❌ 錯誤:不需要用 storeToRefs 包裝方法
const { loadWords } = storeToRefs(useVocabularyStore()) // 方法不需要響應性
</script>

🎨 樣式開發規範

SCSS 組織結構

樣式檔案組織

// assets/styles/main.scss
// 1. 變數和混合器
@import 'variables';
@import 'mixins';

// 2. 基礎樣式
@import 'base/reset';
@import 'base/typography';
@import 'base/layout';

// 3. 組件樣式
@import 'components/buttons';
@import 'components/forms';
@import 'components/cards';

// 4. 工具類
@import 'utilities/spacing';
@import 'utilities/colors';

// 5. 響應式斷點
@import 'responsive';

變數命名規範

// assets/styles/_variables.scss

// 色彩系統
$color-primary: #1976d2;
$color-primary-light: lighten($color-primary, 20%);
$color-primary-dark: darken($color-primary, 20%);

$color-secondary: #26a69a;
$color-accent: #9c27b0;

$color-success: #21ba45;
$color-warning: #f2c037;
$color-error: #c10015;
$color-info: #31ccec;

// 灰階
$color-grey-1: #fafafa;
$color-grey-2: #f5f5f5;
$color-grey-3: #eeeeee;
$color-grey-4: #e0e0e0;
$color-grey-5: #bdbdbd;

// 字體
$font-family-primary: 'Inter', sans-serif;
$font-family-secondary: 'Roboto', sans-serif;

$font-size-xs: 0.75rem;    // 12px
$font-size-sm: 0.875rem;   // 14px
$font-size-base: 1rem;     // 16px
$font-size-lg: 1.125rem;   // 18px
$font-size-xl: 1.25rem;    // 20px

// 間距
$spacing-xs: 0.25rem;   // 4px
$spacing-sm: 0.5rem;    // 8px
$spacing-md: 1rem;      // 16px
$spacing-lg: 1.5rem;    // 24px
$spacing-xl: 2rem;      // 32px

// 響應式斷點
$breakpoint-xs: 0;
$breakpoint-sm: 600px;
$breakpoint-md: 1024px;
$breakpoint-lg: 1440px;
$breakpoint-xl: 1920px;

// Z-index 層級
$z-index-dropdown: 1000;
$z-index-modal: 1050;
$z-index-popover: 1060;
$z-index-tooltip: 1070;
$z-index-toast: 1080;

混合器和工具

// assets/styles/_mixins.scss

// 響應式混合器
@mixin respond-to($breakpoint) {
  @if $breakpoint == xs {
    @media (max-width: #{$breakpoint-sm - 1px}) { @content; }
  }
  @if $breakpoint == sm {
    @media (min-width: #{$breakpoint-sm}) and (max-width: #{$breakpoint-md - 1px}) { @content; }
  }
  @if $breakpoint == md {
    @media (min-width: #{$breakpoint-md}) and (max-width: #{$breakpoint-lg - 1px}) { @content; }
  }
  @if $breakpoint == lg {
    @media (min-width: #{$breakpoint-lg}) { @content; }
  }
}

// 文字省略
@mixin text-ellipsis($lines: 1) {
  @if $lines == 1 {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  } @else {
    display: -webkit-box;
    -webkit-line-clamp: $lines;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
}

// 載入動畫
@mixin loading-skeleton {
  background: linear-gradient(90deg, $color-grey-2 25%, $color-grey-1 50%, $color-grey-2 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

// 卡片陰影
@mixin card-shadow($level: 1) {
  @if $level == 1 {
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  }
  @if $level == 2 {
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.08);
  }
  @if $level == 3 {
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1);
  }
}

組件樣式規範

BEM 命名方法

// ✅ 正確:使用 BEM 命名
.vocabulary-card {
  @include card-shadow(1);
  
  &__header {
    padding: $spacing-md;
    border-bottom: 1px solid $color-grey-3;
    
    &--highlighted {
      background-color: $color-primary-light;
    }
  }
  
  &__content {
    padding: $spacing-md;
  }
  
  &__word {
    font-size: $font-size-lg;
    font-weight: 600;
    color: $color-primary;
    
    &--difficult {
      color: $color-warning;
    }
  }
  
  &--disabled {
    opacity: 0.5;
    pointer-events: none;
  }
}

// ❌ 錯誤:嵌套過深,命名不清楚
.vocabulary-card {
  .header {
    .title {
      .word {
        .text {
          color: blue; // 嵌套過深且使用硬編碼顏色
        }
      }
    }
  }
}

響應式設計實作

.vocabulary-practice {
  display: grid;
  grid-template-columns: 1fr;
  gap: $spacing-md;
  padding: $spacing-md;
  
  // 平板以上:雙欄佈局
  @include respond-to(sm) {
    grid-template-columns: 1fr 1fr;
    padding: $spacing-lg;
  }
  
  // 桌面:三欄佈局
  @include respond-to(md) {
    grid-template-columns: repeat(3, 1fr);
    gap: $spacing-lg;
    padding: $spacing-xl;
  }
  
  &__card {
    @include card-shadow(1);
    transition: transform 0.2s ease;
    
    &:hover {
      transform: translateY(-2px);
      @include card-shadow(2);
    }
    
    // 手機上禁用 hover 效果
    @include respond-to(xs) {
      &:hover {
        transform: none;
        box-shadow: none;
      }
    }
  }
}

🧪 測試規範

單元測試規範

組件測試範例

// tests/unit/components/VocabularyCard.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import VocabularyCard from '@/components/business/VocabularyCard.vue'
import type { VocabularyWord } from '@/types'

// 測試數據
const mockWord: VocabularyWord = {
  id: '1',
  text: 'hello',
  pronunciation: '/həˈloʊ/',
  definition: '你好,哈囉',
  audio_url: '/audio/hello.mp3',
  difficulty: 1
}

// Mock 外部依賴
const mockPlayAudio = vi.fn()
vi.mock('@/composables/useAudio', () => ({
  useAudio: () => ({
    playAudio: mockPlayAudio,
    isLoading: false
  })
}))

describe('VocabularyCard', () => {
  beforeEach(() => {
    // 重置 Pinia store
    setActivePinia(createPinia())
    
    // 重置 mocks
    vi.clearAllMocks()
  })
  
  it('renders word information correctly', () => {
    const wrapper = mount(VocabularyCard, {
      props: { word: mockWord }
    })
    
    // 測試渲染內容
    expect(wrapper.find('[data-test="word-text"]').text()).toBe('hello')
    expect(wrapper.find('[data-test="pronunciation"]').text()).toBe('/həˈloʊ/')
    expect(wrapper.find('[data-test="definition"]').text()).toBe('你好,哈囉')
  })
  
  it('plays audio when pronunciation button is clicked', async () => {
    const wrapper = mount(VocabularyCard, {
      props: { word: mockWord }
    })
    
    const playButton = wrapper.find('[data-test="play-audio"]')
    await playButton.trigger('click')
    
    expect(mockPlayAudio).toHaveBeenCalledWith('/audio/hello.mp3')
  })
  
  it('emits select event when card is clicked', async () => {
    const wrapper = mount(VocabularyCard, {
      props: { word: mockWord }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('select')).toBeTruthy()
    expect(wrapper.emitted('select')?.[0]).toEqual([mockWord.id])
  })
  
  it('applies correct CSS classes based on difficulty', () => {
    const easyWord = { ...mockWord, difficulty: 1 }
    const hardWord = { ...mockWord, difficulty: 3 }
    
    const easyWrapper = mount(VocabularyCard, { props: { word: easyWord } })
    const hardWrapper = mount(VocabularyCard, { props: { word: hardWord } })
    
    expect(easyWrapper.classes()).toContain('vocabulary-card--easy')
    expect(hardWrapper.classes()).toContain('vocabulary-card--hard')
  })
  
  it('handles disabled state correctly', () => {
    const wrapper = mount(VocabularyCard, {
      props: {
        word: mockWord,
        disabled: true
      }
    })
    
    expect(wrapper.classes()).toContain('vocabulary-card--disabled')
    expect(wrapper.find('[data-test="play-audio"]').attributes('disabled')).toBeDefined()
  })
})

Composable 測試範例

// tests/unit/composables/useVocabulary.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useVocabulary } from '@/composables/useVocabulary'
import { flushPromises } from '@vue/test-utils'

// Mock API
const mockVocabularyApi = {
  getWordsByLevel: vi.fn(),
  submitPracticeResult: vi.fn()
}

vi.mock('@/services/vocabularyApi', () => ({
  vocabularyApi: mockVocabularyApi
}))

describe('useVocabulary', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })
  
  it('loads words on initialization when autoLoad is true', async () => {
    mockVocabularyApi.getWordsByLevel.mockResolvedValue({
      data: [mockWord]
    })
    
    const { words, isLoading, loadWords } = useVocabulary(1, { autoLoad: true })
    
    // 初始狀態檢查
    expect(isLoading.value).toBe(true)
    
    await flushPromises()
    
    // 載入完成後狀態檢查
    expect(isLoading.value).toBe(false)
    expect(words.value).toEqual([mockWord])
    expect(mockVocabularyApi.getWordsByLevel).toHaveBeenCalledWith(1)
  })
  
  it('handles API errors gracefully', async () => {
    const errorMessage = 'API Error'
    mockVocabularyApi.getWordsByLevel.mockRejectedValue(new Error(errorMessage))
    
    const { error, loadWords } = useVocabulary(1, { autoLoad: false })
    
    await loadWords(1)
    await flushPromises()
    
    expect(error.value).toBe('載入詞彙失敗')
  })
  
  it('calculates computed properties correctly', async () => {
    mockVocabularyApi.getWordsByLevel.mockResolvedValue({
      data: [mockWord, { ...mockWord, id: '2' }]
    })
    
    const { words, wordsCount, hasWords } = useVocabulary(1)
    
    await flushPromises()
    
    expect(wordsCount.value).toBe(2)
    expect(hasWords.value).toBe(true)
  })
})

E2E 測試規範

用戶流程測試

// tests/e2e/vocabulary-learning.cy.ts
describe('Vocabulary Learning Flow', () => {
  beforeEach(() => {
    // 登入用戶
    cy.login('test@example.com', 'password')
    
    // 模擬 API 回應
    cy.intercept('GET', '/api/vocabulary/level/1', {
      fixture: 'vocabulary-words.json'
    }).as('getWords')
  })
  
  it('completes vocabulary learning session', () => {
    // 導航到詞彙學習頁面
    cy.visit('/vocabulary/introduction/word-1')
    
    // 等待數據載入
    cy.wait('@getWords')
    
    // 檢查頁面內容
    cy.get('[data-test="word-text"]').should('be.visible')
    cy.get('[data-test="definition"]').should('contain', '你好')
    
    // 播放音頻
    cy.get('[data-test="play-audio"]').click()
    
    // 進入練習模式
    cy.get('[data-test="start-practice"]').click()
    
    // 完成選擇題練習
    cy.get('[data-test="practice-option-1"]').click()
    cy.get('[data-test="submit-answer"]').click()
    
    // 檢查結果
    cy.get('[data-test="result-correct"]').should('be.visible')
    
    // 繼續下一個詞彙
    cy.get('[data-test="next-word"]').click()
    
    // 檢查進度更新
    cy.get('[data-test="progress-bar"]').should('have.attr', 'aria-valuenow', '50')
  })
  
  it('handles practice errors gracefully', () => {
    // 模擬 API 錯誤
    cy.intercept('POST', '/api/vocabulary/practice', {
      statusCode: 500,
      body: { error: 'Server error' }
    }).as('practiceError')
    
    cy.visit('/vocabulary/practice')
    
    // 提交答案
    cy.get('[data-test="practice-option-1"]').click()
    cy.get('[data-test="submit-answer"]').click()
    
    cy.wait('@practiceError')
    
    // 檢查錯誤處理
    cy.get('[data-test="error-message"]')
      .should('be.visible')
      .and('contain', '提交失敗,請稍後再試')
    
    // 檢查重試按鈕
    cy.get('[data-test="retry-button"]').should('be.visible')
  })
})

📋 代碼審查檢查清單

組件審查要點

  • 組件命名使用 PascalCase
  • Props 有明確的 TypeScript 類型定義
  • 使用 defineEmits 定義所有事件
  • 計算屬性使用 computed() 而非方法
  • 使用 readonly() 暴露響應式狀態
  • 組件有適當的測試覆蓋
  • 樣式使用 scoped 或 CSS Modules
  • 遵循無障礙性最佳實踐

性能審查要點

  • 避免在模板中直接調用方法
  • 使用 v-memo 優化重複渲染
  • 大列表使用虛擬滾動
  • 圖片使用 lazy loading
  • 組件使用 defineAsyncComponent 懶載入
  • 避免不必要的響應式包裝

安全性審查要點

  • 用戶輸入已進行適當清理
  • 使用 v-html 時已進行 XSS 防護
  • API 調用包含錯誤處理
  • 敏感資料不在客戶端暴露
  • 路由包含適當的權限檢查

文檔狀態: 🟢 完整開發規範
最後更新: 2025-09-09
適用版本: Vue 3 + Composition API
維護團隊: 前端開發團隊