26 KiB
26 KiB
Drama Ling Vue.js前端技術架構規劃
📋 架構概述
專案名稱: Drama Ling 語言學習應用 (Web端)
建立日期: 2025-09-09
技術主軸: Vue.js 3 + Composition API
對應後端: .NET Core API
部署目標: 響應式Web應用程式
核心設計目標
- 🎯 學習體驗優化: 支援語音互動、即時AI分析
- 📱 響應式設計: 桌面優先,兼容平板和手機
- 🚀 效能優化: 快速載入、流暢互動
- 🔒 企業級安全: 資料保護、安全認證
- 💎 商業功能: 支付整合、訂閱管理
🛠️ Vue.js 技術堆疊
核心框架
{
"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框架選擇
// 推薦方案: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" // 豐富組件生態
}
工具鏈配置
{
"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/ # 部署配置
模組化設計
// 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 組織
// 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
}
})
全局狀態設計
// 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框架配置
// 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'
]
}
}
})
響應式設計策略
// 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配置
// 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()
}
})
導航組件設計
<!-- 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客戶端配置
// 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服務抽象
// 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類型定義
// 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整合
// 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
}
}
語音識別整合
// 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
}
}
💎 商業功能整合
支付系統整合
// 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
}
}
訂閱管理
// 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
}
}
🚀 效能優化策略
代碼分割和懶載入
// 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
})
狀態持久化
// 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)
}
})
快取策略
// 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防護
// 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防護
// 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
}
}
🧪 測試策略
單元測試配置
// 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
}
}
}
}
})
組件測試範例
// 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建構配置
// 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
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.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工作流程
# .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"
開發環境設置
// 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"
}
}
📈 監控和分析
性能監控
// 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
負責團隊: 前端開發團隊
下次檢查: 開發開始前進行技術選型確認