新增資料庫架構

This commit is contained in:
2025-07-19 02:12:37 +08:00
parent e3832acfa8
commit 924f03c3d7
45 changed files with 12858 additions and 324 deletions

View File

@@ -0,0 +1,249 @@
import { UserSettingsService } from "./supabase-service"
// 背景音樂管理系統 - Supabase 版本
class BackgroundMusicManagerSupabase {
private audio: HTMLAudioElement | null = null
private isPlaying = false
private enabled = false
private volume = 0.3
private fadeInterval: NodeJS.Timeout | null = null
private initialized = false
constructor() {
this.initAudio()
}
// 初始化並載入用戶設定
async init() {
if (this.initialized) return
try {
const settings = await UserSettingsService.getUserSettings()
if (settings) {
this.volume = settings.background_music_volume
this.enabled = settings.background_music_enabled
this.isPlaying = false // 不自動播放
}
this.initialized = true
} catch (error) {
console.error("Failed to load user settings:", error)
// 使用默認設定
this.volume = 0.3
this.enabled = false
this.isPlaying = false
this.initialized = true
}
}
private initAudio() {
try {
this.audio = new Audio("https://hebbkx1anhila5yf.public.blob.vercel-storage.com/just-relax-11157-iAgp15dV2YGybAezUJFtKmKZPbteXd.mp3")
this.audio.loop = true
this.audio.volume = this.volume
this.audio.preload = "metadata"
this.audio.autoplay = false
this.audio.muted = false
this.audio.addEventListener("canplaythrough", () => {
// 音樂載入完成
})
this.audio.addEventListener("error", (e) => {
this.reinitAudio()
})
this.audio.addEventListener("ended", () => {
if (this.enabled && this.isPlaying) {
this.audio?.play().catch(() => {
this.reinitAudio()
})
}
})
} catch (error) {
console.error("Audio initialization failed:", error)
}
}
private reinitAudio() {
try {
if (this.audio) {
this.audio.pause()
this.audio.src = ""
this.audio = null
}
setTimeout(() => {
this.initAudio()
}, 100)
} catch (error) {
console.error("Audio reinitialization failed:", error)
}
}
private fadeIn(duration = 2000) {
if (!this.audio) return
this.audio.volume = 0
const targetVolume = this.volume
const steps = 50
const stepTime = duration / steps
const volumeStep = targetVolume / steps
let currentStep = 0
this.fadeInterval = setInterval(() => {
if (currentStep >= steps || !this.audio) {
if (this.fadeInterval) {
clearInterval(this.fadeInterval)
this.fadeInterval = null
}
if (this.audio) {
this.audio.volume = targetVolume
}
return
}
this.audio.volume = Math.min(volumeStep * currentStep, targetVolume)
currentStep++
}, stepTime)
}
private fadeOut(duration = 1000) {
if (!this.audio) return
const startVolume = this.audio.volume
const steps = 50
const stepTime = duration / steps
const volumeStep = startVolume / steps
let currentStep = 0
this.fadeInterval = setInterval(() => {
if (currentStep >= steps || !this.audio) {
if (this.fadeInterval) {
clearInterval(this.fadeInterval)
this.fadeInterval = null
}
if (this.audio) {
this.audio.pause()
this.audio.currentTime = 0
this.audio.volume = this.volume
}
return
}
this.audio.volume = Math.max(startVolume - volumeStep * currentStep, 0)
currentStep++
}, stepTime)
}
async start() {
if (!this.initialized) await this.init()
if (this.fadeInterval) {
clearInterval(this.fadeInterval)
this.fadeInterval = null
}
if (!this.audio) {
this.initAudio()
await new Promise((resolve) => setTimeout(resolve, 100))
}
if (!this.audio) return
try {
this.enabled = true
this.isPlaying = true
// 保存設定到 Supabase
await this.saveSettings()
this.audio.currentTime = 0
this.audio.volume = 0
await this.audio.play()
this.fadeIn(2000)
} catch (error) {
console.error("Failed to start music:", error)
this.reinitAudio()
this.isPlaying = false
this.enabled = false
await this.saveSettings()
}
}
async stop() {
if (!this.initialized) await this.init()
if (!this.audio) return
this.enabled = false
this.isPlaying = false
// 保存設定到 Supabase
await this.saveSettings()
if (this.fadeInterval) {
clearInterval(this.fadeInterval)
this.fadeInterval = null
}
this.fadeOut(1000)
}
async setVolume(volume: number) {
if (!this.initialized) await this.init()
this.volume = Math.max(0, Math.min(1, volume))
if (this.audio && this.isPlaying) {
this.audio.volume = this.volume
}
// 保存設定到 Supabase
await this.saveSettings()
}
// 保存設定到 Supabase
private async saveSettings() {
try {
await UserSettingsService.updateUserSettings({
backgroundMusicEnabled: this.enabled,
backgroundMusicVolume: this.volume,
backgroundMusicPlaying: this.isPlaying,
})
} catch (error) {
console.error("Failed to save music settings:", error)
}
}
getVolume() {
return this.volume
}
isEnabled() {
return this.enabled
}
getIsPlaying() {
return this.isPlaying && this.audio && !this.audio.paused
}
getState() {
return {
isPlaying: this.getIsPlaying(),
enabled: this.enabled,
volume: this.volume,
}
}
getMusicInfo() {
if (!this.audio) return null
return {
duration: this.audio.duration || 0,
currentTime: this.audio.currentTime || 0,
loaded: this.audio.readyState >= 3,
}
}
}
// 全局背景音樂管理器 - Supabase 版本
export const backgroundMusicManagerSupabase = new BackgroundMusicManagerSupabase()

View File

