1114 lines
26 KiB
Markdown
1114 lines
26 KiB
Markdown
# Drama Ling Vue.js前端技術架構規劃
|
||
|
||
## 📋 架構概述
|
||
|
||
**專案名稱**: Drama Ling 語言學習應用 (Web端)
|
||
**建立日期**: 2025-09-09
|
||
**技術主軸**: Vue.js 3 + Composition API
|
||
**對應後端**: .NET Core API
|
||
**部署目標**: 響應式Web應用程式
|
||
|
||
### 核心設計目標
|
||
- 🎯 **學習體驗優化**: 支援語音互動、即時AI分析
|
||
- 📱 **響應式設計**: 桌面優先,兼容平板和手機
|
||
- 🚀 **效能優化**: 快速載入、流暢互動
|
||
- 🔒 **企業級安全**: 資料保護、安全認證
|
||
- 💎 **商業功能**: 支付整合、訂閱管理
|
||
|
||
## 🛠️ Vue.js 技術堆疊
|
||
|
||
### 核心框架
|
||
```javascript
|
||
{
|
||
"vue": "3.4.x", // 最新穩定版,全面使用Composition API
|
||
"vue-router": "4.3.x", // Vue 3 官方路由
|
||
"pinia": "2.1.x", // Vue 3 推薦狀態管理
|
||
"vite": "5.x", // 現代化建構工具
|
||
"typescript": "5.x", // 強型別支援
|
||
"vue-tsc": "latest" // Vue TypeScript支援
|
||
}
|
||
```
|
||
|
||
### UI框架選擇
|
||
```javascript
|
||
// 推薦方案:Quasar Framework
|
||
{
|
||
"quasar": "2.16.x", // 企業級UI框架,支援響應式和PWA
|
||
"quasar-cli": "2.4.x", // 完整開發工具鏈
|
||
|
||
// 替代方案
|
||
"element-plus": "2.7.x", // 成熟穩定,企業級組件
|
||
"vuetify": "3.6.x", // Material Design風格
|
||
"ant-design-vue": "4.2.x" // 豐富組件生態
|
||
}
|
||
```
|
||
|
||
### 工具鏈配置
|
||
```javascript
|
||
{
|
||
"dev_tools": {
|
||
"eslint": "9.x", // 代碼檢查
|
||
"prettier": "3.x", // 代碼格式化
|
||
"stylelint": "16.x", // CSS檢查
|
||
"husky": "9.x", // Git hooks
|
||
"lint-staged": "15.x", // 暫存檔檢查
|
||
"vitest": "1.6.x", // Vue生態測試框架
|
||
"@vue/test-utils": "2.4.x" // Vue組件測試
|
||
},
|
||
"build_tools": {
|
||
"vite": "5.x", // 快速建構
|
||
"unplugin-vue-components": "0.27.x", // 自動導入組件
|
||
"unplugin-auto-import": "0.17.x", // 自動導入API
|
||
"@vitejs/plugin-pwa": "0.20.x" // PWA支援
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🏗️ 專案結構設計
|
||
|
||
### 整體目錄結構
|
||
```
|
||
web-frontend/
|
||
├── public/
|
||
│ ├── icons/ # PWA圖標
|
||
│ ├── audio/ # 音頻素材
|
||
│ └── manifest.json # PWA配置
|
||
├── src/
|
||
│ ├── assets/ # 靜態資源
|
||
│ │ ├── images/
|
||
│ │ ├── audio/
|
||
│ │ └── styles/
|
||
│ ├── components/ # 共用組件
|
||
│ │ ├── base/ # 基礎組件
|
||
│ │ ├── business/ # 業務組件
|
||
│ │ └── layout/ # 佈局組件
|
||
│ ├── composables/ # 組合式函數
|
||
│ │ ├── useAuth.ts
|
||
│ │ ├── useAudio.ts
|
||
│ │ └── useApi.ts
|
||
│ ├── modules/ # 功能模組
|
||
│ │ ├── auth/ # 認證模組
|
||
│ │ ├── vocabulary/ # 詞彙學習
|
||
│ │ ├── dialogue/ # 情境對話
|
||
│ │ ├── learning-map/ # 學習地圖
|
||
│ │ └── shop/ # 道具商店
|
||
│ ├── router/ # 路由配置
|
||
│ ├── stores/ # Pinia狀態管理
|
||
│ ├── services/ # API服務層
|
||
│ ├── types/ # TypeScript類型定義
|
||
│ ├── utils/ # 工具函數
|
||
│ ├── App.vue # 根組件
|
||
│ └── main.ts # 應用入口
|
||
├── tests/ # 測試檔案
|
||
├── docs/ # 專案文檔
|
||
└── deployment/ # 部署配置
|
||
```
|
||
|
||
### 模組化設計
|
||
```typescript
|
||
// modules/vocabulary/index.ts
|
||
export interface VocabularyModule {
|
||
routes: RouteRecordRaw[]
|
||
store: StoreDefinition
|
||
components: ComponentMap
|
||
services: ServiceMap
|
||
}
|
||
|
||
// 每個模組包含完整的功能實現
|
||
// modules/vocabulary/
|
||
├── components/
|
||
├── composables/
|
||
├── services/
|
||
├── stores/
|
||
├── types/
|
||
├── views/
|
||
└── router.ts
|
||
```
|
||
|
||
## 🔄 狀態管理架構
|
||
|
||
### Pinia Store 組織
|
||
```typescript
|
||
// stores/index.ts
|
||
export interface StoreMap {
|
||
auth: AuthStore
|
||
user: UserStore
|
||
vocabulary: VocabularyStore
|
||
dialogue: DialogueStore
|
||
shop: ShopStore
|
||
ui: UIStore
|
||
audio: AudioStore
|
||
}
|
||
|
||
// stores/auth.ts
|
||
export const useAuthStore = defineStore('auth', () => {
|
||
const user = ref<User | null>(null)
|
||
const token = ref<string | null>(null)
|
||
const isAuthenticated = computed(() => !!token.value)
|
||
|
||
const login = async (credentials: LoginCredentials) => {
|
||
// API調用和狀態更新
|
||
}
|
||
|
||
const logout = () => {
|
||
user.value = null
|
||
token.value = null
|
||
router.push('/login')
|
||
}
|
||
|
||
return {
|
||
user: readonly(user),
|
||
token: readonly(token),
|
||
isAuthenticated,
|
||
login,
|
||
logout
|
||
}
|
||
})
|
||
```
|
||
|
||
### 全局狀態設計
|
||
```typescript
|
||
// stores/ui.ts - UI狀態管理
|
||
export const useUIStore = defineStore('ui', () => {
|
||
const theme = ref<'light' | 'dark'>('light')
|
||
const language = ref<'zh-TW' | 'en'>('zh-TW')
|
||
const sidebar = ref(false)
|
||
const loading = ref(false)
|
||
const notifications = ref<Notification[]>([])
|
||
|
||
return {
|
||
theme, language, sidebar, loading, notifications
|
||
}
|
||
})
|
||
|
||
// stores/learning.ts - 學習進度狀態
|
||
export const useLearningStore = defineStore('learning', () => {
|
||
const currentLevel = ref(1)
|
||
const diamonds = ref(500)
|
||
const lives = ref(5)
|
||
const streak = ref(0)
|
||
const vocabularyProgress = ref(new Map())
|
||
|
||
return {
|
||
currentLevel, diamonds, lives, streak, vocabularyProgress
|
||
}
|
||
})
|
||
```
|
||
|
||
## 🎨 UI框架整合
|
||
|
||
### Quasar框架配置
|
||
```javascript
|
||
// quasar.config.js
|
||
export default configure(function (ctx) {
|
||
return {
|
||
framework: {
|
||
config: {
|
||
brand: {
|
||
primary: '#1976d2', // Drama Ling 主色調
|
||
secondary: '#26a69a',
|
||
accent: '#9c27b0',
|
||
positive: '#21ba45',
|
||
negative: '#c10015',
|
||
info: '#31ccec',
|
||
warning: '#f2c037'
|
||
}
|
||
},
|
||
|
||
plugins: [
|
||
'Loading',
|
||
'QSpinnerGears',
|
||
'Dialog',
|
||
'Notify',
|
||
'LocalStorage',
|
||
'SessionStorage'
|
||
],
|
||
|
||
components: [
|
||
'QLayout', 'QHeader', 'QDrawer', 'QPageContainer', 'QPage',
|
||
'QToolbar', 'QToolbarTitle', 'QBtn', 'QIcon', 'QList',
|
||
'QItem', 'QItemSection', 'QItemLabel', 'QCard', 'QCardSection',
|
||
'QInput', 'QSelect', 'QCheckbox', 'QRadio', 'QToggle',
|
||
'QSlider', 'QRange', 'QBadge', 'QChip', 'QAvatar',
|
||
'QImg', 'QVideo', 'QAudio', 'QLinearProgress', 'QCircularProgress'
|
||
]
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### 響應式設計策略
|
||
```scss
|
||
// assets/styles/responsive.scss
|
||
$breakpoint-xs: 0;
|
||
$breakpoint-sm: 600px;
|
||
$breakpoint-md: 1024px;
|
||
$breakpoint-lg: 1440px;
|
||
$breakpoint-xl: 1920px;
|
||
|
||
// Vue組件中的響應式設計
|
||
.vocabulary-card {
|
||
@media (max-width: 600px) {
|
||
padding: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
@media (min-width: 601px) and (max-width: 1023px) {
|
||
padding: 12px;
|
||
font-size: 16px;
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
padding: 16px;
|
||
font-size: 18px;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🚦 路由和導航
|
||
|
||
### Vue Router配置
|
||
```typescript
|
||
// router/index.ts
|
||
import { createRouter, createWebHistory } from 'vue-router'
|
||
import type { RouteRecordRaw } from 'vue-router'
|
||
|
||
const routes: RouteRecordRaw[] = [
|
||
{
|
||
path: '/',
|
||
component: () => import('@/layouts/MainLayout.vue'),
|
||
children: [
|
||
{
|
||
path: '',
|
||
name: 'Home',
|
||
component: () => import('@/views/HomePage.vue'),
|
||
meta: { requiresAuth: false }
|
||
},
|
||
{
|
||
path: '/vocabulary',
|
||
name: 'VocabularyModule',
|
||
component: () => import('@/modules/vocabulary/VocabularyLayout.vue'),
|
||
children: [
|
||
{
|
||
path: 'introduction/:wordId',
|
||
name: 'VocabularyIntroduction',
|
||
component: () => import('@/modules/vocabulary/views/IntroductionPage.vue'),
|
||
props: true
|
||
}
|
||
]
|
||
}
|
||
]
|
||
},
|
||
{
|
||
path: '/auth',
|
||
component: () => import('@/layouts/AuthLayout.vue'),
|
||
children: [
|
||
{
|
||
path: 'login',
|
||
name: 'Login',
|
||
component: () => import('@/modules/auth/views/LoginPage.vue')
|
||
}
|
||
]
|
||
}
|
||
]
|
||
|
||
export const router = createRouter({
|
||
history: createWebHistory(),
|
||
routes,
|
||
scrollBehavior(to, from, savedPosition) {
|
||
if (savedPosition) {
|
||
return savedPosition
|
||
}
|
||
return { top: 0 }
|
||
}
|
||
})
|
||
|
||
// 路由守衛
|
||
router.beforeEach((to, from, next) => {
|
||
const authStore = useAuthStore()
|
||
|
||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||
next({ name: 'Login' })
|
||
} else {
|
||
next()
|
||
}
|
||
})
|
||
```
|
||
|
||
### 導航組件設計
|
||
```vue
|
||
<!-- components/layout/NavigationDrawer.vue -->
|
||
<template>
|
||
<q-drawer
|
||
v-model="drawer"
|
||
show-if-above
|
||
:width="280"
|
||
:breakpoint="1024"
|
||
>
|
||
<q-scroll-area class="fit">
|
||
<q-list>
|
||
<NavigationItem
|
||
v-for="item in navigationItems"
|
||
:key="item.name"
|
||
:item="item"
|
||
/>
|
||
</q-list>
|
||
</q-scroll-area>
|
||
</q-drawer>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
interface NavigationItem {
|
||
name: string
|
||
path: string
|
||
icon: string
|
||
children?: NavigationItem[]
|
||
requiresAuth: boolean
|
||
}
|
||
|
||
const navigationItems: NavigationItem[] = [
|
||
{ name: '學習地圖', path: '/learning-map', icon: 'map', requiresAuth: true },
|
||
{ name: '詞彙學習', path: '/vocabulary', icon: 'book', requiresAuth: true },
|
||
{ name: '情境對話', path: '/dialogue', icon: 'chat', requiresAuth: true },
|
||
{ name: '道具商店', path: '/shop', icon: 'shopping_cart', requiresAuth: true },
|
||
{ name: '個人中心', path: '/profile', icon: 'person', requiresAuth: true }
|
||
]
|
||
</script>
|
||
```
|
||
|
||
## 🔌 API服務層架構
|
||
|
||
### HTTP客戶端配置
|
||
```typescript
|
||
// services/http.ts
|
||
import axios from 'axios'
|
||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||
|
||
class HttpClient {
|
||
private client: AxiosInstance
|
||
|
||
constructor() {
|
||
this.client = axios.create({
|
||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||
timeout: 10000,
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
this.setupInterceptors()
|
||
}
|
||
|
||
private setupInterceptors() {
|
||
// 請求攔截器
|
||
this.client.interceptors.request.use(
|
||
(config) => {
|
||
const authStore = useAuthStore()
|
||
if (authStore.token) {
|
||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||
}
|
||
return config
|
||
},
|
||
(error) => Promise.reject(error)
|
||
)
|
||
|
||
// 響應攔截器
|
||
this.client.interceptors.response.use(
|
||
(response) => response,
|
||
(error) => {
|
||
if (error.response?.status === 401) {
|
||
const authStore = useAuthStore()
|
||
authStore.logout()
|
||
}
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
}
|
||
|
||
async get<T>(url: string, params?: any): Promise<T> {
|
||
const response: AxiosResponse<T> = await this.client.get(url, { params })
|
||
return response.data
|
||
}
|
||
|
||
async post<T>(url: string, data?: any): Promise<T> {
|
||
const response: AxiosResponse<T> = await this.client.post(url, data)
|
||
return response.data
|
||
}
|
||
}
|
||
|
||
export const httpClient = new HttpClient()
|
||
```
|
||
|
||
### API服務抽象
|
||
```typescript
|
||
// services/vocabularyApi.ts
|
||
export class VocabularyApiService {
|
||
private basePath = '/api/vocabulary'
|
||
|
||
async getWordIntroduction(wordId: string): Promise<VocabularyWord> {
|
||
return httpClient.get<VocabularyWord>(`${this.basePath}/words/${wordId}`)
|
||
}
|
||
|
||
async submitPracticeResult(result: PracticeResult): Promise<PracticeResponse> {
|
||
return httpClient.post<PracticeResponse>(`${this.basePath}/practice`, result)
|
||
}
|
||
|
||
async getReviewSchedule(): Promise<ReviewSchedule> {
|
||
return httpClient.get<ReviewSchedule>(`${this.basePath}/review/schedule`)
|
||
}
|
||
}
|
||
|
||
export const vocabularyApi = new VocabularyApiService()
|
||
```
|
||
|
||
### API類型定義
|
||
```typescript
|
||
// types/api.ts
|
||
export interface VocabularyWord {
|
||
id: string
|
||
word: string
|
||
pronunciation: string
|
||
definition_zh: string
|
||
definition_en?: string
|
||
part_of_speech: string
|
||
examples: Example[]
|
||
usage_context: string
|
||
related_words: string[]
|
||
frequency_level: number
|
||
audio_url: string
|
||
}
|
||
|
||
export interface PracticeResult {
|
||
word_id: string
|
||
practice_type: 'choice' | 'matching' | 'reorganize'
|
||
is_correct: boolean
|
||
response_time: number
|
||
user_answer: string
|
||
correct_answer: string
|
||
}
|
||
```
|
||
|
||
## 🎵 音頻處理整合
|
||
|
||
### Web Audio API整合
|
||
```typescript
|
||
// composables/useAudio.ts
|
||
export const useAudio = () => {
|
||
const audioContext = ref<AudioContext | null>(null)
|
||
const isPlaying = ref(false)
|
||
const currentTrack = ref<string | null>(null)
|
||
|
||
const initializeAudio = () => {
|
||
if (!audioContext.value) {
|
||
audioContext.value = new AudioContext()
|
||
}
|
||
}
|
||
|
||
const playAudio = async (url: string, playbackRate: number = 1.0) => {
|
||
try {
|
||
initializeAudio()
|
||
isPlaying.value = true
|
||
currentTrack.value = url
|
||
|
||
const response = await fetch(url)
|
||
const arrayBuffer = await response.arrayBuffer()
|
||
const audioBuffer = await audioContext.value!.decodeAudioData(arrayBuffer)
|
||
|
||
const source = audioContext.value!.createBufferSource()
|
||
source.buffer = audioBuffer
|
||
source.playbackRate.value = playbackRate
|
||
source.connect(audioContext.value!.destination)
|
||
|
||
source.onended = () => {
|
||
isPlaying.value = false
|
||
currentTrack.value = null
|
||
}
|
||
|
||
source.start()
|
||
return source
|
||
} catch (error) {
|
||
console.error('Audio playback failed:', error)
|
||
isPlaying.value = false
|
||
}
|
||
}
|
||
|
||
const recordAudio = async (): Promise<MediaRecorder | null> => {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||
const mediaRecorder = new MediaRecorder(stream)
|
||
return mediaRecorder
|
||
} catch (error) {
|
||
console.error('Audio recording failed:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
return {
|
||
isPlaying: readonly(isPlaying),
|
||
currentTrack: readonly(currentTrack),
|
||
playAudio,
|
||
recordAudio
|
||
}
|
||
}
|
||
```
|
||
|
||
### 語音識別整合
|
||
```typescript
|
||
// composables/useSpeechRecognition.ts
|
||
export const useSpeechRecognition = () => {
|
||
const isListening = ref(false)
|
||
const transcript = ref('')
|
||
const confidence = ref(0)
|
||
|
||
let recognition: SpeechRecognition | null = null
|
||
|
||
const startListening = (language: string = 'zh-TW') => {
|
||
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
||
throw new Error('Speech recognition not supported')
|
||
}
|
||
|
||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
||
recognition = new SpeechRecognition()
|
||
|
||
recognition.continuous = false
|
||
recognition.interimResults = true
|
||
recognition.lang = language
|
||
|
||
recognition.onstart = () => {
|
||
isListening.value = true
|
||
}
|
||
|
||
recognition.onresult = (event) => {
|
||
const result = event.results[event.results.length - 1]
|
||
transcript.value = result[0].transcript
|
||
confidence.value = result[0].confidence
|
||
}
|
||
|
||
recognition.onend = () => {
|
||
isListening.value = false
|
||
}
|
||
|
||
recognition.start()
|
||
}
|
||
|
||
const stopListening = () => {
|
||
if (recognition) {
|
||
recognition.stop()
|
||
}
|
||
}
|
||
|
||
return {
|
||
isListening: readonly(isListening),
|
||
transcript: readonly(transcript),
|
||
confidence: readonly(confidence),
|
||
startListening,
|
||
stopListening
|
||
}
|
||
}
|
||
```
|
||
|
||
## 💎 商業功能整合
|
||
|
||
### 支付系統整合
|
||
```typescript
|
||
// services/paymentService.ts
|
||
export class PaymentService {
|
||
async initializeStripe() {
|
||
const stripe = await loadStripe(import.meta.env.VITE_STRIPE_PUBLIC_KEY)
|
||
return stripe
|
||
}
|
||
|
||
async createPaymentIntent(amount: number, currency: string = 'TWD') {
|
||
return httpClient.post<PaymentIntent>('/api/payment/create-intent', {
|
||
amount,
|
||
currency
|
||
})
|
||
}
|
||
|
||
async purchaseDiamonds(packageId: string) {
|
||
const stripe = await this.initializeStripe()
|
||
const { client_secret } = await this.createPaymentIntent(packageId)
|
||
|
||
const result = await stripe!.confirmCardPayment(client_secret)
|
||
|
||
if (result.error) {
|
||
throw new Error(result.error.message)
|
||
}
|
||
|
||
return result.paymentIntent
|
||
}
|
||
}
|
||
```
|
||
|
||
### 訂閱管理
|
||
```typescript
|
||
// composables/useSubscription.ts
|
||
export const useSubscription = () => {
|
||
const subscription = ref<Subscription | null>(null)
|
||
const isVIP = computed(() => subscription.value?.status === 'active')
|
||
|
||
const subscribeToVIP = async (planId: string) => {
|
||
const paymentService = new PaymentService()
|
||
const result = await paymentService.createSubscription(planId)
|
||
subscription.value = result
|
||
return result
|
||
}
|
||
|
||
const cancelSubscription = async () => {
|
||
await httpClient.post('/api/subscription/cancel')
|
||
await refreshSubscription()
|
||
}
|
||
|
||
const refreshSubscription = async () => {
|
||
subscription.value = await httpClient.get<Subscription>('/api/subscription/current')
|
||
}
|
||
|
||
return {
|
||
subscription: readonly(subscription),
|
||
isVIP,
|
||
subscribeToVIP,
|
||
cancelSubscription,
|
||
refreshSubscription
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🚀 效能優化策略
|
||
|
||
### 代碼分割和懶載入
|
||
```typescript
|
||
// router/index.ts - 路由層面的懶載入
|
||
const routes = [
|
||
{
|
||
path: '/vocabulary',
|
||
component: () => import('@/modules/vocabulary/VocabularyModule.vue')
|
||
},
|
||
{
|
||
path: '/dialogue',
|
||
component: () => import(
|
||
/* webpackChunkName: "dialogue" */
|
||
'@/modules/dialogue/DialogueModule.vue'
|
||
)
|
||
}
|
||
]
|
||
|
||
// components - 組件層面的懶載入
|
||
export const LazyVocabularyCard = defineAsyncComponent({
|
||
loader: () => import('./VocabularyCard.vue'),
|
||
loadingComponent: LoadingSpinner,
|
||
errorComponent: ErrorComponent,
|
||
delay: 200,
|
||
timeout: 3000
|
||
})
|
||
```
|
||
|
||
### 狀態持久化
|
||
```typescript
|
||
// stores/persistence.ts
|
||
import { createPersistencePlugin } from 'pinia-plugin-persistedstate'
|
||
|
||
export const persistencePlugin = createPersistencePlugin({
|
||
key: (id) => `__dramaling_${id}__`,
|
||
storage: localStorage,
|
||
serializer: {
|
||
serialize: JSON.stringify,
|
||
deserialize: JSON.parse
|
||
},
|
||
beforeRestore: (context) => {
|
||
console.log('Before restore:', context.store.$id)
|
||
},
|
||
afterRestore: (context) => {
|
||
console.log('After restore:', context.store.$id)
|
||
}
|
||
})
|
||
```
|
||
|
||
### 快取策略
|
||
```typescript
|
||
// composables/useCache.ts
|
||
export const useCache = <T>(key: string, ttl: number = 300000) => {
|
||
const cache = new Map<string, { data: T; timestamp: number }>()
|
||
|
||
const get = (cacheKey: string): T | null => {
|
||
const cached = cache.get(cacheKey)
|
||
if (!cached) return null
|
||
|
||
if (Date.now() - cached.timestamp > ttl) {
|
||
cache.delete(cacheKey)
|
||
return null
|
||
}
|
||
|
||
return cached.data
|
||
}
|
||
|
||
const set = (cacheKey: string, data: T): void => {
|
||
cache.set(cacheKey, {
|
||
data,
|
||
timestamp: Date.now()
|
||
})
|
||
}
|
||
|
||
return { get, set }
|
||
}
|
||
```
|
||
|
||
## 🔒 安全性實作
|
||
|
||
### XSS防護
|
||
```typescript
|
||
// utils/sanitizer.ts
|
||
import DOMPurify from 'dompurify'
|
||
|
||
export const sanitizeHtml = (html: string): string => {
|
||
return DOMPurify.sanitize(html, {
|
||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'span'],
|
||
ALLOWED_ATTR: ['class']
|
||
})
|
||
}
|
||
|
||
export const sanitizeInput = (input: string): string => {
|
||
return input.replace(/[<>'"&]/g, (match) => {
|
||
const escapeMap: Record<string, string> = {
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
'&': '&'
|
||
}
|
||
return escapeMap[match]
|
||
})
|
||
}
|
||
```
|
||
|
||
### CSRF防護
|
||
```typescript
|
||
// services/csrf.ts
|
||
export class CSRFService {
|
||
private token: string | null = null
|
||
|
||
async getCSRFToken(): Promise<string> {
|
||
if (!this.token) {
|
||
const response = await httpClient.get<{token: string}>('/api/csrf-token')
|
||
this.token = response.token
|
||
}
|
||
return this.token
|
||
}
|
||
|
||
async addCSRFHeader(config: any) {
|
||
const token = await this.getCSRFToken()
|
||
config.headers['X-CSRF-Token'] = token
|
||
return config
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🧪 測試策略
|
||
|
||
### 單元測試配置
|
||
```typescript
|
||
// vitest.config.ts
|
||
import { defineConfig } from 'vitest/config'
|
||
import vue from '@vitejs/plugin-vue'
|
||
|
||
export default defineConfig({
|
||
plugins: [vue()],
|
||
test: {
|
||
environment: 'happy-dom',
|
||
globals: true,
|
||
coverage: {
|
||
provider: 'v8',
|
||
reporter: ['text', 'json-summary', 'html'],
|
||
threshold: {
|
||
global: {
|
||
branches: 80,
|
||
functions: 80,
|
||
lines: 80,
|
||
statements: 80
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### 組件測試範例
|
||
```typescript
|
||
// tests/components/VocabularyCard.test.ts
|
||
import { mount } from '@vue/test-utils'
|
||
import { describe, it, expect } from 'vitest'
|
||
import VocabularyCard from '@/components/VocabularyCard.vue'
|
||
|
||
describe('VocabularyCard', () => {
|
||
it('renders vocabulary word correctly', () => {
|
||
const wrapper = mount(VocabularyCard, {
|
||
props: {
|
||
word: {
|
||
id: '1',
|
||
word: 'hello',
|
||
pronunciation: '/həˈloʊ/',
|
||
definition_zh: '你好',
|
||
part_of_speech: 'interjection'
|
||
}
|
||
}
|
||
})
|
||
|
||
expect(wrapper.find('[data-test="word"]').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 mockPlayAudio = vi.fn()
|
||
|
||
const wrapper = mount(VocabularyCard, {
|
||
props: { word: mockWord },
|
||
global: {
|
||
provide: {
|
||
playAudio: mockPlayAudio
|
||
}
|
||
}
|
||
})
|
||
|
||
await wrapper.find('[data-test="play-audio"]').trigger('click')
|
||
expect(mockPlayAudio).toHaveBeenCalledWith(mockWord.audio_url)
|
||
})
|
||
})
|
||
```
|
||
|
||
## 📦 建構和部署
|
||
|
||
### Vite建構配置
|
||
```typescript
|
||
// vite.config.ts
|
||
import { defineConfig } from 'vite'
|
||
import vue from '@vitejs/plugin-vue'
|
||
import { VitePWA } from 'vite-plugin-pwa'
|
||
import Components from 'unplugin-vue-components/vite'
|
||
import AutoImport from 'unplugin-auto-import/vite'
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
vue(),
|
||
Components({
|
||
resolvers: [
|
||
// 自動導入Quasar組件
|
||
(componentName) => {
|
||
if (componentName.startsWith('Q'))
|
||
return { name: componentName, from: 'quasar' }
|
||
}
|
||
]
|
||
}),
|
||
AutoImport({
|
||
imports: [
|
||
'vue',
|
||
'vue-router',
|
||
'pinia',
|
||
{
|
||
'quasar': ['useQuasar', '$q']
|
||
}
|
||
]
|
||
}),
|
||
VitePWA({
|
||
registerType: 'autoUpdate',
|
||
manifest: {
|
||
name: 'Drama Ling',
|
||
short_name: 'DramaLing',
|
||
description: 'AI-powered language learning app',
|
||
theme_color: '#1976d2',
|
||
background_color: '#ffffff',
|
||
display: 'standalone',
|
||
icons: [
|
||
{
|
||
src: '/icons/icon-192x192.png',
|
||
sizes: '192x192',
|
||
type: 'image/png'
|
||
}
|
||
]
|
||
}
|
||
})
|
||
],
|
||
|
||
resolve: {
|
||
alias: {
|
||
'@': path.resolve(__dirname, 'src')
|
||
}
|
||
},
|
||
|
||
build: {
|
||
rollupOptions: {
|
||
output: {
|
||
manualChunks: {
|
||
vendor: ['vue', 'vue-router', 'pinia'],
|
||
quasar: ['quasar'],
|
||
utils: ['axios', 'lodash-es']
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### Docker部署配置
|
||
```dockerfile
|
||
# Dockerfile
|
||
FROM node:18-alpine AS builder
|
||
|
||
WORKDIR /app
|
||
COPY package*.json ./
|
||
RUN npm ci --only=production
|
||
|
||
COPY . .
|
||
RUN npm run build
|
||
|
||
FROM nginx:alpine
|
||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||
COPY nginx.conf /etc/nginx/nginx.conf
|
||
|
||
EXPOSE 80
|
||
CMD ["nginx", "-g", "daemon off;"]
|
||
```
|
||
|
||
### Nginx配置
|
||
```nginx
|
||
# nginx.conf
|
||
server {
|
||
listen 80;
|
||
server_name dramaling.com;
|
||
|
||
location / {
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
|
||
location /api/ {
|
||
proxy_pass http://backend:5000;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
}
|
||
|
||
gzip on;
|
||
gzip_types text/plain text/css application/json application/javascript;
|
||
}
|
||
```
|
||
|
||
## 📊 開發工作流程
|
||
|
||
### Git工作流程
|
||
```yaml
|
||
# .github/workflows/ci.yml
|
||
name: CI/CD Pipeline
|
||
on:
|
||
push:
|
||
branches: [main, develop]
|
||
pull_request:
|
||
branches: [main]
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
- uses: actions/setup-node@v3
|
||
with:
|
||
node-version: '18'
|
||
cache: 'npm'
|
||
|
||
- run: npm ci
|
||
- run: npm run lint
|
||
- run: npm run type-check
|
||
- run: npm run test:unit
|
||
- run: npm run build
|
||
|
||
deploy:
|
||
needs: test
|
||
if: github.ref == 'refs/heads/main'
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- name: Deploy to production
|
||
run: echo "Deploying to production server"
|
||
```
|
||
|
||
### 開發環境設置
|
||
```json
|
||
// package.json scripts
|
||
{
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "vue-tsc --noEmit && vite build",
|
||
"preview": "vite preview",
|
||
"test:unit": "vitest",
|
||
"test:e2e": "cypress run",
|
||
"lint": "eslint . --ext .vue,.ts,.tsx --fix",
|
||
"type-check": "vue-tsc --noEmit"
|
||
}
|
||
}
|
||
```
|
||
|
||
## 📈 監控和分析
|
||
|
||
### 性能監控
|
||
```typescript
|
||
// utils/performance.ts
|
||
export class PerformanceMonitor {
|
||
static trackPageLoad(routeName: string) {
|
||
if (typeof window !== 'undefined' && 'performance' in window) {
|
||
const loadTime = window.performance.timing.loadEventEnd - window.performance.timing.navigationStart
|
||
|
||
// 發送到分析服務
|
||
this.sendMetric('page_load_time', loadTime, { route: routeName })
|
||
}
|
||
}
|
||
|
||
static trackUserAction(action: string, duration?: number) {
|
||
this.sendMetric('user_action', duration || 0, { action })
|
||
}
|
||
|
||
private static sendMetric(name: string, value: number, labels: Record<string, string>) {
|
||
// 發送到Google Analytics、Mixpanel等
|
||
if (window.gtag) {
|
||
window.gtag('event', name, {
|
||
value: value,
|
||
custom_parameter: labels
|
||
})
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 📋 開發檢查清單
|
||
|
||
### 代碼品質檢查
|
||
- [ ] ESLint檢查通過
|
||
- [ ] TypeScript類型檢查無錯誤
|
||
- [ ] 單元測試覆蓋率 > 80%
|
||
- [ ] 組件測試完整
|
||
- [ ] E2E測試關鍵流程
|
||
|
||
### 性能檢查
|
||
- [ ] 首屏載入時間 < 2秒
|
||
- [ ] 代碼分割策略實施
|
||
- [ ] 圖片資源優化
|
||
- [ ] API請求優化
|
||
- [ ] PWA功能測試
|
||
|
||
### 安全檢查
|
||
- [ ] XSS防護實施
|
||
- [ ] CSRF防護配置
|
||
- [ ] 輸入驗證完整
|
||
- [ ] 敏感資料加密
|
||
- [ ] HTTPS配置
|
||
|
||
### 響應式檢查
|
||
- [ ] 手機版佈局測試
|
||
- [ ] 平板版佈局測試
|
||
- [ ] 桌面版佈局測試
|
||
- [ ] 觸控操作優化
|
||
- [ ] 鍵盤導航支援
|
||
|
||
---
|
||
|
||
**文檔狀態**: 🟢 已完成技術架構規劃
|
||
**最後更新**: 2025-09-09
|
||
**負責團隊**: 前端開發團隊
|
||
**下次檢查**: 開發開始前進行技術選型確認 |