1310 lines
31 KiB
Markdown
1310 lines
31 KiB
Markdown
# Drama Ling 原生前端技術架構規劃
|
||
|
||
## 📋 架構概述
|
||
|
||
**專案名稱**: Drama Ling 語言學習應用 (Web端)
|
||
**建立日期**: 2025-09-10
|
||
**技術主軸**: 原生Web技術 (HTML5 + CSS3 + Modern JavaScript)
|
||
**對應後端**: .NET Core API
|
||
**部署目標**: 響應式Web應用程式
|
||
|
||
### 核心設計目標
|
||
- 🎯 **像素級精確**: 完全按照HTML原型設計實現,無框架抽象干擾
|
||
- 📱 **響應式設計**: 桌面優先,兼容平板和手機
|
||
- 🚀 **輕量高效**: 無框架負擔,快速載入和流暢互動
|
||
- 🔒 **企業級安全**: 資料保護、安全認證
|
||
- 💎 **Claude Code友好**: 適合AI輔助開發,易於理解和修改
|
||
|
||
## 🛠️ 技術堆疊
|
||
|
||
### 核心技術
|
||
```javascript
|
||
{
|
||
"markup": "HTML5", // 語義化標記,無障礙支援
|
||
"styling": "CSS3 + SCSS", // 現代CSS特性 + 預處理器
|
||
"scripting": "ES2022+", // 現代JavaScript特性
|
||
"bundler": "Vite 5.x", // 快速開發伺服器和打包工具
|
||
"typescript": "5.x" // 可選的強型別支援
|
||
}
|
||
```
|
||
|
||
### 開發工具鏈
|
||
```javascript
|
||
{
|
||
"dev_tools": {
|
||
"bundler": "Vite 5.x", // 快速HMR和打包
|
||
"css_preprocessor": "Sass/SCSS", // CSS預處理器
|
||
"linter": "ESLint 9.x", // JavaScript代碼檢查
|
||
"formatter": "Prettier 3.x", // 代碼格式化
|
||
"css_linter": "Stylelint 16.x", // CSS代碼檢查
|
||
"testing": "Vitest 1.6.x", // 快速單元測試
|
||
"e2e_testing": "Playwright" // 端到端測試
|
||
},
|
||
"build_optimization": {
|
||
"minification": "Terser", // JS壓縮
|
||
"css_optimization": "cssnano", // CSS優化
|
||
"image_optimization": "vite-plugin-imagemin",
|
||
"pwa_support": "@vite/plugin-pwa"
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🏗️ 專案結構設計
|
||
|
||
### 整體目錄結構
|
||
```
|
||
web-frontend/
|
||
├── public/
|
||
│ ├── icons/ # PWA圖標和favicon
|
||
│ ├── assets/ # 靜態資源
|
||
│ │ ├── audio/ # 音頻檔案
|
||
│ │ ├── images/ # 圖片資源
|
||
│ │ └── fonts/ # 字體檔案
|
||
│ └── manifest.json # PWA配置
|
||
├── src/
|
||
│ ├── pages/ # 頁面HTML檔案
|
||
│ │ ├── home/ # 首頁相關
|
||
│ │ ├── auth/ # 認證相關頁面
|
||
│ │ ├── vocabulary/ # 詞彙學習頁面
|
||
│ │ ├── dialogue/ # 情境對話頁面
|
||
│ │ └── profile/ # 用戶相關頁面
|
||
│ ├── components/ # 可重用組件
|
||
│ │ ├── ui/ # 基礎UI組件
|
||
│ │ ├── layout/ # 佈局組件
|
||
│ │ └── business/ # 業務邏輯組件
|
||
│ ├── styles/ # 樣式檔案
|
||
│ │ ├── base/ # 基礎樣式
|
||
│ │ │ ├── reset.scss # CSS重置
|
||
│ │ │ ├── typography.scss # 字體樣式
|
||
│ │ │ └── variables.scss # SCSS變數
|
||
│ │ ├── components/ # 組件樣式
|
||
│ │ ├── pages/ # 頁面樣式
|
||
│ │ └── utilities/ # 工具類樣式
|
||
│ ├── scripts/ # JavaScript模組
|
||
│ │ ├── core/ # 核心功能
|
||
│ │ │ ├── router.js # 路由管理
|
||
│ │ │ ├── state.js # 狀態管理
|
||
│ │ │ ├── api.js # API客戶端
|
||
│ │ │ └── events.js # 事件系統
|
||
│ │ ├── modules/ # 功能模組
|
||
│ │ │ ├── auth/ # 認證模組
|
||
│ │ │ ├── vocabulary/ # 詞彙學習
|
||
│ │ │ ├── audio/ # 音頻處理
|
||
│ │ │ └── analytics/ # 數據分析
|
||
│ │ ├── utils/ # 工具函數
|
||
│ │ └── services/ # 業務服務
|
||
│ └── main.js # 應用入口
|
||
├── tests/ # 測試檔案
|
||
├── docs/ # 專案文檔
|
||
└── vite.config.js # Vite配置
|
||
```
|
||
|
||
### 模組化設計
|
||
```javascript
|
||
// modules/vocabulary/index.js
|
||
export class VocabularyModule {
|
||
constructor() {
|
||
this.state = new VocabularyState()
|
||
this.api = new VocabularyAPI()
|
||
this.ui = new VocabularyUI()
|
||
}
|
||
|
||
init() {
|
||
this.setupEventListeners()
|
||
this.loadInitialData()
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// 事件監聽設置
|
||
}
|
||
}
|
||
|
||
// 每個模組包含完整的功能實現
|
||
// modules/vocabulary/
|
||
├── components/ # 詞彙相關組件
|
||
├── services/ # API服務
|
||
├── state/ # 狀態管理
|
||
├── utils/ # 工具函數
|
||
└── index.js # 模組入口
|
||
```
|
||
|
||
## 🔄 狀態管理架構
|
||
|
||
### 簡單狀態管理模式
|
||
```javascript
|
||
// core/state.js
|
||
class ApplicationState {
|
||
constructor() {
|
||
this.stores = {
|
||
auth: new AuthState(),
|
||
vocabulary: new VocabularyState(),
|
||
ui: new UIState(),
|
||
audio: new AudioState()
|
||
}
|
||
this.subscribers = new Map()
|
||
}
|
||
|
||
// 訂閱狀態變化
|
||
subscribe(storeName, callback) {
|
||
if (!this.subscribers.has(storeName)) {
|
||
this.subscribers.set(storeName, [])
|
||
}
|
||
this.subscribers.get(storeName).push(callback)
|
||
}
|
||
|
||
// 通知訂閱者
|
||
notify(storeName, data) {
|
||
const callbacks = this.subscribers.get(storeName) || []
|
||
callbacks.forEach(callback => callback(data))
|
||
}
|
||
|
||
// 更新狀態
|
||
updateStore(storeName, updater) {
|
||
const store = this.stores[storeName]
|
||
if (store) {
|
||
store.update(updater)
|
||
this.notify(storeName, store.getState())
|
||
}
|
||
}
|
||
}
|
||
|
||
// 認證狀態管理
|
||
class AuthState {
|
||
constructor() {
|
||
this.state = {
|
||
user: null,
|
||
token: localStorage.getItem('auth_token'),
|
||
isAuthenticated: false
|
||
}
|
||
this.checkAuthStatus()
|
||
}
|
||
|
||
update(updater) {
|
||
this.state = { ...this.state, ...updater(this.state) }
|
||
this.persistState()
|
||
}
|
||
|
||
persistState() {
|
||
if (this.state.token) {
|
||
localStorage.setItem('auth_token', this.state.token)
|
||
} else {
|
||
localStorage.removeItem('auth_token')
|
||
}
|
||
}
|
||
|
||
getState() {
|
||
return { ...this.state }
|
||
}
|
||
}
|
||
|
||
// 全域狀態實例
|
||
export const appState = new ApplicationState()
|
||
```
|
||
|
||
### 本地存儲策略
|
||
```javascript
|
||
// utils/storage.js
|
||
class StorageManager {
|
||
constructor() {
|
||
this.prefix = 'dramaling_'
|
||
}
|
||
|
||
// localStorage封裝
|
||
setLocal(key, value) {
|
||
try {
|
||
localStorage.setItem(
|
||
`${this.prefix}${key}`,
|
||
JSON.stringify(value)
|
||
)
|
||
} catch (error) {
|
||
console.error('LocalStorage write failed:', error)
|
||
}
|
||
}
|
||
|
||
getLocal(key, defaultValue = null) {
|
||
try {
|
||
const item = localStorage.getItem(`${this.prefix}${key}`)
|
||
return item ? JSON.parse(item) : defaultValue
|
||
} catch (error) {
|
||
console.error('LocalStorage read failed:', error)
|
||
return defaultValue
|
||
}
|
||
}
|
||
|
||
// sessionStorage封裝
|
||
setSession(key, value) {
|
||
try {
|
||
sessionStorage.setItem(
|
||
`${this.prefix}${key}`,
|
||
JSON.stringify(value)
|
||
)
|
||
} catch (error) {
|
||
console.error('SessionStorage write failed:', error)
|
||
}
|
||
}
|
||
|
||
getSession(key, defaultValue = null) {
|
||
try {
|
||
const item = sessionStorage.getItem(`${this.prefix}${key}`)
|
||
return item ? JSON.parse(item) : defaultValue
|
||
} catch (error) {
|
||
console.error('SessionStorage read failed:', error)
|
||
return defaultValue
|
||
}
|
||
}
|
||
}
|
||
|
||
export const storage = new StorageManager()
|
||
```
|
||
|
||
## 🎨 CSS架構設計
|
||
|
||
### SCSS組織結構
|
||
```scss
|
||
// styles/main.scss
|
||
// 1. 工具和變數
|
||
@import 'base/variables';
|
||
@import 'base/functions';
|
||
@import 'base/mixins';
|
||
|
||
// 2. 基礎樣式
|
||
@import 'base/reset';
|
||
@import 'base/typography';
|
||
@import 'base/layout';
|
||
|
||
// 3. 組件樣式
|
||
@import 'components/buttons';
|
||
@import 'components/forms';
|
||
@import 'components/cards';
|
||
@import 'components/modals';
|
||
|
||
// 4. 頁面樣式
|
||
@import 'pages/home';
|
||
@import 'pages/vocabulary';
|
||
@import 'pages/auth';
|
||
|
||
// 5. 工具類
|
||
@import 'utilities/spacing';
|
||
@import 'utilities/colors';
|
||
@import 'utilities/responsive';
|
||
```
|
||
|
||
### 變數系統
|
||
```scss
|
||
// styles/base/_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;
|
||
|
||
// 字體系統
|
||
$font-family-primary: 'Inter', 'Noto Sans TC', sans-serif;
|
||
$font-family-secondary: 'Roboto', 'Microsoft JhengHei', 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;
|
||
```
|
||
|
||
### 響應式設計混合器
|
||
```scss
|
||
// styles/base/_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; }
|
||
}
|
||
}
|
||
|
||
// CSS Grid佈局工具
|
||
@mixin grid-container($columns: 1, $gap: $spacing-md) {
|
||
display: grid;
|
||
grid-template-columns: repeat($columns, 1fr);
|
||
gap: $gap;
|
||
}
|
||
|
||
// 按鈕樣式基礎
|
||
@mixin button-base {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: $spacing-sm $spacing-md;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-family: $font-family-primary;
|
||
font-size: $font-size-base;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
text-decoration: none;
|
||
|
||
&:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
}
|
||
|
||
// 卡片組件樣式
|
||
@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);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🚦 路由和導航
|
||
|
||
### 簡單路由器實現
|
||
```javascript
|
||
// core/router.js
|
||
class SimpleRouter {
|
||
constructor() {
|
||
this.routes = new Map()
|
||
this.currentRoute = null
|
||
this.init()
|
||
}
|
||
|
||
init() {
|
||
window.addEventListener('popstate', () => {
|
||
this.handleRoute()
|
||
})
|
||
|
||
// 處理初始路由
|
||
this.handleRoute()
|
||
}
|
||
|
||
// 註冊路由
|
||
addRoute(path, handler, options = {}) {
|
||
this.routes.set(path, {
|
||
handler,
|
||
requiresAuth: options.requiresAuth || false,
|
||
title: options.title || 'Drama Ling'
|
||
})
|
||
}
|
||
|
||
// 導航到指定路由
|
||
navigate(path, state = {}) {
|
||
history.pushState(state, '', path)
|
||
this.handleRoute()
|
||
}
|
||
|
||
// 處理路由變化
|
||
handleRoute() {
|
||
const path = window.location.pathname
|
||
const route = this.findRoute(path)
|
||
|
||
if (route) {
|
||
// 檢查認證要求
|
||
if (route.requiresAuth && !this.checkAuth()) {
|
||
this.navigate('/login')
|
||
return
|
||
}
|
||
|
||
// 設置頁面標題
|
||
document.title = route.title
|
||
|
||
// 執行路由處理器
|
||
route.handler(path)
|
||
this.currentRoute = path
|
||
} else {
|
||
this.handle404()
|
||
}
|
||
}
|
||
|
||
// 查找匹配的路由
|
||
findRoute(path) {
|
||
// 精確匹配
|
||
if (this.routes.has(path)) {
|
||
return this.routes.get(path)
|
||
}
|
||
|
||
// 動態路由匹配 (例如: /vocabulary/:id)
|
||
for (const [pattern, route] of this.routes) {
|
||
const regex = this.pathToRegex(pattern)
|
||
if (regex.test(path)) {
|
||
return route
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// 路徑轉正則表達式
|
||
pathToRegex(path) {
|
||
const regexPath = path
|
||
.replace(/:\w+/g, '([^/]+)') // :id -> ([^/]+)
|
||
.replace(/\*/g, '.*') // * -> .*
|
||
return new RegExp(`^${regexPath}$`)
|
||
}
|
||
|
||
// 檢查用戶認證狀態
|
||
checkAuth() {
|
||
return appState.stores.auth.getState().isAuthenticated
|
||
}
|
||
|
||
// 處理404錯誤
|
||
handle404() {
|
||
document.title = 'Page Not Found - Drama Ling'
|
||
// 顯示404頁面
|
||
}
|
||
}
|
||
|
||
// 路由配置
|
||
export const router = new SimpleRouter()
|
||
|
||
// 註冊路由
|
||
router.addRoute('/', () => import('../pages/home/index.js'), {
|
||
title: 'Drama Ling - AI語言學習'
|
||
})
|
||
|
||
router.addRoute('/login', () => import('../pages/auth/login.js'), {
|
||
title: '登入 - Drama Ling'
|
||
})
|
||
|
||
router.addRoute('/vocabulary', () => import('../pages/vocabulary/index.js'), {
|
||
requiresAuth: true,
|
||
title: '詞彙學習 - Drama Ling'
|
||
})
|
||
|
||
router.addRoute('/vocabulary/:id', (path) => {
|
||
const id = path.split('/').pop()
|
||
import('../pages/vocabulary/detail.js').then(module => {
|
||
module.default.init(id)
|
||
})
|
||
}, {
|
||
requiresAuth: true,
|
||
title: '詞彙詳情 - Drama Ling'
|
||
})
|
||
```
|
||
|
||
### 導航組件
|
||
```javascript
|
||
// components/layout/Navigation.js
|
||
export class Navigation {
|
||
constructor() {
|
||
this.element = null
|
||
this.init()
|
||
}
|
||
|
||
init() {
|
||
this.render()
|
||
this.setupEventListeners()
|
||
}
|
||
|
||
render() {
|
||
this.element = document.createElement('nav')
|
||
this.element.className = 'main-navigation'
|
||
this.element.innerHTML = `
|
||
<div class="nav-container">
|
||
<div class="nav-brand">
|
||
<a href="/" data-navigate>Drama Ling</a>
|
||
</div>
|
||
<ul class="nav-menu">
|
||
<li><a href="/vocabulary" data-navigate>詞彙學習</a></li>
|
||
<li><a href="/dialogue" data-navigate>情境對話</a></li>
|
||
<li><a href="/profile" data-navigate>個人中心</a></li>
|
||
</ul>
|
||
<div class="nav-actions">
|
||
<button id="logout-btn" class="btn btn-outline">登出</button>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// 導航點擊處理
|
||
this.element.addEventListener('click', (e) => {
|
||
const link = e.target.closest('[data-navigate]')
|
||
if (link) {
|
||
e.preventDefault()
|
||
const path = link.getAttribute('href')
|
||
router.navigate(path)
|
||
}
|
||
})
|
||
|
||
// 登出處理
|
||
this.element.querySelector('#logout-btn').addEventListener('click', () => {
|
||
appState.updateStore('auth', (state) => ({
|
||
...state,
|
||
user: null,
|
||
token: null,
|
||
isAuthenticated: false
|
||
}))
|
||
router.navigate('/login')
|
||
})
|
||
}
|
||
|
||
mount(container) {
|
||
container.appendChild(this.element)
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🔌 API服務層架構
|
||
|
||
### HTTP客戶端
|
||
```javascript
|
||
// services/httpClient.js
|
||
class HttpClient {
|
||
constructor() {
|
||
this.baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||
this.timeout = 10000
|
||
}
|
||
|
||
// 通用請求方法
|
||
async request(url, options = {}) {
|
||
const config = {
|
||
method: 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...this.getAuthHeaders(),
|
||
...options.headers
|
||
},
|
||
timeout: this.timeout,
|
||
...options
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${this.baseURL}${url}`, config)
|
||
return await this.handleResponse(response)
|
||
} catch (error) {
|
||
throw this.handleError(error)
|
||
}
|
||
}
|
||
|
||
// 獲取認證標頭
|
||
getAuthHeaders() {
|
||
const token = appState.stores.auth.getState().token
|
||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||
}
|
||
|
||
// 處理回應
|
||
async handleResponse(response) {
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
// 處理認證過期
|
||
appState.updateStore('auth', (state) => ({
|
||
...state,
|
||
user: null,
|
||
token: null,
|
||
isAuthenticated: false
|
||
}))
|
||
router.navigate('/login')
|
||
}
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const contentType = response.headers.get('content-type')
|
||
if (contentType && contentType.includes('application/json')) {
|
||
return await response.json()
|
||
}
|
||
return await response.text()
|
||
}
|
||
|
||
// 錯誤處理
|
||
handleError(error) {
|
||
console.error('API Error:', error)
|
||
return error
|
||
}
|
||
|
||
// 便捷方法
|
||
get(url, params = {}) {
|
||
const queryString = new URLSearchParams(params).toString()
|
||
const fullUrl = queryString ? `${url}?${queryString}` : url
|
||
return this.request(fullUrl)
|
||
}
|
||
|
||
post(url, data = {}) {
|
||
return this.request(url, {
|
||
method: 'POST',
|
||
body: JSON.stringify(data)
|
||
})
|
||
}
|
||
|
||
put(url, data = {}) {
|
||
return this.request(url, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(data)
|
||
})
|
||
}
|
||
|
||
delete(url) {
|
||
return this.request(url, {
|
||
method: 'DELETE'
|
||
})
|
||
}
|
||
}
|
||
|
||
export const httpClient = new HttpClient()
|
||
```
|
||
|
||
### API服務類別
|
||
```javascript
|
||
// services/vocabularyApi.js
|
||
class VocabularyAPI {
|
||
constructor() {
|
||
this.basePath = '/vocabulary'
|
||
}
|
||
|
||
// 獲取詞彙介紹
|
||
async getWordIntroduction(wordId) {
|
||
return await httpClient.get(`${this.basePath}/words/${wordId}`)
|
||
}
|
||
|
||
// 提交練習結果
|
||
async submitPracticeResult(result) {
|
||
return await httpClient.post(`${this.basePath}/practice`, result)
|
||
}
|
||
|
||
// 獲取複習計劃
|
||
async getReviewSchedule() {
|
||
return await httpClient.get(`${this.basePath}/review/schedule`)
|
||
}
|
||
|
||
// 獲取分析數據
|
||
async getAnalytics(timeRange) {
|
||
return await httpClient.get(`${this.basePath}/analytics`, { timeRange })
|
||
}
|
||
}
|
||
|
||
export const vocabularyAPI = new VocabularyAPI()
|
||
```
|
||
|
||
## 🎵 音頻處理整合
|
||
|
||
### Web Audio API封裝
|
||
```javascript
|
||
// modules/audio/audioManager.js
|
||
class AudioManager {
|
||
constructor() {
|
||
this.audioContext = null
|
||
this.currentSource = null
|
||
this.isPlaying = false
|
||
}
|
||
|
||
// 初始化音頻上下文
|
||
async initializeAudioContext() {
|
||
if (!this.audioContext) {
|
||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
||
|
||
// 處理瀏覽器自動播放政策
|
||
if (this.audioContext.state === 'suspended') {
|
||
await this.audioContext.resume()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 播放音頻
|
||
async playAudio(url, playbackRate = 1.0) {
|
||
try {
|
||
await this.initializeAudioContext()
|
||
|
||
// 停止當前播放
|
||
this.stopCurrentAudio()
|
||
|
||
// 載入音頻
|
||
const response = await fetch(url)
|
||
const arrayBuffer = await response.arrayBuffer()
|
||
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
|
||
|
||
// 創建音頻源
|
||
this.currentSource = this.audioContext.createBufferSource()
|
||
this.currentSource.buffer = audioBuffer
|
||
this.currentSource.playbackRate.value = playbackRate
|
||
this.currentSource.connect(this.audioContext.destination)
|
||
|
||
// 播放結束處理
|
||
this.currentSource.onended = () => {
|
||
this.isPlaying = false
|
||
this.currentSource = null
|
||
}
|
||
|
||
// 開始播放
|
||
this.currentSource.start()
|
||
this.isPlaying = true
|
||
|
||
return this.currentSource
|
||
} catch (error) {
|
||
console.error('Audio playback failed:', error)
|
||
this.isPlaying = false
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// 停止當前播放
|
||
stopCurrentAudio() {
|
||
if (this.currentSource && this.isPlaying) {
|
||
this.currentSource.stop()
|
||
this.currentSource = null
|
||
this.isPlaying = false
|
||
}
|
||
}
|
||
|
||
// 錄製音頻
|
||
async startRecording() {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||
const mediaRecorder = new MediaRecorder(stream)
|
||
|
||
const audioChunks = []
|
||
mediaRecorder.ondataavailable = (event) => {
|
||
audioChunks.push(event.data)
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
mediaRecorder.onstop = () => {
|
||
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
|
||
resolve(audioBlob)
|
||
}
|
||
|
||
mediaRecorder.onerror = (event) => {
|
||
reject(event.error)
|
||
}
|
||
|
||
mediaRecorder.start()
|
||
|
||
// 返回停止函數
|
||
setTimeout(() => {
|
||
mediaRecorder.stop()
|
||
stream.getTracks().forEach(track => track.stop())
|
||
}, 5000) // 5秒自動停止
|
||
})
|
||
} catch (error) {
|
||
console.error('Recording failed:', error)
|
||
throw error
|
||
}
|
||
}
|
||
}
|
||
|
||
export const audioManager = new AudioManager()
|
||
```
|
||
|
||
## 🧩 組件系統
|
||
|
||
### Web Components基礎
|
||
```javascript
|
||
// components/ui/BaseComponent.js
|
||
class BaseComponent extends HTMLElement {
|
||
constructor() {
|
||
super()
|
||
this.state = {}
|
||
this.listeners = new Map()
|
||
}
|
||
|
||
// 組件生命週期
|
||
connectedCallback() {
|
||
this.render()
|
||
this.setupEventListeners()
|
||
this.onMounted()
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
this.cleanup()
|
||
this.onUnmounted()
|
||
}
|
||
|
||
// 狀態管理
|
||
setState(newState) {
|
||
this.state = { ...this.state, ...newState }
|
||
this.render()
|
||
}
|
||
|
||
getState() {
|
||
return { ...this.state }
|
||
}
|
||
|
||
// 事件處理
|
||
addEventListener(event, handler) {
|
||
if (!this.listeners.has(event)) {
|
||
this.listeners.set(event, [])
|
||
}
|
||
this.listeners.get(event).push(handler)
|
||
super.addEventListener(event, handler)
|
||
}
|
||
|
||
// 清理資源
|
||
cleanup() {
|
||
for (const [event, handlers] of this.listeners) {
|
||
handlers.forEach(handler => {
|
||
super.removeEventListener(event, handler)
|
||
})
|
||
}
|
||
this.listeners.clear()
|
||
}
|
||
|
||
// 子類需要實現的方法
|
||
render() {
|
||
throw new Error('render method must be implemented')
|
||
}
|
||
|
||
onMounted() {
|
||
// 組件掛載後的邏輯
|
||
}
|
||
|
||
onUnmounted() {
|
||
// 組件卸載前的清理
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// 事件監聽設置
|
||
}
|
||
}
|
||
|
||
export { BaseComponent }
|
||
```
|
||
|
||
### 詞彙卡片組件範例
|
||
```javascript
|
||
// components/business/VocabularyCard.js
|
||
import { BaseComponent } from '../ui/BaseComponent.js'
|
||
|
||
class VocabularyCard extends BaseComponent {
|
||
constructor() {
|
||
super()
|
||
this.state = {
|
||
word: null,
|
||
isFlipped: false,
|
||
isLoading: false
|
||
}
|
||
}
|
||
|
||
static get observedAttributes() {
|
||
return ['word-id', 'show-pronunciation', 'interactive']
|
||
}
|
||
|
||
attributeChangedCallback(name, oldValue, newValue) {
|
||
if (name === 'word-id' && newValue !== oldValue) {
|
||
this.loadWord(newValue)
|
||
}
|
||
}
|
||
|
||
async loadWord(wordId) {
|
||
this.setState({ isLoading: true })
|
||
try {
|
||
const word = await vocabularyAPI.getWordIntroduction(wordId)
|
||
this.setState({ word, isLoading: false })
|
||
} catch (error) {
|
||
console.error('Failed to load word:', error)
|
||
this.setState({ isLoading: false })
|
||
}
|
||
}
|
||
|
||
render() {
|
||
if (this.state.isLoading) {
|
||
this.innerHTML = `
|
||
<div class="vocabulary-card loading">
|
||
<div class="loading-spinner"></div>
|
||
</div>
|
||
`
|
||
return
|
||
}
|
||
|
||
if (!this.state.word) {
|
||
this.innerHTML = `
|
||
<div class="vocabulary-card error">
|
||
<p>無法載入詞彙資料</p>
|
||
</div>
|
||
`
|
||
return
|
||
}
|
||
|
||
const { word } = this.state
|
||
const showPronunciation = this.getAttribute('show-pronunciation') !== 'false'
|
||
const interactive = this.getAttribute('interactive') !== 'false'
|
||
|
||
this.innerHTML = `
|
||
<div class="vocabulary-card ${this.state.isFlipped ? 'flipped' : ''}">
|
||
<div class="card-front">
|
||
<div class="word-header">
|
||
<h3 class="word-text">${word.word}</h3>
|
||
${showPronunciation ? `<span class="pronunciation">${word.pronunciation}</span>` : ''}
|
||
</div>
|
||
${interactive ? `
|
||
<button class="audio-btn" data-audio-url="${word.audio_url}">
|
||
🔊 播放發音
|
||
</button>
|
||
<button class="flip-btn">
|
||
顯示定義
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
|
||
<div class="card-back">
|
||
<div class="definition">
|
||
<h4>定義</h4>
|
||
<p>${word.definition_zh}</p>
|
||
</div>
|
||
<div class="examples">
|
||
<h4>例句</h4>
|
||
${word.examples.map(example => `
|
||
<p class="example">${example.sentence}</p>
|
||
`).join('')}
|
||
</div>
|
||
${interactive ? `
|
||
<button class="flip-btn">
|
||
返回
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
setupEventListeners() {
|
||
this.addEventListener('click', (e) => {
|
||
if (e.target.matches('.flip-btn')) {
|
||
this.setState({ isFlipped: !this.state.isFlipped })
|
||
}
|
||
|
||
if (e.target.matches('.audio-btn')) {
|
||
const audioUrl = e.target.getAttribute('data-audio-url')
|
||
audioManager.playAudio(audioUrl)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 註冊自定義元素
|
||
customElements.define('vocabulary-card', VocabularyCard)
|
||
|
||
export { VocabularyCard }
|
||
```
|
||
|
||
## 🚀 效能優化策略
|
||
|
||
### 載入優化
|
||
```javascript
|
||
// utils/lazyLoader.js
|
||
class LazyLoader {
|
||
constructor() {
|
||
this.observer = new IntersectionObserver(
|
||
this.handleIntersection.bind(this),
|
||
{ threshold: 0.1 }
|
||
)
|
||
}
|
||
|
||
// 延遲載入圖片
|
||
lazyLoadImages() {
|
||
const images = document.querySelectorAll('img[data-src]')
|
||
images.forEach(img => this.observer.observe(img))
|
||
}
|
||
|
||
// 延遲載入組件
|
||
lazyLoadComponent(element, importFunction) {
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
importFunction().then(module => {
|
||
module.default.init(element)
|
||
})
|
||
observer.disconnect()
|
||
}
|
||
})
|
||
},
|
||
{ threshold: 0.1 }
|
||
)
|
||
observer.observe(element)
|
||
}
|
||
|
||
handleIntersection(entries) {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
const img = entry.target
|
||
img.src = img.getAttribute('data-src')
|
||
img.removeAttribute('data-src')
|
||
this.observer.unobserve(img)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
export const lazyLoader = new LazyLoader()
|
||
```
|
||
|
||
### 快取策略
|
||
```javascript
|
||
// utils/cache.js
|
||
class CacheManager {
|
||
constructor() {
|
||
this.memoryCache = new Map()
|
||
this.maxAge = 5 * 60 * 1000 // 5分鐘
|
||
}
|
||
|
||
// 記憶體快取
|
||
setMemoryCache(key, data, maxAge = this.maxAge) {
|
||
this.memoryCache.set(key, {
|
||
data,
|
||
timestamp: Date.now(),
|
||
maxAge
|
||
})
|
||
}
|
||
|
||
getMemoryCache(key) {
|
||
const cached = this.memoryCache.get(key)
|
||
if (!cached) return null
|
||
|
||
if (Date.now() - cached.timestamp > cached.maxAge) {
|
||
this.memoryCache.delete(key)
|
||
return null
|
||
}
|
||
|
||
return cached.data
|
||
}
|
||
|
||
// localStorage快取
|
||
setLocalCache(key, data, maxAge = 24 * 60 * 60 * 1000) {
|
||
const cacheData = {
|
||
data,
|
||
timestamp: Date.now(),
|
||
maxAge
|
||
}
|
||
storage.setLocal(`cache_${key}`, cacheData)
|
||
}
|
||
|
||
getLocalCache(key) {
|
||
const cached = storage.getLocal(`cache_${key}`)
|
||
if (!cached) return null
|
||
|
||
if (Date.now() - cached.timestamp > cached.maxAge) {
|
||
storage.removeLocal(`cache_${key}`)
|
||
return null
|
||
}
|
||
|
||
return cached.data
|
||
}
|
||
}
|
||
|
||
export const cacheManager = new CacheManager()
|
||
```
|
||
|
||
## 🔒 安全性實作
|
||
|
||
### XSS防護
|
||
```javascript
|
||
// utils/security.js
|
||
class SecurityUtils {
|
||
// HTML清理
|
||
sanitizeHTML(html) {
|
||
const temp = document.createElement('div')
|
||
temp.textContent = html
|
||
return temp.innerHTML
|
||
}
|
||
|
||
// 輸入驗證
|
||
validateInput(input, type = 'text') {
|
||
const patterns = {
|
||
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||
phone: /^\+?[\d\s\-\(\)]+$/,
|
||
username: /^[a-zA-Z0-9_]{3,20}$/,
|
||
password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/
|
||
}
|
||
|
||
if (patterns[type]) {
|
||
return patterns[type].test(input)
|
||
}
|
||
|
||
// 基本長度檢查
|
||
return input.length > 0 && input.length < 1000
|
||
}
|
||
|
||
// CSRF Token處理
|
||
async getCSRFToken() {
|
||
try {
|
||
const response = await httpClient.get('/csrf-token')
|
||
return response.token
|
||
} catch (error) {
|
||
console.error('Failed to get CSRF token:', error)
|
||
return null
|
||
}
|
||
}
|
||
}
|
||
|
||
export const security = new SecurityUtils()
|
||
```
|
||
|
||
## 🧪 測試策略
|
||
|
||
### Vitest配置
|
||
```javascript
|
||
// vite.config.js
|
||
import { defineConfig } from 'vite'
|
||
|
||
export default defineConfig({
|
||
test: {
|
||
environment: 'happy-dom',
|
||
globals: true,
|
||
coverage: {
|
||
provider: 'v8',
|
||
reporter: ['text', 'json-summary', 'html'],
|
||
threshold: {
|
||
global: {
|
||
branches: 80,
|
||
functions: 80,
|
||
lines: 80,
|
||
statements: 80
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### 組件測試範例
|
||
```javascript
|
||
// tests/components/VocabularyCard.test.js
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||
import { VocabularyCard } from '../../src/components/business/VocabularyCard.js'
|
||
|
||
describe('VocabularyCard', () => {
|
||
let container
|
||
let vocabularyCard
|
||
|
||
beforeEach(() => {
|
||
// 設置DOM容器
|
||
container = document.createElement('div')
|
||
document.body.appendChild(container)
|
||
|
||
// 創建組件實例
|
||
vocabularyCard = document.createElement('vocabulary-card')
|
||
container.appendChild(vocabularyCard)
|
||
})
|
||
|
||
afterEach(() => {
|
||
// 清理DOM
|
||
document.body.removeChild(container)
|
||
})
|
||
|
||
it('should render word information correctly', () => {
|
||
vocabularyCard.setAttribute('word-id', 'test-word')
|
||
|
||
// 模擬API回應
|
||
vi.mock('../../src/services/vocabularyApi.js', () => ({
|
||
vocabularyAPI: {
|
||
getWordIntroduction: vi.fn().mockResolvedValue({
|
||
word: 'hello',
|
||
pronunciation: '/həˈloʊ/',
|
||
definition_zh: '你好',
|
||
examples: [
|
||
{ sentence: 'Hello, world!' }
|
||
]
|
||
})
|
||
}
|
||
}))
|
||
|
||
// 等待組件渲染
|
||
return new Promise(resolve => {
|
||
setTimeout(() => {
|
||
expect(vocabularyCard.querySelector('.word-text')).toHaveTextContent('hello')
|
||
expect(vocabularyCard.querySelector('.pronunciation')).toHaveTextContent('/həˈloʊ/')
|
||
resolve()
|
||
}, 100)
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
## 📦 建構和部署
|
||
|
||
### Vite建構配置
|
||
```javascript
|
||
// vite.config.js
|
||
import { defineConfig } from 'vite'
|
||
import { VitePWA } from 'vite-plugin-pwa'
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
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'
|
||
}
|
||
]
|
||
}
|
||
})
|
||
],
|
||
|
||
build: {
|
||
rollupOptions: {
|
||
output: {
|
||
manualChunks: {
|
||
vendor: ['vite'],
|
||
utils: ['./src/utils/index.js']
|
||
}
|
||
}
|
||
},
|
||
minify: 'terser',
|
||
terserOptions: {
|
||
compress: {
|
||
drop_console: true,
|
||
drop_debugger: true
|
||
}
|
||
}
|
||
},
|
||
|
||
css: {
|
||
preprocessorOptions: {
|
||
scss: {
|
||
additionalData: `@import "./src/styles/base/variables.scss";`
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
**文檔狀態**: 🟢 已完成原生前端技術架構規劃
|
||
**最後更新**: 2025-09-10
|
||
**負責團隊**: 前端開發團隊
|
||
**下次檢查**: 開發開始前進行技術實施確認 |