@@ -49,9 +49,9 @@ export const categories = [
"報告",
"圖表",
],
color: "#EC4899",
bgColor: "from-pink-500/20 to-rose-600/20",
borderColor: "border-pink-400/30",
color: "#DB2777",
bgColor: "from-pink-600/20 to-rose-700/20",
borderColor: "border-pink-500/30",
textColor: "text-pink-200",
icon: "📊",
},
@@ -322,10 +322,10 @@ export function categorizeWishMultiple(wish: Wish): Category[] {
name: "其他問題",
description: "未能歸類的特殊工作困擾",
keywords: [],
color: "#6B7280",
bgColor: "from-gray-500/20 to-slate-600/20",
borderColor: "border-gray-400/30",
textColor: "text-gray-200",
color: "#94A3B8",
bgColor: "from-slate-400/20 to-slate-500/20",
borderColor: "border-slate-400/40",
textColor: "text-slate-200",
icon: "❓",
},
]

249
lib/content-moderation.ts Normal file
View File

@@ -0,0 +1,249 @@
// 內容審核 AI 系統
export interface ModerationResult {
isAppropriate: boolean
issues: string[]
suggestions: string[]
severity: "low" | "medium" | "high"
blockedWords: string[]
}
// 不雅詞彙和辱罵詞彙庫
const inappropriateWords = [
// 髒話和不雅詞彙
"幹",
"靠",
"操",
"媽的",
"他媽的",
"去死",
"死",
"滾",
"白痴",
"智障",
"腦殘",
"垃圾",
"廢物",
"混蛋",
"王八蛋",
"狗屎",
"屎",
"婊子",
"賤",
"爛",
"鳥",
"屌",
"雞掰",
"機掰",
"北七",
// 公司辱罵相關
"爛公司",
"垃圾公司",
"黑心公司",
"慣老闆",
"奴隸主",
"血汗工廠",
"剝削",
"壓榨",
"去你的",
"見鬼",
"該死",
"要死",
"找死",
"活該",
"報應",
"天殺的",
// 威脅性詞彙
"殺",
"打死",
"弄死",
"搞死",
"整死",
"報復",
"復仇",
"毀掉",
"搞垮",
// 歧視性詞彙
"歧視",
"種族",
"性別歧視",
"老不死",
"死老頭",
"死老太婆",
"殘廢",
"瘸子",
// 英文不雅詞彙
"fuck",
"shit",
"damn",
"bitch",
"asshole",
"bastard",
"crap",
"hell",
"wtf",
"stfu",
"bullshit",
"motherfucker",
"dickhead",
"piss",
]
// 負面但可接受的詞彙(會給予建議但不阻擋)
const negativeButAcceptableWords = [
"討厭",
"煩",
"累",
"辛苦",
"困難",
"挫折",
"失望",
"無奈",
"痛苦",
"壓力",
"不滿",
"抱怨",
"不爽",
"生氣",
"憤怒",
"沮喪",
"絕望",
"疲憊",
"厭倦",
]
// 建設性詞彙建議
const constructiveSuggestions = [
"建議使用更具體的描述來說明遇到的困難",
"可以嘗試描述期望的改善方向",
"分享具體的情況會更有助於找到解決方案",
"描述問題的影響程度會幫助我們更好地理解",
"可以說明這個問題對工作效率的具體影響",
]
export function moderateContent(content: string): ModerationResult {
const fullText = content.toLowerCase()
const issues: string[] = []
const suggestions: string[] = []
const blockedWords: string[] = []
let severity: "low" | "medium" | "high" = "low"
// 檢查不雅詞彙
inappropriateWords.forEach((word) => {
if (fullText.includes(word.toLowerCase())) {
blockedWords.push(word)
issues.push(`包含不適當詞彙: "${word}"`)
}
})
// 檢查負面但可接受的詞彙
const negativeWordCount = negativeButAcceptableWords.filter((word) => fullText.includes(word.toLowerCase())).length
// 判斷嚴重程度
if (blockedWords.length > 0) {
severity = "high"
issues.push("內容包含不雅或辱罵詞彙,無法提交")
suggestions.push("請使用更專業和建設性的語言描述遇到的困難")
suggestions.push("我們理解工作中的挫折,但希望能以正面的方式表達")
} else if (negativeWordCount > 3) {
severity = "medium"
issues.push("內容情緒較為負面")
suggestions.push("建議加入一些具體的改善建議或期望")
suggestions.push("描述具體情況會比情緒性詞彙更有幫助")
} else if (negativeWordCount > 1) {
severity = "low"
suggestions.push("可以嘗試更具體地描述遇到的挑戰")
}
// 內容長度檢查
if (content.trim().length < 10) {
issues.push("內容過於簡短,請提供更詳細的描述")
severity = severity === "low" ? "medium" : severity
}
// 重複字符檢查(可能是情緒性表達)
const repeatedChars = content.match(/(.)\1{4,}/g)
if (repeatedChars) {
issues.push("請避免使用過多重複字符")
suggestions.push("建議使用清楚的文字描述來表達感受")
}
// 全大寫檢查(可能是憤怒表達)
const upperCaseRatio = (content.match(/[A-Z]/g) || []).length / content.length
if (upperCaseRatio > 0.5 && content.length > 20) {
issues.push("請避免使用過多大寫字母")
suggestions.push("正常的大小寫會讓內容更容易閱讀")
}
// 如果沒有具體建議,添加通用建議
if (suggestions.length === 0 && severity !== "high") {
suggestions.push(...constructiveSuggestions.slice(0, 2))
}
return {
isAppropriate: blockedWords.length === 0,
issues,
suggestions,
severity,
blockedWords,
}
}
// 檢查整個表單內容
export function moderateWishForm(formData: {
title: string
currentPain: string
expectedSolution: string
expectedEffect: string
}): ModerationResult {
const allContent = `${formData.title} ${formData.currentPain} ${formData.expectedSolution} ${formData.expectedEffect}`
const result = moderateContent(allContent)
// 針對不同欄位給出具體建議
const fieldSpecificSuggestions: string[] = []
if (formData.title.length < 5) {
fieldSpecificSuggestions.push('標題建議更具體一些,例如:"資料整理效率低下" 而非 "很煩"')
}
if (formData.currentPain.length < 20) {
fieldSpecificSuggestions.push("困擾描述可以更詳細,包括具體情況和影響")
}
if (formData.expectedSolution.length < 15) {
fieldSpecificSuggestions.push("期望解決方式可以更具體,這有助於我們提供更好的建議")
}
return {
...result,
suggestions: [...result.suggestions, ...fieldSpecificSuggestions],
}
}
// 提供正面的表達建議
export function getSuggestedPhrases(originalText: string): string[] {
const suggestions: string[] = []
// 根據內容提供建議
if (originalText.includes("很煩") || originalText.includes("討厭")) {
suggestions.push('可以說:"這個流程讓我感到困擾,希望能夠簡化"')
}
if (originalText.includes("爛") || originalText.includes("垃圾")) {
suggestions.push('可以說:"這個系統存在一些問題,影響了工作效率"')
}
if (originalText.includes("老闆") && (originalText.includes("討厌") || originalText.includes("爛"))) {
suggestions.push('可以說:"希望能與主管有更好的溝通和協作"')
}
if (originalText.includes("同事")) {
suggestions.push('可以說:"團隊協作方面遇到一些挑戰"')
}
return suggestions
}

