1391 lines
32 KiB
Markdown
1391 lines
32 KiB
Markdown
# 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
|
||
**維護團隊**: 前端開發團隊 |