dramaling-app/docs/04_technical/03_frontend/vue-development-standards.md

1391 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Vue.js 開發規範和最佳實踐
## 📋 總體指導原則
### 核心原則
1. **可讀性優先**: 代碼應該易於理解和維護
2. **一致性**: 整個專案使用統一的編碼風格
3. **可測試性**: 編寫易於測試的代碼
4. **效能考量**: 避免不必要的重複渲染和記憶體洩漏
5. **類型安全**: 充分利用 TypeScript 的類型檢查
### 技術選型標準
- **Vue 3 + Composition API**: 優先使用 Composition API
- **TypeScript**: 所有 .vue 檔案使用 `<script setup lang="ts">`
- **Single File Components**: 採用 SFC 結構
- **組件化設計**: 遵循原子設計理念
## 🏗️ 組件開發規範
### 組件命名規範
#### 組件檔案命名
```bash
# ✅ 正確:使用 PascalCase
UserProfile.vue
VocabularyCard.vue
DialogueInterface.vue
PaymentModal.vue
# ❌ 錯誤
userProfile.vue
vocabulary-card.vue
dialogueInterface.vue
```
#### 組件註冊和使用
```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 結構
```vue
<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>
```
#### 複雜組件結構範例
```vue
<!-- 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 類型定義
```typescript
// ✅ 正確:明確的類型定義
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 驗證
```typescript
// 複雜驗證邏輯可以使用計算屬性
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 事件命名
```typescript
// ✅ 正確:使用動詞命名,描述發生的動作
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] // 無意義
}
```
#### 事件觸發範例
```typescript
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 結構
```typescript
// 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 範例
```typescript
// 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 使用規範
#### 在組件中正確使用
```vue
<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 結構
```typescript
// 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 組合和模組化
```typescript
// 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
```vue
<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 組織結構
#### 樣式檔案組織
```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';
```
#### 變數命名規範
```scss
// 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;
```
#### 混合器和工具
```scss
// 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 命名方法
```scss
// ✅ 正確:使用 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; // 嵌套過深且使用硬編碼顏色
}
}
}
}
}
```
#### 響應式設計實作
```scss
.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;
}
}
}
}
```
## 🧪 測試規範
### 單元測試規範
#### 組件測試範例
```typescript
// 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 測試範例
```typescript
// 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 測試規範
#### 用戶流程測試
```typescript
// 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
**維護團隊**: 前端開發團隊