663 lines
16 KiB
JavaScript
663 lines
16 KiB
JavaScript
/**
|
|
* Drama Ling - 工具類別和狀態管理
|
|
* 提供應用程序所需的基礎工具函數和狀態管理
|
|
*/
|
|
|
|
/**
|
|
* 應用狀態管理類別
|
|
* 簡潔的響應式狀態管理,不依賴外部框架
|
|
*/
|
|
export class AppState {
|
|
constructor() {
|
|
this.data = {
|
|
user: null,
|
|
isAuthenticated: false,
|
|
vocabulary: [],
|
|
currentSession: null,
|
|
stats: {
|
|
totalWords: 0,
|
|
studyTime: 0,
|
|
achievements: 0,
|
|
streak: 0,
|
|
diamonds: 0
|
|
},
|
|
settings: {
|
|
theme: 'auto',
|
|
language: 'zh-TW',
|
|
soundEnabled: true,
|
|
notificationsEnabled: true
|
|
},
|
|
offline: false
|
|
};
|
|
|
|
this.listeners = new Map();
|
|
|
|
// 從本地存儲恢復狀態
|
|
this.loadFromStorage();
|
|
}
|
|
|
|
/**
|
|
* 添加狀態變化監聽器
|
|
*/
|
|
addListener(event, callback) {
|
|
if (!this.listeners.has(event)) {
|
|
this.listeners.set(event, []);
|
|
}
|
|
this.listeners.get(event).push(callback);
|
|
}
|
|
|
|
/**
|
|
* 移除狀態變化監聽器
|
|
*/
|
|
removeListener(event, callback) {
|
|
if (this.listeners.has(event)) {
|
|
const callbacks = this.listeners.get(event);
|
|
const index = callbacks.indexOf(callback);
|
|
if (index > -1) {
|
|
callbacks.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 觸發狀態變化事件
|
|
*/
|
|
emit(event, data) {
|
|
if (this.listeners.has(event)) {
|
|
this.listeners.get(event).forEach(callback => {
|
|
try {
|
|
callback(data);
|
|
} catch (error) {
|
|
console.error(`狀態事件監聽器錯誤 (${event}):`, error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 設定用戶資料
|
|
*/
|
|
setUser(user) {
|
|
this.data.user = user;
|
|
this.data.isAuthenticated = !!user;
|
|
this.saveToStorage();
|
|
this.emit('userUpdated', user);
|
|
this.emit('authStatusChanged', !!user);
|
|
}
|
|
|
|
/**
|
|
* 獲取用戶資料
|
|
*/
|
|
getUser() {
|
|
return this.data.user;
|
|
}
|
|
|
|
/**
|
|
* 檢查是否已認證
|
|
*/
|
|
isAuthenticated() {
|
|
return this.data.isAuthenticated;
|
|
}
|
|
|
|
/**
|
|
* 更新統計資料
|
|
*/
|
|
updateStats(newStats) {
|
|
this.data.stats = { ...this.data.stats, ...newStats };
|
|
this.saveToStorage();
|
|
this.emit('statsUpdated', this.data.stats);
|
|
}
|
|
|
|
/**
|
|
* 獲取統計資料
|
|
*/
|
|
getStats() {
|
|
return this.data.stats;
|
|
}
|
|
|
|
/**
|
|
* 更新設定
|
|
*/
|
|
updateSettings(newSettings) {
|
|
this.data.settings = { ...this.data.settings, ...newSettings };
|
|
this.saveToStorage();
|
|
this.emit('settingsUpdated', this.data.settings);
|
|
}
|
|
|
|
/**
|
|
* 獲取設定
|
|
*/
|
|
getSettings() {
|
|
return this.data.settings;
|
|
}
|
|
|
|
/**
|
|
* 設定離線狀態
|
|
*/
|
|
setOffline(offline) {
|
|
this.data.offline = offline;
|
|
this.emit('offlineStatusChanged', offline);
|
|
}
|
|
|
|
/**
|
|
* 檢查是否離線
|
|
*/
|
|
isOffline() {
|
|
return this.data.offline;
|
|
}
|
|
|
|
/**
|
|
* 保存狀態到本地存儲
|
|
*/
|
|
saveToStorage() {
|
|
try {
|
|
const stateToSave = {
|
|
user: this.data.user,
|
|
settings: this.data.settings,
|
|
stats: this.data.stats
|
|
};
|
|
localStorage.setItem('drama-ling-state', JSON.stringify(stateToSave));
|
|
} catch (error) {
|
|
console.error('保存狀態失敗:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 從本地存儲載入狀態
|
|
*/
|
|
loadFromStorage() {
|
|
try {
|
|
const savedState = localStorage.getItem('drama-ling-state');
|
|
if (savedState) {
|
|
const parsedState = JSON.parse(savedState);
|
|
this.data.user = parsedState.user;
|
|
this.data.isAuthenticated = !!parsedState.user;
|
|
this.data.settings = { ...this.data.settings, ...parsedState.settings };
|
|
this.data.stats = { ...this.data.stats, ...parsedState.stats };
|
|
}
|
|
} catch (error) {
|
|
console.error('載入狀態失敗:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清除所有狀態
|
|
*/
|
|
clear() {
|
|
this.data = {
|
|
user: null,
|
|
isAuthenticated: false,
|
|
vocabulary: [],
|
|
currentSession: null,
|
|
stats: {
|
|
totalWords: 0,
|
|
studyTime: 0,
|
|
achievements: 0,
|
|
streak: 0,
|
|
diamonds: 0
|
|
},
|
|
settings: {
|
|
theme: 'auto',
|
|
language: 'zh-TW',
|
|
soundEnabled: true,
|
|
notificationsEnabled: true
|
|
},
|
|
offline: false
|
|
};
|
|
|
|
localStorage.removeItem('drama-ling-state');
|
|
this.emit('stateCleared');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 工具函數集合
|
|
*/
|
|
export class Utils {
|
|
/**
|
|
* 防抖函數
|
|
*/
|
|
static debounce(func, wait, immediate = false) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
timeout = null;
|
|
if (!immediate) func.apply(this, args);
|
|
};
|
|
const callNow = immediate && !timeout;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
if (callNow) func.apply(this, args);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 節流函數
|
|
*/
|
|
static throttle(func, limit) {
|
|
let inThrottle;
|
|
return function executedFunction(...args) {
|
|
if (!inThrottle) {
|
|
func.apply(this, args);
|
|
inThrottle = true;
|
|
setTimeout(() => inThrottle = false, limit);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 深度複製對象
|
|
*/
|
|
static deepClone(obj) {
|
|
if (obj === null || typeof obj !== 'object') {
|
|
return obj;
|
|
}
|
|
|
|
if (obj instanceof Date) {
|
|
return new Date(obj.getTime());
|
|
}
|
|
|
|
if (obj instanceof Array) {
|
|
return obj.map(item => Utils.deepClone(item));
|
|
}
|
|
|
|
if (typeof obj === 'object') {
|
|
const clonedObj = {};
|
|
for (const key in obj) {
|
|
if (obj.hasOwnProperty(key)) {
|
|
clonedObj[key] = Utils.deepClone(obj[key]);
|
|
}
|
|
}
|
|
return clonedObj;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 格式化時間
|
|
*/
|
|
static formatTime(seconds) {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
} else {
|
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 格式化數字
|
|
*/
|
|
static formatNumber(num) {
|
|
if (num >= 1000000) {
|
|
return (num / 1000000).toFixed(1) + 'M';
|
|
} else if (num >= 1000) {
|
|
return (num / 1000).toFixed(1) + 'K';
|
|
}
|
|
return num.toString();
|
|
}
|
|
|
|
/**
|
|
* 格式化日期
|
|
*/
|
|
static formatDate(date, locale = 'zh-TW') {
|
|
if (!(date instanceof Date)) {
|
|
date = new Date(date);
|
|
}
|
|
|
|
return date.toLocaleDateString(locale, {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 獲取相對時間
|
|
*/
|
|
static getRelativeTime(date, locale = 'zh-TW') {
|
|
const now = new Date();
|
|
const targetDate = new Date(date);
|
|
const diffInSeconds = Math.floor((now - targetDate) / 1000);
|
|
|
|
const intervals = {
|
|
year: 31536000,
|
|
month: 2592000,
|
|
week: 604800,
|
|
day: 86400,
|
|
hour: 3600,
|
|
minute: 60
|
|
};
|
|
|
|
for (const [unit, seconds] of Object.entries(intervals)) {
|
|
const interval = Math.floor(diffInSeconds / seconds);
|
|
if (interval >= 1) {
|
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
return rtf.format(-interval, unit);
|
|
}
|
|
}
|
|
|
|
return '剛剛';
|
|
}
|
|
|
|
/**
|
|
* 產生唯一 ID
|
|
*/
|
|
static generateId() {
|
|
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
|
}
|
|
|
|
/**
|
|
* 驗證電子郵件格式
|
|
*/
|
|
static validateEmail(email) {
|
|
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return re.test(email);
|
|
}
|
|
|
|
/**
|
|
* 驗證密碼強度
|
|
*/
|
|
static validatePassword(password) {
|
|
const minLength = 8;
|
|
const hasUpperCase = /[A-Z]/.test(password);
|
|
const hasLowerCase = /[a-z]/.test(password);
|
|
const hasNumbers = /\d/.test(password);
|
|
const hasNonalphas = /\W/.test(password);
|
|
|
|
return {
|
|
isValid: password.length >= minLength && hasUpperCase && hasLowerCase && hasNumbers,
|
|
strength: [hasUpperCase, hasLowerCase, hasNumbers, hasNonalphas].filter(Boolean).length,
|
|
minLength: password.length >= minLength,
|
|
hasUpperCase,
|
|
hasLowerCase,
|
|
hasNumbers,
|
|
hasSpecialChars: hasNonalphas
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 本地存儲輔助函數
|
|
*/
|
|
static storage = {
|
|
set(key, value) {
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|
return true;
|
|
} catch (error) {
|
|
console.error('本地存儲設置失敗:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
get(key, defaultValue = null) {
|
|
try {
|
|
const item = localStorage.getItem(key);
|
|
return item ? JSON.parse(item) : defaultValue;
|
|
} catch (error) {
|
|
console.error('本地存儲讀取失敗:', error);
|
|
return defaultValue;
|
|
}
|
|
},
|
|
|
|
remove(key) {
|
|
try {
|
|
localStorage.removeItem(key);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('本地存儲刪除失敗:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
clear() {
|
|
try {
|
|
localStorage.clear();
|
|
return true;
|
|
} catch (error) {
|
|
console.error('本地存儲清除失敗:', error);
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* URL 參數處理
|
|
*/
|
|
static url = {
|
|
getParams() {
|
|
return new URLSearchParams(window.location.search);
|
|
},
|
|
|
|
getParam(key, defaultValue = null) {
|
|
const params = new URLSearchParams(window.location.search);
|
|
return params.get(key) || defaultValue;
|
|
},
|
|
|
|
setParam(key, value) {
|
|
const url = new URL(window.location);
|
|
url.searchParams.set(key, value);
|
|
window.history.pushState({}, '', url);
|
|
},
|
|
|
|
removeParam(key) {
|
|
const url = new URL(window.location);
|
|
url.searchParams.delete(key);
|
|
window.history.pushState({}, '', url);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* DOM 輔助函數
|
|
*/
|
|
static dom = {
|
|
/**
|
|
* 創建元素
|
|
*/
|
|
create(tagName, attributes = {}, textContent = '') {
|
|
const element = document.createElement(tagName);
|
|
|
|
for (const [key, value] of Object.entries(attributes)) {
|
|
if (key === 'className') {
|
|
element.className = value;
|
|
} else if (key.startsWith('data-')) {
|
|
element.setAttribute(key, value);
|
|
} else {
|
|
element[key] = value;
|
|
}
|
|
}
|
|
|
|
if (textContent) {
|
|
element.textContent = textContent;
|
|
}
|
|
|
|
return element;
|
|
},
|
|
|
|
/**
|
|
* 查詢元素
|
|
*/
|
|
$(selector, context = document) {
|
|
return context.querySelector(selector);
|
|
},
|
|
|
|
/**
|
|
* 查詢所有元素
|
|
*/
|
|
$$(selector, context = document) {
|
|
return Array.from(context.querySelectorAll(selector));
|
|
},
|
|
|
|
/**
|
|
* 添加事件監聽器
|
|
*/
|
|
on(element, event, handler, options = false) {
|
|
element.addEventListener(event, handler, options);
|
|
},
|
|
|
|
/**
|
|
* 移除事件監聽器
|
|
*/
|
|
off(element, event, handler, options = false) {
|
|
element.removeEventListener(event, handler, options);
|
|
},
|
|
|
|
/**
|
|
* 添加類別
|
|
*/
|
|
addClass(element, className) {
|
|
element.classList.add(className);
|
|
},
|
|
|
|
/**
|
|
* 移除類別
|
|
*/
|
|
removeClass(element, className) {
|
|
element.classList.remove(className);
|
|
},
|
|
|
|
/**
|
|
* 切換類別
|
|
*/
|
|
toggleClass(element, className) {
|
|
element.classList.toggle(className);
|
|
},
|
|
|
|
/**
|
|
* 檢查是否有類別
|
|
*/
|
|
hasClass(element, className) {
|
|
return element.classList.contains(className);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 數學輔助函數
|
|
*/
|
|
static math = {
|
|
/**
|
|
* 限制數值範圍
|
|
*/
|
|
clamp(value, min, max) {
|
|
return Math.min(Math.max(value, min), max);
|
|
},
|
|
|
|
/**
|
|
* 線性插值
|
|
*/
|
|
lerp(start, end, factor) {
|
|
return start + (end - start) * factor;
|
|
},
|
|
|
|
/**
|
|
* 隨機整數
|
|
*/
|
|
randomInt(min, max) {
|
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
},
|
|
|
|
/**
|
|
* 隨機浮點數
|
|
*/
|
|
randomFloat(min, max) {
|
|
return Math.random() * (max - min) + min;
|
|
},
|
|
|
|
/**
|
|
* 百分比計算
|
|
*/
|
|
percentage(value, total) {
|
|
return total > 0 ? (value / total) * 100 : 0;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 顏色輔助函數
|
|
*/
|
|
static color = {
|
|
/**
|
|
* 十六進制轉 RGB
|
|
*/
|
|
hexToRgb(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ? {
|
|
r: parseInt(result[1], 16),
|
|
g: parseInt(result[2], 16),
|
|
b: parseInt(result[3], 16)
|
|
} : null;
|
|
},
|
|
|
|
/**
|
|
* RGB 轉十六進制
|
|
*/
|
|
rgbToHex(r, g, b) {
|
|
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
|
},
|
|
|
|
/**
|
|
* 生成隨機顏色
|
|
*/
|
|
random() {
|
|
return '#' + Math.floor(Math.random() * 16777215).toString(16);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 事件匯流排
|
|
* 用於組件間通信
|
|
*/
|
|
export class EventBus {
|
|
constructor() {
|
|
this.events = {};
|
|
}
|
|
|
|
/**
|
|
* 監聽事件
|
|
*/
|
|
on(event, callback) {
|
|
if (!this.events[event]) {
|
|
this.events[event] = [];
|
|
}
|
|
this.events[event].push(callback);
|
|
}
|
|
|
|
/**
|
|
* 觸發事件
|
|
*/
|
|
emit(event, data) {
|
|
if (this.events[event]) {
|
|
this.events[event].forEach(callback => {
|
|
try {
|
|
callback(data);
|
|
} catch (error) {
|
|
console.error(`事件處理器錯誤 (${event}):`, error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 移除事件監聽器
|
|
*/
|
|
off(event, callback) {
|
|
if (this.events[event]) {
|
|
const index = this.events[event].indexOf(callback);
|
|
if (index > -1) {
|
|
this.events[event].splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清除所有事件監聽器
|
|
*/
|
|
clear() {
|
|
this.events = {};
|
|
}
|
|
}
|
|
|
|
// 創建全域事件匯流排實例
|
|
export const eventBus = new EventBus(); |