60
lib/email-validation.ts Normal file
View File

@@ -0,0 +1,60 @@
// Email 驗證工具
export function isValidEmail(email: string): boolean {
if (!email) return true // 空值是允許的(可選欄位)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export function sanitizeEmail(email: string): string {
return email.trim().toLowerCase()
}
export function getEmailDomain(email: string): string {
const parts = email.split("@")
return parts.length > 1 ? parts[1] : ""
}
// 檢查是否為常見的臨時郵箱服務
const temporaryEmailDomains = [
"10minutemail.com",
"guerrillamail.com",
"mailinator.com",
"tempmail.org",
"throwaway.email",
]
export function isTemporaryEmail(email: string): boolean {
if (!email) return false
const domain = getEmailDomain(email)
return temporaryEmailDomains.includes(domain)
}
export function validateEmailForSubmission(email: string): {
isValid: boolean
message?: string
suggestion?: string
} {
if (!email) {
return { isValid: true } // 空值允許
}
if (!isValidEmail(email)) {
return {
isValid: false,
message: "請輸入有效的 Email 格式",
suggestion: "例如your.name@company.com",
}
}
if (isTemporaryEmail(email)) {
return {
isValid: false,
message: "請避免使用臨時郵箱服務",
suggestion: "建議使用常用的 Email 地址,以便我們能夠聯繫到你",
}
}
return { isValid: true }
}

190
lib/image-utils.ts Normal file
View File

@@ -0,0 +1,190 @@
// 圖片處理工具
export interface ImageFile {
id: string
file?: File // 可選,因為從 localStorage 恢復時不會有原始 File
url: string // 改為 base64 URL
name: string
size: number
type: string
base64?: string // 新增 base64 字段
}
export interface ImageValidationResult {
isValid: boolean
error?: string
suggestion?: string
}
// 允許的圖片格式
export const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]
// 允許的檔案副檔名
export const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif"]
// 檔案大小限制 (5MB)
export const MAX_FILE_SIZE = 5 * 1024 * 1024
// 單次上傳數量限制
export const MAX_FILES_PER_UPLOAD = 10
// 總檔案數量限制
export const MAX_TOTAL_FILES = 20
export function validateImageFile(file: File): ImageValidationResult {
// 檢查檔案類型
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
return {
isValid: false,
error: `不支援的檔案格式: ${file.type}`,
suggestion: `請使用 JPG、PNG、WebP 或 GIF 格式`,
}
}
// 檢查檔案大小
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1)
return {
isValid: false,
error: `檔案過大: ${sizeMB}MB`,
suggestion: `請壓縮圖片至 5MB 以下`,
}
}
// 檢查檔案名稱
if (file.name.length > 100) {
return {
isValid: false,
error: "檔案名稱過長",
suggestion: "請使用較短的檔案名稱",
}
}
return { isValid: true }
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]
}
// 將 File 轉換為 base64
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result)
} else {
reject(new Error("Failed to convert file to base64"))
}
}
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}
// 創建圖片文件對象(使用 base64
export async function createImageFile(file: File): Promise<ImageFile> {
const base64 = await fileToBase64(file)
return {
id: `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
file,
url: base64, // 直接使用 base64 作為 URL
name: file.name,
size: file.size,
type: file.type,
base64,
}
}
// 從儲存的數據恢復圖片對象
export function restoreImageFile(data: any): ImageFile {
return {
id: data.id,
url: data.base64 || data.url, // 優先使用 base64向後兼容
name: data.name,
size: data.size,
type: data.type,
base64: data.base64,
}
}
// 不再需要 revokeImageUrl因為使用 base64
export function revokeImageUrl(imageFile: ImageFile): void {
// base64 不需要手動釋放
return
}
// 壓縮圖片並轉為 base64
export function compressImage(file: File, maxWidth = 1920, quality = 0.8): Promise<File> {
return new Promise((resolve) => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const img = new Image()
img.onload = () => {
// 計算新尺寸
let { width, height } = img
if (width > maxWidth) {
height = (height * maxWidth) / width
width = maxWidth
}
canvas.width = width
canvas.height = height
// 繪製壓縮後的圖片
ctx?.drawImage(img, 0, 0, width, height)
canvas.toBlob(
(blob) => {
if (blob) {
const compressedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now(),
})
resolve(compressedFile)
} else {
resolve(file) // 如果壓縮失敗,返回原檔案
}
},
file.type,
quality,
)
}
img.src = URL.createObjectURL(file)
})
}
// 生成縮圖
export function generateThumbnail(file: File, size = 200): Promise<string> {
return new Promise((resolve) => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const img = new Image()
img.onload = () => {
canvas.width = size
canvas.height = size
// 計算裁切區域 (正方形縮圖)
const minDimension = Math.min(img.width, img.height)
const x = (img.width - minDimension) / 2
const y = (img.height - minDimension) / 2
ctx?.drawImage(img, x, y, minDimension, minDimension, 0, 0, size, size)
resolve(canvas.toDataURL(file.type, 0.7))
}
img.src = URL.createObjectURL(file)
})
}

330
lib/supabase-image-utils.ts Normal file
View File

@@ -0,0 +1,330 @@
import { supabase } from "./supabase"
// Supabase 圖片相關的類型定義
export interface SupabaseImageFile {
id: string
name: string
size: number
type: string
storage_path: string // Supabase Storage 中的路徑
public_url: string // 公開訪問 URL
uploaded_at: string
}
export interface ImageUploadResult {
success: boolean
data?: SupabaseImageFile
error?: string
}
export interface BatchUploadResult {
successful: SupabaseImageFile[]
failed: Array<{ file: File; error: string }>
total: number
}
// 圖片上傳服務
export class SupabaseImageService {
private static readonly BUCKET_NAME = "wish-images"
private static readonly MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
private static readonly ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]
// 驗證圖片文件
static validateImageFile(file: File): { isValid: boolean; error?: string } {
if (!this.ALLOWED_TYPES.includes(file.type)) {
return {
isValid: false,
error: `不支援的檔案格式: ${file.type}。請使用 JPG、PNG、WebP 或 GIF 格式。`,
}
}
if (file.size > this.MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1)
return {
isValid: false,
error: `檔案過大: ${sizeMB}MB。請壓縮圖片至 5MB 以下。`,
}
}
return { isValid: true }
}
// 生成唯一的檔案路徑
static generateFilePath(file: File): string {
const timestamp = Date.now()
const randomId = Math.random().toString(36).substring(2, 15)
const extension = file.name.split(".").pop()?.toLowerCase() || "jpg"
return `${timestamp}_${randomId}.${extension}`
}
// 上傳單個圖片到 Supabase Storage
static async uploadImage(file: File): Promise<ImageUploadResult> {
try {
// 驗證檔案
const validation = this.validateImageFile(file)
if (!validation.isValid) {
return { success: false, error: validation.error }
}
// 生成檔案路徑
const filePath = this.generateFilePath(file)
// 上傳到 Supabase Storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from(this.BUCKET_NAME)
.upload(filePath, file, {
cacheControl: "3600",
upsert: false,
})
if (uploadError) {
console.error("Upload error:", uploadError)
return { success: false, error: `上傳失敗: ${uploadError.message}` }
}
// 獲取公開 URL
const { data: urlData } = supabase.storage.from(this.BUCKET_NAME).getPublicUrl(filePath)
if (!urlData.publicUrl) {
return { success: false, error: "無法獲取圖片 URL" }
}
// 創建圖片記錄
const imageFile: SupabaseImageFile = {
id: `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
size: file.size,
type: file.type,
storage_path: filePath,
public_url: urlData.publicUrl,
uploaded_at: new Date().toISOString(),
}
return { success: true, data: imageFile }
} catch (error) {
console.error("Image upload error:", error)
return { success: false, error: `上傳過程中發生錯誤: ${error}` }
}
}
// 批量上傳圖片
static async uploadImages(files: File[]): Promise<BatchUploadResult> {
const result: BatchUploadResult = {
successful: [],
failed: [],
total: files.length,
}
// 並行上傳所有圖片
const uploadPromises = files.map(async (file) => {
const uploadResult = await this.uploadImage(file)
if (uploadResult.success && uploadResult.data) {
result.successful.push(uploadResult.data)
} else {
result.failed.push({
file,
error: uploadResult.error || "未知錯誤",
})
}
})
await Promise.all(uploadPromises)
return result
}
// 刪除圖片
static async deleteImage(storagePath: string): Promise<{ success: boolean; error?: string }> {
try {
const { error } = await supabase.storage.from(this.BUCKET_NAME).remove([storagePath])
if (error) {
console.error("Delete error:", error)
return { success: false, error: `刪除失敗: ${error.message}` }
}
return { success: true }
} catch (error) {
console.error("Image delete error:", error)
return { success: false, error: `刪除過程中發生錯誤: ${error}` }
}
}
// 批量刪除圖片
static async deleteImages(storagePaths: string[]): Promise<{ success: boolean; error?: string }> {
try {
const { error } = await supabase.storage.from(this.BUCKET_NAME).remove(storagePaths)
if (error) {
console.error("Batch delete error:", error)
return { success: false, error: `批量刪除失敗: ${error.message}` }
}
return { success: true }
} catch (error) {
console.error("Batch delete error:", error)
return { success: false, error: `批量刪除過程中發生錯誤: ${error}` }
}
}
// 獲取圖片的公開 URL
static getPublicUrl(storagePath: string): string {
const { data } = supabase.storage.from(this.BUCKET_NAME).getPublicUrl(storagePath)
return data.publicUrl
}
// 檢查存儲桶是否存在並可訪問
static async checkStorageHealth(): Promise<{ healthy: boolean; error?: string }> {
try {
const { data, error } = await supabase.storage.from(this.BUCKET_NAME).list("", { limit: 1 })
if (error) {
return { healthy: false, error: `存儲檢查失敗: ${error.message}` }
}
return { healthy: true }
} catch (error) {
return { healthy: false, error: `存儲檢查過程中發生錯誤: ${error}` }
}
}
// 獲取存儲使用統計
static async getStorageStats(): Promise<{
totalFiles: number
totalSize: number
error?: string
}> {
try {
const { data, error } = await supabase.storage.from(this.BUCKET_NAME).list("", { limit: 1000 })
if (error) {
return { totalFiles: 0, totalSize: 0, error: error.message }
}
const totalFiles = data?.length || 0
const totalSize = data?.reduce((sum, file) => sum + (file.metadata?.size || 0), 0) || 0
return { totalFiles, totalSize }
} catch (error) {
return { totalFiles: 0, totalSize: 0, error: `獲取統計失敗: ${error}` }
}
}
}
// 圖片壓縮工具(在上傳前使用)
export class ImageCompressionService {
// 壓縮圖片
static async compressImage(file: File, maxWidth = 1920, quality = 0.8): Promise<File> {
return new Promise((resolve) => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const img = new Image()
img.onload = () => {
// 計算新尺寸
let { width, height } = img
if (width > maxWidth) {
height = (height * maxWidth) / width
width = maxWidth
}
canvas.width = width
canvas.height = height
// 繪製壓縮後的圖片
ctx?.drawImage(img, 0, 0, width, height)
canvas.toBlob(
(blob) => {
if (blob) {
const compressedFile = new File([blob], file.name, {
type: file.type,
lastModified: Date.now(),
})
resolve(compressedFile)
} else {
resolve(file) // 如果壓縮失敗,返回原檔案
}
},
file.type,
quality,
)
}
img.onerror = () => resolve(file) // 如果載入失敗,返回原檔案
img.src = URL.createObjectURL(file)
})
}
// 批量壓縮圖片
static async compressImages(files: File[]): Promise<File[]> {
const compressionPromises = files.map((file) => {
// 如果檔案小於 1MB不需要壓縮
if (file.size < 1024 * 1024) {
return Promise.resolve(file)
}
return this.compressImage(file, 1920, 0.8)
})
return Promise.all(compressionPromises)
}
}
// 從舊的 base64 格式遷移到 Supabase Storage
export class ImageMigrationService {
// 將 base64 圖片遷移到 Supabase Storage
static async migrateBase64ToStorage(base64Data: string, fileName: string): Promise<ImageUploadResult> {
try {
// 將 base64 轉換為 Blob
const response = await fetch(base64Data)
const blob = await response.blob()
// 創建 File 對象
const file = new File([blob], fileName, { type: blob.type })
// 上傳到 Supabase Storage
return await SupabaseImageService.uploadImage(file)
} catch (error) {
console.error("Base64 migration error:", error)
return { success: false, error: `遷移失敗: ${error}` }
}
}
// 批量遷移圖片
static async migrateImagesFromWish(wishImages: any[]): Promise<{
successful: SupabaseImageFile[]
failed: Array<{ originalImage: any; error: string }>
}> {
const result = {
successful: [] as SupabaseImageFile[],
failed: [] as Array<{ originalImage: any; error: string }>,
}
for (const image of wishImages) {
try {
if (image.base64) {
// 遷移 base64 圖片
const migrationResult = await this.migrateBase64ToStorage(image.base64, image.name)
if (migrationResult.success && migrationResult.data) {
result.successful.push(migrationResult.data)
} else {
result.failed.push({
originalImage: image,
error: migrationResult.error || "遷移失敗",
})
}
} else if (image.storage_path) {
// 已經是 Supabase Storage 格式,直接保留
result.successful.push(image as SupabaseImageFile)
}
} catch (error) {
result.failed.push({
originalImage: image,
error: `處理失敗: ${error}`,
})
}
}
return result
}
}

View File

@@ -0,0 +1,299 @@
import { supabase, type Database } from "./supabase"
import { SupabaseImageService, ImageMigrationService, type SupabaseImageFile } from "./supabase-image-utils"
// 更新的 Wish 類型定義
export type Wish = Database["public"]["Tables"]["wishes"]["Row"] & {
like_count?: number
images?: SupabaseImageFile[] // 使用新的圖片類型
}
export type WishInsert = Database["public"]["Tables"]["wishes"]["Insert"]
export type WishLike = Database["public"]["Tables"]["wish_likes"]["Row"]
export type UserSettings = Database["public"]["Tables"]["user_settings"]["Row"]
// 錯誤處理
export class SupabaseError extends Error {
constructor(
message: string,
public originalError?: any,
) {
super(message)
this.name = "SupabaseError"
}
}
// 更新的困擾案例服務
export class WishService {
// 獲取所有公開的困擾案例(帶點讚數和圖片)
static async getPublicWishes(): Promise<Wish[]> {
try {
const { data, error } = await supabase
.from("wishes_with_likes")
.select("*")
.eq("is_public", true)
.order("created_at", { ascending: false })
if (error) throw new SupabaseError("獲取公開困擾失敗", error)
// 轉換圖片格式
return (data || []).map((wish) => ({
...wish,
images: this.parseImages(wish.images),
}))
} catch (error) {
console.error("Error fetching public wishes:", error)
throw error
}
}
// 獲取所有困擾案例(用於分析,包含私密的)
static async getAllWishes(): Promise<Wish[]> {
try {
const { data, error } = await supabase
.from("wishes_with_likes")
.select("*")
.order("created_at", { ascending: false })
if (error) throw new SupabaseError("獲取所有困擾失敗", error)
// 轉換圖片格式
return (data || []).map((wish) => ({
...wish,
images: this.parseImages(wish.images),
}))
} catch (error) {
console.error("Error fetching all wishes:", error)
throw error
}
}
// 創建新的困擾案例(支持 Supabase Storage 圖片)
static async createWish(wishData: {
title: string
currentPain: string
expectedSolution: string
expectedEffect?: string
isPublic?: boolean
email?: string
images?: SupabaseImageFile[]
}): Promise<Wish> {
try {
// 準備圖片數據
const imageData =
wishData.images?.map((img) => ({
id: img.id,
name: img.name,
size: img.size,
type: img.type,
storage_path: img.storage_path,
public_url: img.public_url,
uploaded_at: img.uploaded_at,
})) || []
const insertData: WishInsert = {
title: wishData.title,
current_pain: wishData.currentPain,
expected_solution: wishData.expectedSolution,
expected_effect: wishData.expectedEffect || null,
is_public: wishData.isPublic ?? true,
email: wishData.email || null,
images: imageData,
}
const { data, error } = await supabase.from("wishes").insert(insertData).select().single()
if (error) throw new SupabaseError("創建困擾失敗", error)
return {
...data,
images: this.parseImages(data.images),
}
} catch (error) {
console.error("Error creating wish:", error)
throw error
}
}
// 解析圖片數據
private static parseImages(imagesData: any): SupabaseImageFile[] {
if (!imagesData || !Array.isArray(imagesData)) return []
return imagesData.map((img) => ({
id: img.id || `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: img.name || "unknown.jpg",
size: img.size || 0,
type: img.type || "image/jpeg",
storage_path: img.storage_path || "",
public_url: img.public_url || img.url || "", // 向後兼容
uploaded_at: img.uploaded_at || new Date().toISOString(),
}))
}
// 獲取統計數據
static async getWishesStats() {
try {
const { data, error } = await supabase.rpc("get_wishes_stats")
if (error) throw new SupabaseError("獲取統計數據失敗", error)
return data
} catch (error) {
console.error("Error fetching wishes stats:", error)
throw error
}
}
// 刪除困擾案例(包括相關圖片)
static async deleteWish(wishId: number): Promise<boolean> {
try {
// 先獲取困擾案例的圖片信息
const { data: wish, error: fetchError } = await supabase.from("wishes").select("images").eq("id", wishId).single()
if (fetchError) throw new SupabaseError("獲取困擾案例失敗", fetchError)
// 刪除相關圖片
if (wish.images && Array.isArray(wish.images)) {
const storagePaths = wish.images.map((img: any) => img.storage_path).filter((path: string) => path)
if (storagePaths.length > 0) {
await SupabaseImageService.deleteImages(storagePaths)
}
}
// 刪除困擾案例記錄
const { error: deleteError } = await supabase.from("wishes").delete().eq("id", wishId)
if (deleteError) throw new SupabaseError("刪除困擾案例失敗", deleteError)
return true
} catch (error) {
console.error("Error deleting wish:", error)
throw error
}
}
}
// 更新的數據遷移服務
export class MigrationService {
// 遷移 localStorage 中的困擾案例到 Supabase包括圖片遷移
static async migrateWishesFromLocalStorage(): Promise<{
success: number
failed: number
errors: string[]
}> {
const result = {
success: 0,
failed: 0,
errors: [] as string[],
}
try {
const localWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
if (localWishes.length === 0) {
console.log("No local wishes to migrate")
return result
}
console.log(`Starting migration of ${localWishes.length} wishes...`)
for (const wish of localWishes) {
try {
let migratedImages: SupabaseImageFile[] = []
// 遷移圖片(如果有的話)
if (wish.images && Array.isArray(wish.images) && wish.images.length > 0) {
console.log(`Migrating ${wish.images.length} images for wish: ${wish.title}`)
const imageMigrationResult = await ImageMigrationService.migrateImagesFromWish(wish.images)
migratedImages = imageMigrationResult.successful
if (imageMigrationResult.failed.length > 0) {
console.warn(`Failed to migrate ${imageMigrationResult.failed.length} images for wish: ${wish.title}`)
// 記錄圖片遷移失敗,但不阻止整個 wish 的遷移
result.errors.push(`部分圖片遷移失敗 "${wish.title}": ${imageMigrationResult.failed.length} 張圖片`)
}
}
// 創建困擾案例
await WishService.createWish({
title: wish.title,
currentPain: wish.currentPain,
expectedSolution: wish.expectedSolution,
expectedEffect: wish.expectedEffect,
isPublic: wish.isPublic !== false,
email: wish.email,
images: migratedImages,
})
result.success++
console.log(`Successfully migrated wish: ${wish.title}`)
} catch (error) {
result.failed++
result.errors.push(`Failed to migrate wish "${wish.title}": ${error}`)
console.error(`Failed to migrate wish "${wish.title}":`, error)
}
}
console.log(`Migration completed: ${result.success} success, ${result.failed} failed`)
return result
} catch (error) {
console.error("Migration error:", error)
result.errors.push(`Migration process failed: ${error}`)
return result
}
}
// 清空 localStorage 中的舊數據
static clearLocalStorageData(): void {
const keysToRemove = ["wishes", "wishLikes", "userLikedWishes"]
keysToRemove.forEach((key) => {
localStorage.removeItem(key)
})
console.log("Local storage data cleared")
}
}
// 存儲健康檢查服務
export class StorageHealthService {
// 檢查 Supabase Storage 健康狀態
static async checkStorageHealth(): Promise<{
healthy: boolean
stats?: { totalFiles: number; totalSize: number }
error?: string
}> {
try {
const healthCheck = await SupabaseImageService.checkStorageHealth()
if (!healthCheck.healthy) {
return { healthy: false, error: healthCheck.error }
}
const stats = await SupabaseImageService.getStorageStats()
return {
healthy: true,
stats: {
totalFiles: stats.totalFiles,
totalSize: stats.totalSize,
},
error: stats.error,
}
} catch (error) {
return { healthy: false, error: `健康檢查失敗: ${error}` }
}
}
// 清理孤立的圖片
static async cleanupOrphanedImages(): Promise<{ cleaned: number; error?: string }> {
try {
const { data, error } = await supabase.rpc("cleanup_orphaned_images")
if (error) {
return { cleaned: 0, error: error.message }
}
return { cleaned: data || 0 }
} catch (error) {
return { cleaned: 0, error: `清理過程失敗: ${error}` }
}
}
}

322
lib/supabase-service.ts Normal file
View File

@@ -0,0 +1,322 @@
import { supabase, getUserSession, type Database } from "./supabase"
import type { ImageFile } from "./image-utils"
// 類型定義
export type Wish = Database["public"]["Tables"]["wishes"]["Row"] & {
like_count?: number
}
export type WishInsert = Database["public"]["Tables"]["wishes"]["Insert"]
export type WishLike = Database["public"]["Tables"]["wish_likes"]["Row"]
export type UserSettings = Database["public"]["Tables"]["user_settings"]["Row"]
// 錯誤處理
export class SupabaseError extends Error {
constructor(
message: string,
public originalError?: any,
) {
super(message)
this.name = "SupabaseError"
}
}
// 困擾案例相關服務
export class WishService {
// 獲取所有公開的困擾案例(帶點讚數)
static async getPublicWishes(): Promise<Wish[]> {
try {
const { data, error } = await supabase
.from("wishes_with_likes")
.select("*")
.eq("is_public", true)
.order("created_at", { ascending: false })
if (error) throw new SupabaseError("獲取公開困擾失敗", error)
return data || []
} catch (error) {
console.error("Error fetching public wishes:", error)
throw error
}
}
// 獲取所有困擾案例(用於分析,包含私密的)
static async getAllWishes(): Promise<Wish[]> {
try {
const { data, error } = await supabase
.from("wishes_with_likes")
.select("*")
.order("created_at", { ascending: false })
if (error) throw new SupabaseError("獲取所有困擾失敗", error)
return data || []
} catch (error) {
console.error("Error fetching all wishes:", error)
throw error
}
}
// 創建新的困擾案例
static async createWish(wishData: {
title: string
currentPain: string
expectedSolution: string
expectedEffect?: string
isPublic?: boolean
email?: string
images?: ImageFile[]
}): Promise<Wish> {
try {
// 轉換圖片數據格式
const imageData =
wishData.images?.map((img) => ({
id: img.id,
name: img.name,
size: img.size,
type: img.type,
base64: img.base64 || img.url,
})) || []
const insertData: WishInsert = {
title: wishData.title,
current_pain: wishData.currentPain,
expected_solution: wishData.expectedSolution,
expected_effect: wishData.expectedEffect || null,
is_public: wishData.isPublic ?? true,
email: wishData.email || null,
images: imageData,
}
const { data, error } = await supabase.from("wishes").insert(insertData).select().single()
if (error) throw new SupabaseError("創建困擾失敗", error)
return data
} catch (error) {
console.error("Error creating wish:", error)
throw error
}
}
// 獲取統計數據
static async getWishesStats() {
try {
const { data, error } = await supabase.rpc("get_wishes_stats")
if (error) throw new SupabaseError("獲取統計數據失敗", error)
return data
} catch (error) {
console.error("Error fetching wishes stats:", error)
throw error
}
}
}
// 點讚相關服務
export class LikeService {
// 為困擾案例點讚
static async likeWish(wishId: number): Promise<boolean> {
try {
const userSession = getUserSession()
const { error } = await supabase.from("wish_likes").insert({
wish_id: wishId,
user_session: userSession,
})
if (error) {
// 如果是重複點讚錯誤,返回 false
if (error.code === "23505") {
return false
}
throw new SupabaseError("點讚失敗", error)
}
return true
} catch (error) {
console.error("Error liking wish:", error)
throw error
}
}
// 檢查用戶是否已點讚
static async hasUserLiked(wishId: number): Promise<boolean> {
try {
const userSession = getUserSession()
const { data, error } = await supabase
.from("wish_likes")
.select("id")
.eq("wish_id", wishId)
.eq("user_session", userSession)
.single()
if (error && error.code !== "PGRST116") {
throw new SupabaseError("檢查點讚狀態失敗", error)
}
return !!data
} catch (error) {
console.error("Error checking like status:", error)
return false
}
}
// 獲取困擾案例的點讚數
static async getWishLikeCount(wishId: number): Promise<number> {
try {
const { count, error } = await supabase
.from("wish_likes")
.select("*", { count: "exact", head: true })
.eq("wish_id", wishId)
if (error) throw new SupabaseError("獲取點讚數失敗", error)
return count || 0
} catch (error) {
console.error("Error fetching like count:", error)
return 0
}
}
// 獲取用戶已點讚的困擾 ID 列表
static async getUserLikedWishes(): Promise<number[]> {
try {
const userSession = getUserSession()
const { data, error } = await supabase.from("wish_likes").select("wish_id").eq("user_session", userSession)
if (error) throw new SupabaseError("獲取用戶點讚記錄失敗", error)
return data?.map((item) => item.wish_id) || []
} catch (error) {
console.error("Error fetching user liked wishes:", error)
return []
}
}
}
// 用戶設定相關服務
export class UserSettingsService {
// 獲取用戶設定
static async getUserSettings(): Promise<UserSettings | null> {
try {
const userSession = getUserSession()
const { data, error } = await supabase.from("user_settings").select("*").eq("user_session", userSession).single()
if (error && error.code !== "PGRST116") {
throw new SupabaseError("獲取用戶設定失敗", error)
}
return data
} catch (error) {
console.error("Error fetching user settings:", error)
return null
}
}
// 更新或創建用戶設定
static async updateUserSettings(settings: {
backgroundMusicEnabled?: boolean
backgroundMusicVolume?: number
backgroundMusicPlaying?: boolean
}): Promise<UserSettings> {
try {
const userSession = getUserSession()
// 先嘗試更新
const { data: updateData, error: updateError } = await supabase
.from("user_settings")
.update({
background_music_enabled: settings.backgroundMusicEnabled,
background_music_volume: settings.backgroundMusicVolume,
background_music_playing: settings.backgroundMusicPlaying,
})
.eq("user_session", userSession)
.select()
.single()
if (updateError && updateError.code === "PGRST116") {
// 如果記錄不存在,創建新記錄
const { data: insertData, error: insertError } = await supabase
.from("user_settings")
.insert({
user_session: userSession,
background_music_enabled: settings.backgroundMusicEnabled ?? false,
background_music_volume: settings.backgroundMusicVolume ?? 0.3,
background_music_playing: settings.backgroundMusicPlaying ?? false,
})
.select()
.single()
if (insertError) throw new SupabaseError("創建用戶設定失敗", insertError)
return insertData
}
if (updateError) throw new SupabaseError("更新用戶設定失敗", updateError)
return updateData
} catch (error) {
console.error("Error updating user settings:", error)
throw error
}
}
}
// 數據遷移服務(從 localStorage 遷移到 Supabase
export class MigrationService {
// 遷移 localStorage 中的困擾案例到 Supabase
static async migrateWishesFromLocalStorage(): Promise<{
success: number
failed: number
errors: string[]
}> {
const result = {
success: 0,
failed: 0,
errors: [] as string[],
}
try {
const localWishes = JSON.parse(localStorage.getItem("wishes") || "[]")
if (localWishes.length === 0) {
console.log("No local wishes to migrate")
return result
}
console.log(`Starting migration of ${localWishes.length} wishes...`)
for (const wish of localWishes) {
try {
await WishService.createWish({
title: wish.title,
currentPain: wish.currentPain,
expectedSolution: wish.expectedSolution,
expectedEffect: wish.expectedEffect,
isPublic: wish.isPublic !== false, // 默認為 true
email: wish.email,
images: wish.images || [],
})
result.success++
} catch (error) {
result.failed++
result.errors.push(`Failed to migrate wish "${wish.title}": ${error}`)
}
}
console.log(`Migration completed: ${result.success} success, ${result.failed} failed`)
return result
} catch (error) {
console.error("Migration error:", error)
result.errors.push(`Migration process failed: ${error}`)
return result
}
}
// 清空 localStorage 中的舊數據
static clearLocalStorageData(): void {
const keysToRemove = ["wishes", "wishLikes", "userLikedWishes"]
keysToRemove.forEach((key) => {
localStorage.removeItem(key)
})
console.log("Local storage data cleared")
}
}

152
lib/supabase.ts Normal file
View File

@@ -0,0 +1,152 @@
import { createClient } from "@supabase/supabase-js"
// Supabase 配置
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
// 創建 Supabase 客戶端(單例模式)
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: false, // 我們不需要用戶認證
},
db: {
schema: "public",
},
})
// 數據庫類型定義
export interface Database {
public: {
Tables: {
wishes: {
Row: {
id: number
title: string
current_pain: string
expected_solution: string
expected_effect: string | null
is_public: boolean
email: string | null
images: any[] | null
created_at: string
updated_at: string
}
Insert: {
title: string
current_pain: string
expected_solution: string
expected_effect?: string | null
is_public?: boolean
email?: string | null
images?: any[] | null
}
Update: {
title?: string
current_pain?: string
expected_solution?: string
expected_effect?: string | null
is_public?: boolean
email?: string | null
images?: any[] | null
}
}
wish_likes: {
Row: {
id: number
wish_id: number
user_session: string
created_at: string
}
Insert: {
wish_id: number
user_session: string
}
Update: {
wish_id?: number
user_session?: string
}
}
user_settings: {
Row: {
id: number
user_session: string
background_music_enabled: boolean
background_music_volume: number
background_music_playing: boolean
created_at: string
updated_at: string
}
Insert: {
user_session: string
background_music_enabled?: boolean
background_music_volume?: number
background_music_playing?: boolean
}
Update: {
background_music_enabled?: boolean
background_music_volume?: number
background_music_playing?: boolean
}
}
}
Views: {
wishes_with_likes: {
Row: {
id: number
title: string
current_pain: string
expected_solution: string
expected_effect: string | null
is_public: boolean
email: string | null
images: any[] | null
created_at: string
updated_at: string
like_count: number
}
}
}
Functions: {
get_wishes_stats: {
Args: {}
Returns: {
total_wishes: number
public_wishes: number
private_wishes: number
this_week: number
last_week: number
}
}
}
}
}
// 生成用戶會話 ID用於匿名識別
export function getUserSession(): string {
if (typeof window === "undefined") return "server-session"
let session = localStorage.getItem("user_session")
if (!session) {
session = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem("user_session", session)
}
return session
}
// 測試 Supabase 連接
export async function testSupabaseConnection(): Promise<boolean> {
try {
const { data, error } = await supabase.from("wishes").select("count").limit(1)
if (error) {
console.error("Supabase connection test failed:", error)
return false
}
console.log("✅ Supabase connection successful")
return true
} catch (error) {
console.error("Supabase connection test error:", error)
return false
}
}