實作個人收藏、個人活動紀錄

This commit is contained in:
2025-09-11 17:40:07 +08:00
parent bc2104d374
commit 9c5dceb001
29 changed files with 3781 additions and 601 deletions

View File

@@ -131,6 +131,7 @@ export function AppManagement() {
basic: {
views: 0,
likes: 0,
favorites: 0,
rating: 0,
reviewCount: 0
},
@@ -1305,7 +1306,7 @@ export function AppManagement() {
<TabsContent value="stats" className="space-y-4">
{/* 基本統計數據 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<Card>
<CardContent className="p-4">
<div className="text-center">
@@ -1322,6 +1323,16 @@ export function AppManagement() {
<p className="text-2xl font-bold text-red-600">
{isLoadingStats ? '...' : (appStats?.basic?.likes || 0)}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-purple-600">
{isLoadingStats ? '...' : (appStats?.basic?.favorites || 0)}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>

View File

@@ -903,8 +903,8 @@ export function UserManagement() {
<TableCell className="text-sm text-gray-600">{user.lastLogin || "-"}</TableCell>
<TableCell>
<div className="text-sm">
<p>{user.totalApps} </p>
<p className="text-gray-500">{user.totalReviews} </p>
<p>{user.appCount || 0} </p>
<p className="text-gray-500">{user.reviewCount || 0} </p>
</div>
</TableCell>
<TableCell>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
@@ -13,6 +13,7 @@ import {
Star,
Eye,
Heart,
ThumbsUp,
Info,
MessageSquare,
User,
@@ -21,6 +22,23 @@ import {
TrendingUp,
Users,
BarChart3,
Brain,
ImageIcon,
Mic,
Settings,
Zap,
Target,
Bookmark,
Lightbulb,
Search,
Plus,
X,
ChevronLeft,
ChevronRight,
ArrowLeft,
Trophy,
Award,
Medal,
} from "lucide-react"
import { FavoriteButton } from "./favorite-button"
import { ReviewSystem } from "./reviews/review-system"
@@ -61,6 +79,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
basic: {
views: 0,
likes: 0,
favorites: 0,
rating: 0,
reviewCount: 0
},
@@ -85,10 +104,42 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
return new Date().toISOString().split("T")[0]
})
const IconComponent = app.icon
const likes = getAppLikes(app.id.toString())
const views = getViewCount(app.id.toString())
const usageStats = getAppUsageStats(app.id.toString(), startDate, endDate)
// 圖標映射函數
const getIconComponent = (iconName: string) => {
const iconMap: { [key: string]: any } = {
'Bot': Brain,
'ImageIcon': ImageIcon,
'Mic': Mic,
'MessageSquare': MessageSquare,
'Settings': Settings,
'Zap': Zap,
'TrendingUp': TrendingUp,
'Star': Star,
'Heart': Heart,
'Eye': Eye,
'Trophy': Trophy,
'Award': Award,
'Medal': Medal,
'Target': Target,
'Users': Users,
'Lightbulb': Lightbulb,
'Search': Search,
'Plus': Plus,
'X': X,
'ChevronLeft': ChevronLeft,
'ChevronRight': ChevronRight,
'ArrowLeft': ArrowLeft
}
return iconMap[iconName] || Brain // 預設使用 Brain 圖標
}
const IconComponent = getIconComponent(app.icon || 'Bot')
const likes = (app as any).likesCount || 0
const views = (app as any).viewsCount || 0
const rating = (app as any).rating || 0
const reviewsCount = (app as any).reviewsCount || 0
// 使用從 API 載入的實際數據
const usageStats = appStats.usage
const getTypeColor = (type: string) => {
const colors = {
@@ -100,18 +151,19 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
const handleRatingUpdate = (newRating: number, newReviewCount: number) => {
setCurrentRating(newRating)
setReviewCount(newReviewCount)
}
// 載入應用統計數據
const loadAppStats = async () => {
const loadAppStats = useCallback(async (customStartDate?: string, customEndDate?: string) => {
if (!app.id) return
setIsLoadingStats(true)
try {
const response = await fetch(`/api/apps/${app.id}/stats`)
// 構建查詢參數
const params = new URLSearchParams()
if (customStartDate) params.append('startDate', customStartDate)
if (customEndDate) params.append('endDate', customEndDate)
const url = `/api/apps/${app.id}/stats${params.toString() ? `?${params.toString()}` : ''}`
const response = await fetch(url)
const data = await response.json()
if (data.success) {
@@ -124,36 +176,107 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
} finally {
setIsLoadingStats(false)
}
}
}, [app.id])
const handleRatingUpdate = useCallback(async (newRating: number, newReviewCount: number) => {
setCurrentRating(newRating)
setReviewCount(newReviewCount)
// Reload stats after rating update
await loadAppStats()
}, [loadAppStats])
// 處理日期範圍變更
const handleDateRangeChange = useCallback(async () => {
if (startDate && endDate) {
await loadAppStats(startDate, endDate)
}
}, [startDate, endDate, loadAppStats])
// 當對話框打開時載入統計數據
useEffect(() => {
if (open && app.id) {
loadAppStats()
}
}, [open, app.id])
}, [open, app.id, loadAppStats])
const handleTryApp = () => {
// 當日期範圍變更時重新載入使用趨勢數據
useEffect(() => {
if (open && app.id && startDate && endDate) {
handleDateRangeChange()
}
}, [startDate, endDate, open, app.id, handleDateRangeChange])
const handleTryApp = async () => {
console.log('handleTryApp 被調用', { user: user?.id, appId: app.id })
if (user) {
addToRecentApps(app.id.toString())
// 記錄用戶活動
try {
console.log('開始記錄用戶活動...')
const response = await fetch('/api/user/activity', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
action: 'view',
resourceType: 'app',
resourceId: app.id.toString(),
details: {
appName: app.name,
timestamp: new Date().toISOString()
}
})
})
if (response.ok) {
console.log('活動記錄成功')
} else {
console.error('活動記錄失敗:', response.status, response.statusText)
}
} catch (error) {
console.error('記錄活動失敗:', error)
}
} else {
console.log('用戶未登入,跳過活動記錄')
}
// Increment view count when trying the app
incrementViewCount(app.id.toString())
await incrementViewCount(app.id.toString())
// Reload stats after incrementing view count
await loadAppStats()
// Open external app URL in new tab
const appUrls: Record<string, string> = {
"1": "https://dify.example.com/chat-assistant",
"2": "https://image-gen.example.com",
"3": "https://speech.example.com",
"4": "https://recommend.example.com",
"5": "https://text-analysis.example.com",
"6": "https://ai-writing.example.com",
}
const appUrl = appUrls[app.id.toString()]
// Get app URL from database or fallback to default URLs
const appUrl = (app as any).appUrl || (app as any).app_url
if (appUrl) {
window.open(appUrl, "_blank", "noopener,noreferrer")
// Ensure URL has protocol
const url = appUrl.startsWith('http') ? appUrl : `https://${appUrl}`
window.open(url, "_blank", "noopener,noreferrer")
} else {
// Fallback to default URLs for testing
const defaultUrls: Record<string, string> = {
"1": "https://dify.example.com/chat-assistant",
"2": "https://image-gen.example.com",
"3": "https://speech.example.com",
"4": "https://recommend.example.com",
"5": "https://text-analysis.example.com",
"6": "https://ai-writing.example.com",
}
const fallbackUrl = defaultUrls[app.id.toString()]
if (fallbackUrl) {
window.open(fallbackUrl, "_blank", "noopener,noreferrer")
} else {
console.warn('No app URL found for app:', app.id)
// Show a toast or alert to user
alert('此應用暫無可用連結')
}
}
}
@@ -181,7 +304,9 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-start space-x-4">
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl flex items-center justify-center">
<div
className={`w-16 h-16 bg-gradient-to-r ${(app as any).iconColor || 'from-blue-500 to-purple-500'} rounded-xl flex items-center justify-center`}
>
<IconComponent className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
@@ -201,19 +326,30 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</Badge>
)}
</div>
<div className="flex items-center space-x-6 text-sm text-gray-600">
<div className="flex items-center space-x-1">
<Heart className="w-4 h-4" />
<span>{likes} </span>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-6 text-sm text-gray-600">
<div className="flex items-center space-x-1">
<ThumbsUp className="w-4 h-4" />
<span>{likes} </span>
</div>
<div className="flex items-center space-x-1">
<Eye className="w-4 h-4" />
<span>{views} </span>
</div>
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500" />
<span>{Number(rating).toFixed(1)}</span>
{reviewsCount > 0 && <span className="text-gray-500">({reviewsCount} )</span>}
</div>
</div>
<div className="flex items-center space-x-1">
<Eye className="w-4 h-4" />
<span>{views} </span>
</div>
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500" />
<span>{currentRating.toFixed(1)}</span>
{reviewCount > 0 && <span className="text-gray-500">({reviewCount} )</span>}
<div className="flex items-center space-x-3">
<FavoriteButton appId={app.id.toString()} size="md" />
<Button
onClick={handleTryApp}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white px-6 py-2"
>
</Button>
</div>
</div>
</div>
@@ -262,7 +398,13 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<Calendar className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">2024115</p>
<p className="font-medium">
{(app as any).createdAt ? new Date((app as any).createdAt).toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : '未知'}
</p>
</div>
</div>
</div>
@@ -270,10 +412,10 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<div>
<p className="text-sm text-gray-500 mb-2"></p>
<ul className="space-y-1 text-sm">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
<li> {app.type} </li>
<li> {app.department} </li>
<li> </li>
<li> 使</li>
</ul>
</div>
</div>
@@ -282,28 +424,17 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<div className="pt-4 border-t">
<p className="text-sm text-gray-500 mb-2"></p>
<p className="text-gray-700 leading-relaxed">
{app.description}
使
{app.description || '暫無詳細描述'}
</p>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<FavoriteButton appId={app.id.toString()} size="default" showText={true} className="px-6" />
<Button
onClick={handleTryApp}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
</Button>
</div>
</TabsContent>
<TabsContent value="statistics" className="space-y-6">
{/* 基本統計數據 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
@@ -311,7 +442,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">
{isLoadingStats ? '...' : appStats.basic.views}
{isLoadingStats ? '...' : views}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
@@ -319,12 +450,25 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Heart className="h-4 w-4 text-red-500" />
<CardTitle className="text-sm font-medium"></CardTitle>
<ThumbsUp className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{isLoadingStats ? '...' : appStats.basic.likes}
{isLoadingStats ? '...' : likes}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Heart className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-purple-600">
{isLoadingStats ? '...' : (appStats.basic as any).favorites || 0}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
@@ -337,7 +481,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">
{isLoadingStats ? '...' : appStats.basic.rating.toFixed(1)}
{isLoadingStats ? '...' : Number(rating).toFixed(1)}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
@@ -350,7 +494,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{isLoadingStats ? '...' : appStats.basic.reviewCount}
{isLoadingStats ? '...' : reviewsCount}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
@@ -418,6 +562,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-36"
max={endDate}
/>
</div>
<div className="flex items-center space-x-2">
@@ -430,103 +575,139 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-36"
min={startDate}
max={new Date().toISOString().split("T")[0]}
/>
</div>
<Button
onClick={handleDateRangeChange}
disabled={isLoadingStats || !startDate || !endDate}
size="sm"
variant="outline"
>
{isLoadingStats ? (
<>
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500 mr-1"></div>
</>
) : (
'重新載入'
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Chart Container with Horizontal Scroll */}
<div className="w-full overflow-x-auto">
<div
className="h-80 relative bg-gray-50 rounded-lg p-4"
style={{
minWidth: `${Math.max(800, usageStats.trendData.length * 40)}px`, // Dynamic width based on data points
}}
>
{/* Month/Year Section Headers */}
<div className="absolute top-2 left-4 right-4 flex">
{(() => {
const sections = getDateSections(usageStats.trendData)
const totalBars = usageStats.trendData.length
return Object.entries(sections).map(([key, section]) => {
const width = ((section.endIndex - section.startIndex + 1) / totalBars) * 100
const left = (section.startIndex / totalBars) * 100
return (
<div
key={key}
className="absolute text-xs font-medium text-gray-600 bg-white/90 px-2 py-1 rounded shadow-sm border"
style={{
left: `${left}%`,
width: `${width}%`,
textAlign: "center",
}}
>
{section.label}
</div>
)
})
})()}
</div>
{/* Chart Bars */}
<div className="h-full flex items-end justify-between space-x-2" style={{ paddingTop: "40px" }}>
{usageStats.trendData.map((day, index) => {
const maxUsers = Math.max(...usageStats.trendData.map((d) => d.users))
const minUsers = Math.min(...usageStats.trendData.map((d) => d.users))
const range = maxUsers - minUsers
const normalizedHeight = range > 0 ? ((day.users - minUsers) / range) * 70 + 15 : 40
const currentDate = new Date(day.date)
const prevDate = index > 0 ? new Date(usageStats.trendData[index - 1].date) : null
// Check if this is the start of a new month/year for divider
const isNewMonth =
!prevDate ||
currentDate.getMonth() !== prevDate.getMonth() ||
currentDate.getFullYear() !== prevDate.getFullYear()
return (
<div
key={day.date}
className="flex-1 flex flex-col items-center group relative"
style={{ minWidth: "32px" }}
>
{/* Month divider line */}
{isNewMonth && index > 0 && (
<div className="absolute left-0 top-0 bottom-8 w-px bg-gray-300 opacity-50" />
)}
<div
className="w-full flex flex-col items-center justify-end"
style={{ height: "200px" }}
>
<div
className="w-full bg-gradient-to-t from-blue-500 to-purple-500 rounded-t-md transition-all duration-300 hover:from-blue-600 hover:to-purple-600 cursor-pointer relative"
style={{ height: `${normalizedHeight}%` }}
>
{/* Value label */}
<div className="absolute -top-5 left-1/2 transform -translate-x-1/2 text-xs font-medium text-gray-600 bg-white/80 px-1 rounded">
{day.users}
</div>
</div>
</div>
{/* Consistent day-only labels */}
<div className="text-xs text-gray-500 mt-2 text-center">{currentDate.getDate()}</div>
</div>
)
})}
{isLoadingStats ? (
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-gray-500">使...</p>
</div>
</div>
</div>
) : usageStats.trendData && usageStats.trendData.length > 0 ? (
<>
{/* Chart Container with Horizontal Scroll */}
<div className="w-full overflow-x-auto">
<div
className="h-80 relative bg-gray-50 rounded-lg p-4"
style={{
minWidth: `${Math.max(800, usageStats.trendData.length * 40)}px`, // Dynamic width based on data points
}}
>
{/* Month/Year Section Headers */}
<div className="absolute top-2 left-4 right-4 flex">
{(() => {
const sections = getDateSections(usageStats.trendData)
const totalBars = usageStats.trendData.length
{/* Scroll Hint */}
{usageStats.trendData.length > 20 && (
<div className="text-xs text-gray-500 text-center">💡 </div>
return Object.entries(sections).map(([key, section]) => {
const width = ((section.endIndex - section.startIndex + 1) / totalBars) * 100
const left = (section.startIndex / totalBars) * 100
return (
<div
key={key}
className="absolute text-xs font-medium text-gray-600 bg-white/90 px-2 py-1 rounded shadow-sm border"
style={{
left: `${left}%`,
width: `${width}%`,
textAlign: "center",
}}
>
{section.label}
</div>
)
})
})()}
</div>
{/* Chart Bars */}
<div className="h-full flex items-end justify-between space-x-2" style={{ paddingTop: "40px" }}>
{usageStats.trendData.map((day: any, index: number) => {
const maxUsers = Math.max(...usageStats.trendData.map((d: any) => d.users))
const minUsers = Math.min(...usageStats.trendData.map((d: any) => d.users))
const range = maxUsers - minUsers
const normalizedHeight = range > 0 ? ((day.users - minUsers) / range) * 70 + 15 : 40
const currentDate = new Date(day.date)
const prevDate = index > 0 ? new Date((usageStats.trendData[index - 1] as any).date) : null
// Check if this is the start of a new month/year for divider
const isNewMonth =
!prevDate ||
currentDate.getMonth() !== prevDate.getMonth() ||
currentDate.getFullYear() !== prevDate.getFullYear()
return (
<div
key={day.date}
className="flex-1 flex flex-col items-center group relative"
style={{ minWidth: "32px" }}
>
{/* Month divider line */}
{isNewMonth && index > 0 && (
<div className="absolute left-0 top-0 bottom-8 w-px bg-gray-300 opacity-50" />
)}
<div
className="w-full flex flex-col items-center justify-end"
style={{ height: "200px" }}
>
<div
className="w-full bg-gradient-to-t from-blue-500 to-purple-500 rounded-t-md transition-all duration-300 hover:from-blue-600 hover:to-purple-600 cursor-pointer relative"
style={{ height: `${normalizedHeight}%` }}
>
{/* Value label */}
<div className="absolute -top-5 left-1/2 transform -translate-x-1/2 text-xs font-medium text-gray-600 bg-white/80 px-1 rounded">
{day.users}
</div>
</div>
</div>
{/* Consistent day-only labels */}
<div className="text-xs text-gray-500 mt-2 text-center">{currentDate.getDate()}</div>
</div>
)
})}
</div>
</div>
</div>
{/* Scroll Hint */}
{usageStats.trendData && usageStats.trendData.length > 20 && (
<div className="text-xs text-gray-500 text-center">💡 </div>
)}
</>
) : (
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
<div className="text-center text-gray-500">
<TrendingUp className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>使</p>
<p className="text-sm mt-1"></p>
</div>
</div>
)}
</div>
</CardContent>
@@ -540,18 +721,30 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardHeader>
<CardContent>
<div className="space-y-4">
{usageStats.topDepartments.map((dept) => (
<div key={dept.name} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" />
<span className="font-medium">{dept.name}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">{dept.users} </span>
<span className="text-sm font-medium">{dept.percentage}%</span>
</div>
{usageStats.topDepartments && usageStats.topDepartments.length > 0 ? (
usageStats.topDepartments.map((dept: any, index: number) => {
const totalUsers = usageStats.topDepartments.reduce((sum: number, d: any) => sum + d.count, 0)
const percentage = totalUsers > 0 ? Math.round((dept.count / totalUsers) * 100) : 0
return (
<div key={dept.department || index} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" />
<span className="font-medium">{dept.department || '未知部門'}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">{dept.count} </span>
<span className="text-sm font-medium">{percentage}%</span>
</div>
</div>
)
})
) : (
<div className="text-center text-gray-500 py-8">
<Building className="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>使</p>
</div>
))}
)}
</div>
</CardContent>
</Card>

View File

@@ -6,7 +6,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Progress } from "@/components/ui/progress"
import { BarChart3, Clock, Heart, ImageIcon, MessageSquare, FileText, TrendingUp, Trash2, RefreshCw } from "lucide-react"
import {
BarChart3, Clock, Heart, ImageIcon, MessageSquare, FileText, TrendingUp, Trash2, RefreshCw,
Bot, Mic, Settings, Zap, Star, Eye, Target, Users, Lightbulb, Search, Database, Shield, Cpu,
Globe, Layers, PieChart, Activity, Calendar, Code, Command, Compass, CreditCard, Download,
Edit, ExternalLink, Filter, Flag, Folder, Gift, Home, Info, Key, Link, Lock, Mail, MapPin,
Minus, Monitor, MoreHorizontal, MoreVertical, MousePointer, Navigation, Pause, Play, Plus,
Power, Save, Send, Share, Smartphone, Tablet, Upload, Volume2, Wifi, X, ZoomIn, ZoomOut
} from "lucide-react"
import { useState, useEffect } from "react"
interface ActivityRecordsDialogProps {
@@ -30,74 +37,47 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
const [recentApps, setRecentApps] = useState<any[]>([])
const [categoryData, setCategoryData] = useState<any[]>([])
const [userStats, setUserStats] = useState({
totalUsage: 0,
favoriteApps: 0,
daysJoined: 0
})
const [isLoading, setIsLoading] = useState(false)
const [isResetting, setIsResetting] = useState(false)
if (!user) return null
// Calculate user statistics
const calculateUserStats = () => {
if (!user) return {
totalUsage: 0,
totalDuration: 0,
favoriteApps: 0,
daysJoined: 0
}
// Load user activity data from API
const loadUserActivity = async () => {
if (!user) return
// Calculate total usage count (views)
const totalUsage = Object.values(user.recentApps || []).length
// Calculate total duration (simplified - 5 minutes per app view)
const totalDuration = totalUsage * 5 // minutes
// Get favorite apps count
const favoriteApps = user.favoriteApps?.length || 0
// Calculate days joined
const joinDate = new Date(user.joinDate)
const now = new Date()
// Check if joinDate is valid
let daysJoined = 0
if (!isNaN(joinDate.getTime())) {
daysJoined = Math.floor((now.getTime() - joinDate.getTime()) / (1000 * 60 * 60 * 24))
}
return {
totalUsage,
totalDuration,
favoriteApps,
daysJoined: Math.max(0, daysJoined)
setIsLoading(true)
try {
const response = await fetch(`/api/user/activity?userId=${user.id}`)
const data = await response.json()
if (data.success) {
setRecentApps(data.data.recentApps || [])
setCategoryData(data.data.categoryStats || [])
setUserStats(data.data.userStats || {
totalUsage: 0,
favoriteApps: 0,
daysJoined: 0
})
}
} catch (error) {
console.error('載入用戶活動數據錯誤:', error)
} finally {
setIsLoading(false)
}
}
const stats = calculateUserStats()
// Load recent apps from user's recent apps
// Load data when dialog opens
useEffect(() => {
if (user?.recentApps) {
// Convert recent app IDs to app objects (simplified)
const recentAppsData = user.recentApps.slice(0, 10).map((appId, index) => ({
id: appId,
name: `應用 ${appId}`,
author: "系統",
category: "AI應用",
usageCount: getViewCount(appId),
timeSpent: "5分鐘",
lastUsed: `${index + 1}天前`,
icon: MessageSquare,
color: "bg-blue-500"
}))
setRecentApps(recentAppsData)
} else {
setRecentApps([])
if (open && user) {
loadUserActivity()
}
}, [user, getViewCount])
// Load category data (simplified)
useEffect(() => {
// This would normally be calculated from actual usage data
setCategoryData([])
}, [user])
}, [open, user])
// Reset user activity data
const resetActivityData = async () => {
@@ -155,38 +135,150 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
<h3 className="text-lg font-semibold mb-2">使</h3>
<p className="text-sm text-muted-foreground mb-4"> AI </p>
{recentApps.length > 0 ? (
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-2"></div>
<p className="text-gray-500">...</p>
</div>
) : recentApps.length > 0 ? (
<div className="grid gap-4">
{recentApps.map((app) => {
const IconComponent = app.icon
// 圖標映射函數
const getIconComponent = (iconName: string) => {
const iconMap: { [key: string]: any } = {
'Bot': Bot,
'ImageIcon': ImageIcon,
'Mic': Mic,
'MessageSquare': MessageSquare,
'Settings': Settings,
'Zap': Zap,
'TrendingUp': TrendingUp,
'Star': Star,
'Heart': Heart,
'Eye': Eye,
'Target': Target,
'Users': Users,
'Lightbulb': Lightbulb,
'Search': Search,
'BarChart3': BarChart3,
'Database': Database,
'Shield': Shield,
'Cpu': Cpu,
'FileText': FileText,
'Globe': Globe,
'Layers': Layers,
'PieChart': PieChart,
'Activity': Activity,
'Calendar': Calendar,
'Clock': Clock,
'Code': Code,
'Command': Command,
'Compass': Compass,
'CreditCard': CreditCard,
'Download': Download,
'Edit': Edit,
'ExternalLink': ExternalLink,
'Filter': Filter,
'Flag': Flag,
'Folder': Folder,
'Gift': Gift,
'Home': Home,
'Info': Info,
'Key': Key,
'Link': Link,
'Lock': Lock,
'Mail': Mail,
'MapPin': MapPin,
'Minus': Minus,
'Monitor': Monitor,
'MoreHorizontal': MoreHorizontal,
'MoreVertical': MoreVertical,
'MousePointer': MousePointer,
'Navigation': Navigation,
'Pause': Pause,
'Play': Play,
'Plus': Plus,
'Power': Power,
'RefreshCw': RefreshCw,
'Save': Save,
'Send': Send,
'Share': Share,
'Smartphone': Smartphone,
'Tablet': Tablet,
'Trash2': Trash2,
'Upload': Upload,
'Volume2': Volume2,
'Wifi': Wifi,
'X': X,
'ZoomIn': ZoomIn,
'ZoomOut': ZoomOut,
}
return iconMap[iconName] || MessageSquare
}
const IconComponent = getIconComponent(app.icon || 'MessageSquare')
const lastUsedDate = new Date(app.lastUsed)
const now = new Date()
const diffTime = Math.abs(now.getTime() - lastUsedDate.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return (
<Card key={app.id} className="hover:shadow-md transition-shadow">
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center space-x-4">
<div className={`p-3 rounded-lg ${app.color}`}>
<div className={`p-3 rounded-lg bg-gradient-to-r ${app.iconColor || 'from-blue-500 to-purple-500'}`}>
<IconComponent className="w-6 h-6 text-white" />
</div>
<div>
<h4 className="font-semibold">{app.name}</h4>
<p className="text-sm text-muted-foreground">by {app.author}</p>
<p className="text-sm text-muted-foreground">by {app.creator}</p>
<div className="flex items-center gap-4 mt-1">
<Badge variant="secondary" className="text-xs">
{app.category}
{app.type}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<BarChart3 className="w-3 h-3" />
使 {app.usageCount}
</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{app.timeSpent}
</span>
</div>
</div>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground mb-2">{app.lastUsed}</p>
<Button size="sm" variant="outline">
<p className="text-xs text-muted-foreground mb-2">
{diffDays === 1 ? '昨天' : diffDays < 7 ? `${diffDays}天前` : `${Math.ceil(diffDays / 7)}週前`}
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
// 記錄活動
if (user) {
fetch('/api/user/activity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: user.id,
action: 'view',
resourceType: 'app',
resourceId: app.id,
details: JSON.stringify({ appName: app.name })
})
}).catch(console.error)
}
// 增加查看次數
fetch(`/api/apps/${app.id}/interactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'view', userId: user?.id })
}).catch(console.error)
// 打開應用
if (app.appUrl && app.appUrl !== '#') {
window.open(app.appUrl, '_blank')
}
}}
>
</Button>
</div>
@@ -237,35 +329,16 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{isNaN(stats.totalUsage) ? 0 : stats.totalUsage}</div>
<div className="text-2xl font-bold">{isNaN(userStats.totalUsage) ? 0 : userStats.totalUsage}</div>
<p className="text-xs text-muted-foreground">
{(isNaN(stats.totalUsage) ? 0 : stats.totalUsage) > 0 ? "累計使用" : "尚未使用任何應用"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{isNaN(stats.totalDuration) ? "0分鐘" : (
stats.totalDuration >= 60
? `${(stats.totalDuration / 60).toFixed(1)}小時`
: `${stats.totalDuration}分鐘`
)}
</div>
<p className="text-xs text-muted-foreground">
{(isNaN(stats.totalDuration) ? 0 : stats.totalDuration) > 0 ? "累計時長" : "尚未開始使用"}
{(isNaN(userStats.totalUsage) ? 0 : userStats.totalUsage) > 0 ? "累計使用" : "尚未使用任何應用"}
</p>
</CardContent>
</Card>
@@ -276,9 +349,9 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
<Heart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{isNaN(stats.favoriteApps) ? 0 : stats.favoriteApps}</div>
<div className="text-2xl font-bold">{isNaN(userStats.favoriteApps) ? 0 : userStats.favoriteApps}</div>
<p className="text-xs text-muted-foreground">
{(isNaN(stats.favoriteApps) ? 0 : stats.favoriteApps) > 0 ? "個人收藏" : "尚未收藏任何應用"}
{(isNaN(userStats.favoriteApps) ? 0 : userStats.favoriteApps) > 0 ? "個人收藏" : "尚未收藏任何應用"}
</p>
</CardContent>
</Card>
@@ -289,9 +362,9 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{isNaN(stats.daysJoined) ? 0 : stats.daysJoined}</div>
<div className="text-2xl font-bold">{isNaN(userStats.daysJoined) ? 0 : userStats.daysJoined}</div>
<p className="text-xs text-muted-foreground">
{(isNaN(stats.daysJoined) ? 0 : stats.daysJoined) > 0 ? "已加入平台" : "今天剛加入"}
{(isNaN(userStats.daysJoined) ? 0 : userStats.daysJoined) > 0 ? "已加入平台" : "今天剛加入"}
</p>
</CardContent>
</Card>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Button } from "@/components/ui/button"
import { Heart } from "lucide-react"
import { cn } from "@/lib/utils"
@@ -20,17 +21,31 @@ export function FavoriteButton({
size = "md",
variant = "ghost",
}: FavoriteButtonProps) {
const { user, isFavorite, toggleFavorite } = useAuth()
const [isFavorited, setIsFavorited] = useState(initialFavorited)
const [isLoading, setIsLoading] = useState(false)
// 使用 isFavorite 的實時結果
const currentFavorited = user ? isFavorite(appId) : isFavorited
// 載入收藏狀態
useEffect(() => {
if (user) {
const favorited = isFavorite(appId)
setIsFavorited(favorited)
}
}, [user, appId, isFavorite])
const handleToggle = async () => {
if (!user) {
console.warn('用戶未登入,無法收藏')
return
}
setIsLoading(true)
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 300))
const newFavoriteState = !isFavorited
const newFavoriteState = await toggleFavorite(appId)
setIsFavorited(newFavoriteState)
// Call the callback if provided
@@ -58,16 +73,16 @@ export function FavoriteButton({
<Button
variant={variant}
size="icon"
className={cn(sizeClasses[size], "transition-all duration-200", isFavorited && "text-red-500 hover:text-red-600")}
className={cn(sizeClasses[size], "transition-all duration-200", currentFavorited && "text-red-500 hover:text-red-600")}
onClick={handleToggle}
disabled={isLoading}
title={isFavorited ? "取消收藏" : "加入收藏"}
title={currentFavorited ? "取消收藏" : "加入收藏"}
>
<Heart
className={cn(
iconSizes[size],
"transition-all duration-200",
isFavorited && "fill-current",
currentFavorited && "fill-current",
isLoading && "animate-pulse",
)}
/>

View File

@@ -1,28 +1,158 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Heart, ExternalLink } from "lucide-react"
// Favorite apps data - empty for production
const mockFavoriteApps: any[] = []
import { Heart, ExternalLink, Star, Eye, ThumbsUp, MessageSquare, Brain, ImageIcon, Mic, MessageSquare as MessageSquareIcon, Settings, Zap, TrendingUp, Target, Users, Lightbulb, Search } from "lucide-react"
export function FavoritesPage() {
const { user } = useAuth()
const [sortBy, setSortBy] = useState("name")
const [sortBy, setSortBy] = useState("favorited")
const [filterDepartment, setFilterDepartment] = useState("all")
const [favoriteApps, setFavoriteApps] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const handleUseApp = (app: any) => {
// Open app in new tab
window.open(app.url, "_blank")
console.log(`Opening app: ${app.name}`)
// 載入收藏列表
const loadFavorites = async (page: number = 1) => {
if (!user) return
setIsLoading(true)
try {
const response = await fetch(`/api/user/favorites?userId=${user.id}&page=${page}&limit=12`)
const data = await response.json()
if (data.success) {
setFavoriteApps(data.data.apps)
setTotalPages(data.data.pagination.totalPages)
setCurrentPage(page)
}
} catch (error) {
console.error('載入收藏列表錯誤:', error)
} finally {
setIsLoading(false)
}
}
const filteredAndSortedApps = mockFavoriteApps
// 初始載入
useEffect(() => {
if (user) {
loadFavorites()
}
}, [user])
const handleUseApp = async (app: any) => {
console.log('handleUseApp 被調用', { user: user?.id, appId: app.id, appName: app.name })
try {
// Increment view count when using the app
if (user) {
const response = await fetch(`/api/apps/${app.id}/interactions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'view',
userId: user.id
})
})
if (response.ok) {
// 記錄用戶活動
try {
console.log('開始記錄用戶活動...')
const activityResponse = await fetch('/api/user/activity', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
action: 'view',
resourceType: 'app',
resourceId: app.id,
details: {
appName: app.name,
timestamp: new Date().toISOString()
}
})
})
if (activityResponse.ok) {
console.log('活動記錄成功')
} else {
console.error('活動記錄失敗:', activityResponse.status, activityResponse.statusText)
}
} catch (error) {
console.error('記錄活動失敗:', error)
}
// Reload favorites to update view count
await loadFavorites(currentPage)
} else {
console.error('增加查看次數失敗:', response.status, response.statusText)
}
} else {
console.log('用戶未登入,跳過活動記錄')
}
} catch (error) {
console.error('增加查看次數失敗:', error)
}
// Open app in new tab
if (app.appUrl) {
const url = app.appUrl.startsWith('http') ? app.appUrl : `https://${app.appUrl}`
window.open(url, "_blank", "noopener,noreferrer")
} else {
console.log(`Opening app: ${app.name}`)
}
}
// 圖標映射函數
const getIconComponent = (iconName: string) => {
const iconMap: { [key: string]: any } = {
'Bot': Brain,
'ImageIcon': ImageIcon,
'Mic': Mic,
'MessageSquare': MessageSquareIcon,
'Settings': Settings,
'Zap': Zap,
'TrendingUp': TrendingUp,
'Star': Star,
'Heart': Heart,
'Eye': Eye,
'Target': Target,
'Users': Users,
'Lightbulb': Lightbulb,
'Search': Search,
}
return iconMap[iconName] || Heart
}
const getTypeColor = (type: string) => {
const colors = {
: "bg-blue-100 text-blue-800 border-blue-200",
: "bg-purple-100 text-purple-800 border-purple-200",
: "bg-green-100 text-green-800 border-green-200",
: "bg-orange-100 text-orange-800 border-orange-200",
: "bg-pink-100 text-pink-800 border-pink-200",
: "bg-indigo-100 text-indigo-800 border-indigo-200",
: "bg-cyan-100 text-cyan-800 border-cyan-200",
: "bg-teal-100 text-teal-800 border-teal-200",
: "bg-yellow-100 text-yellow-800 border-yellow-200",
: "bg-rose-100 text-rose-800 border-rose-200",
: "bg-emerald-100 text-emerald-800 border-emerald-200",
: "bg-violet-100 text-violet-800 border-violet-200",
}
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
const filteredAndSortedApps = favoriteApps
.filter((app) => filterDepartment === "all" || app.department === filterDepartment)
.sort((a, b) => {
switch (sortBy) {
@@ -32,6 +162,8 @@ export function FavoritesPage() {
return a.creator.localeCompare(b.creator)
case "department":
return a.department.localeCompare(b.department)
case "favorited":
return new Date(b.favoritedAt).getTime() - new Date(a.favoritedAt).getTime()
default:
return 0
}
@@ -39,91 +171,163 @@ export function FavoritesPage() {
return (
<div className="space-y-6">
{/* Stats */}
<div className="flex items-center justify-end">
<div className="text-sm text-gray-500">
{favoriteApps.length}
</div>
</div>
{/* Filter and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex gap-3">
<div className="flex flex-col sm:flex-row gap-4">
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
<SelectTrigger className="w-48">
<SelectValue placeholder="選擇部門" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="HR">HR</SelectItem>
<SelectItem value="Finance">Finance</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-32">
<SelectTrigger className="w-48">
<SelectValue placeholder="排序方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name"></SelectItem>
<SelectItem value="creator"></SelectItem>
<SelectItem value="department"></SelectItem>
</SelectContent>
</Select>
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
<SelectTrigger className="w-32">
<SelectValue placeholder="部門篩選" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
<SelectItem value="name"></SelectItem>
<SelectItem value="creator"></SelectItem>
<SelectItem value="department"></SelectItem>
<SelectItem value="favorited"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500"> {filteredAndSortedApps.length} </div>
</div>
{/* Favorites Grid */}
{filteredAndSortedApps.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAndSortedApps.map((app) => (
<Card key={app.id} className="h-full flex flex-col hover:shadow-lg transition-shadow">
<CardContent className="p-6 flex flex-col h-full">
{/* Header with heart icon */}
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex-1 pr-2">{app.name}</h3>
<Heart className="w-5 h-5 text-red-500 fill-current flex-shrink-0" />
</div>
{/* Description */}
<p className="text-gray-600 text-sm mb-4 line-clamp-2 flex-grow">{app.description}</p>
{/* Developer and Department */}
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-700">: {app.creator}</span>
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200">
{app.department}
</Badge>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-2 mb-6 flex-grow">
{app.tags.map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
{/* Action Button */}
<div className="mt-auto flex-shrink-0">
<Button className="w-full bg-black hover:bg-gray-800 text-white" onClick={() => handleUseApp(app)}>
<ExternalLink className="w-4 h-4 mr-2" />
使
</Button>
</div>
</CardContent>
</Card>
))}
{/* Loading State */}
{isLoading && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-2"></div>
<p className="text-gray-500">...</p>
</div>
) : (
)}
{/* Apps Grid */}
{!isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAndSortedApps.map((app) => {
const IconComponent = getIconComponent(app.icon || 'Heart')
return (
<Card key={app.id} className="group hover:shadow-lg transition-all duration-300">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
<div className={`w-12 h-12 bg-gradient-to-r ${app.iconColor || 'from-purple-500 to-pink-500'} rounded-lg flex items-center justify-center`}>
<IconComponent className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-semibold text-lg group-hover:text-purple-600 transition-colors">
{app.name}
</h3>
<p className="text-sm text-gray-500">by {app.creator}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleUseApp(app)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<ExternalLink className="w-4 h-4" />
</Button>
</div>
<p className="text-gray-600 mb-4 line-clamp-2">{app.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="secondary" className={getTypeColor(app.type)}>
{app.type}
</Badge>
<Badge variant="outline">{app.department}</Badge>
</div>
{/* 統計數據區塊 - 優化佈局 */}
<div className="space-y-4">
{/* 評分 - 突出顯示 */}
<div className="flex items-center justify-center bg-gradient-to-r from-yellow-50 to-orange-50 rounded-lg px-4 py-3 border border-yellow-200">
<Star className="w-5 h-5 text-yellow-500 mr-2" />
<span className="text-lg font-bold text-gray-800">{Number(app.rating).toFixed(1)}</span>
<span className="text-sm text-gray-500 ml-1"></span>
</div>
{/* 其他統計數據 - 3列佈局 */}
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col items-center space-y-1 text-sm text-gray-600 bg-gray-50 rounded-lg px-2 py-2">
<Eye className="w-4 h-4 text-blue-500" />
<span className="font-medium text-base">{app.views}</span>
<span className="text-xs text-gray-400"></span>
</div>
<div className="flex flex-col items-center space-y-1 text-sm text-gray-600 bg-gray-50 rounded-lg px-2 py-2">
<ThumbsUp className="w-4 h-4 text-red-500" />
<span className="font-medium text-base">{app.likes}</span>
<span className="text-xs text-gray-400"></span>
</div>
<div className="flex flex-col items-center space-y-1 text-sm text-gray-600 bg-gray-50 rounded-lg px-2 py-2">
<MessageSquare className="w-4 h-4 text-green-500" />
<span className="font-medium text-base">{app.reviewCount}</span>
<span className="text-xs text-gray-400"></span>
</div>
</div>
{/* 使用應用按鈕 */}
<Button
onClick={() => handleUseApp(app)}
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white py-2.5"
>
使
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* Empty State */}
{!isLoading && filteredAndSortedApps.length === 0 && (
<div className="text-center py-12">
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-600 mb-2"></h3>
<p className="text-gray-500">
{filterDepartment !== "all"
? "該部門暫無收藏的應用,請嘗試其他篩選條件"
: "您還沒有收藏任何應用,快去探索並收藏您喜歡的 AI 應用吧!"}
</p>
<h3 className="text-lg font-semibold text-gray-500 mb-2"></h3>
<p className="text-gray-400"> AI </p>
</div>
)}
{/* Pagination */}
{!isLoading && totalPages > 1 && (
<div className="flex justify-center space-x-2">
<Button
variant="outline"
onClick={() => loadFavorites(currentPage - 1)}
disabled={currentPage === 1}
>
</Button>
<span className="flex items-center px-4 text-sm text-gray-500">
{currentPage} {totalPages}
</span>
<Button
variant="outline"
onClick={() => loadFavorites(currentPage + 1)}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</div>
)
}
}

View File

@@ -2,7 +2,7 @@
import type React from "react"
import { useState } from "react"
import { useState, useEffect } from "react"
import { ThumbsUp } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/contexts/auth-context"
@@ -14,15 +14,40 @@ interface LikeButtonProps {
size?: "sm" | "default" | "lg"
className?: string
showCount?: boolean
likeCount?: number
userLiked?: boolean
}
export function LikeButton({ appId, size = "default", className, showCount = true }: LikeButtonProps) {
const { user, likeApp, getAppLikes, hasLikedToday } = useAuth()
export function LikeButton({ appId, size = "default", className, showCount = true, likeCount: propLikeCount, userLiked: propUserLiked }: LikeButtonProps) {
const { user, toggleLike, getAppLikes, isLiked } = useAuth()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const [localLikeCount, setLocalLikeCount] = useState(propLikeCount || 0)
const [localUserLiked, setLocalUserLiked] = useState(propUserLiked || false)
const likeCount = getAppLikes(appId)
const hasLiked = user ? hasLikedToday(appId) : false
const likeCount = localLikeCount
const hasLiked = user ? isLiked(appId) : localUserLiked
// 載入用戶的按讚狀態
useEffect(() => {
if (user) {
const liked = isLiked(appId)
setLocalUserLiked(liked)
}
}, [user, appId, isLiked])
// 同步外部 props 變化
useEffect(() => {
if (propLikeCount !== undefined) {
setLocalLikeCount(propLikeCount)
}
}, [propLikeCount])
useEffect(() => {
if (propUserLiked !== undefined) {
setLocalUserLiked(propUserLiked)
}
}, [propUserLiked])
const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation()
@@ -36,25 +61,37 @@ export function LikeButton({ appId, size = "default", className, showCount = tru
return
}
if (hasLiked) {
toast({
title: "今日已按讚",
description: "您今天已經為這個應用按過讚了",
variant: "destructive",
})
return
}
setIsLoading(true)
// 立即更新本地狀態
const newLikedState = !hasLiked
const newLikeCount = hasLiked ? likeCount - 1 : likeCount + 1
setLocalUserLiked(newLikedState)
setLocalLikeCount(newLikeCount)
try {
await likeApp(appId)
toast({
title: "按讚成功!",
description: "感謝您的支持",
})
const success = await toggleLike(appId)
if (newLikedState) {
// 剛剛按讚了
toast({
title: "按讚成功!",
description: "感謝您的支持",
})
} else {
// 剛剛取消按讚了
toast({
title: "取消按讚",
description: "已取消對該應用的按讚",
})
}
} catch (error) {
console.error("按讚操作失敗:", error)
// 如果操作失敗,回滾本地狀態
setLocalUserLiked(hasLiked)
setLocalLikeCount(likeCount)
toast({
title: "按讚失敗",
title: "操作失敗",
description: "請稍後再試",
variant: "destructive",
})
@@ -85,7 +122,7 @@ export function LikeButton({ appId, size = "default", className, showCount = tru
sizeClasses[size],
"flex items-center",
hasLiked
? "text-blue-600 hover:text-blue-700 hover:bg-blue-50"
? "text-blue-600 bg-blue-50 border-blue-200 hover:text-blue-700 hover:bg-blue-100"
: "text-gray-500 hover:text-blue-600 hover:bg-blue-50",
"transition-all duration-200",
className,

View File

@@ -0,0 +1,496 @@
"use client"
import { useState, useEffect } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Separator } from "@/components/ui/separator"
import { Star, MessageSquare, ThumbsUp, ThumbsDown, Edit, Trash2, MoreHorizontal } from "lucide-react"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
interface Review {
id: string
userId: string
userName: string
userAvatar?: string
userDepartment: string
rating: number
comment: string
createdAt: string
updatedAt?: string
helpful: number
notHelpful: number
userHelpfulVotes: string[] // user IDs who voted helpful
userNotHelpfulVotes: string[] // user IDs who voted not helpful
}
interface ReviewSystemProps {
appId: string
appName: string
currentRating: number
onRatingUpdate: (newRating: number, reviewCount: number) => void
}
export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }: ReviewSystemProps) {
const { user, updateAppRating } = useAuth()
// Load reviews from database
const [reviews, setReviews] = useState<Review[]>([])
const [isLoadingReviews, setIsLoadingReviews] = useState(true)
const [showReviewForm, setShowReviewForm] = useState(false)
const [newRating, setNewRating] = useState(5)
const [newComment, setNewComment] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [editingReview, setEditingReview] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<"newest" | "oldest" | "helpful">("newest")
// Load reviews from database
const loadReviews = async () => {
try {
setIsLoadingReviews(true)
const response = await fetch(`/api/apps/${appId}/reviews?limit=100`) // 載入更多評論
if (response.ok) {
const data = await response.json()
if (data.success) {
// Transform database format to component format
const transformedReviews = data.data.reviews.map((review: any) => ({
id: review.id,
userId: review.user_id || 'unknown',
userName: review.userName || review.user_name || '未知用戶',
userAvatar: review.userAvatar || review.user_avatar,
userDepartment: review.userDepartment || review.user_department || '未知部門',
rating: review.rating,
comment: review.review || review.comment || '',
createdAt: review.ratedAt || review.rated_at || new Date().toISOString(),
helpful: 0,
notHelpful: 0,
userHelpfulVotes: [],
userNotHelpfulVotes: [],
}))
setReviews(transformedReviews)
}
}
} catch (error) {
console.error('載入評論錯誤:', error)
} finally {
setIsLoadingReviews(false)
}
}
// Load reviews on component mount
useEffect(() => {
loadReviews()
}, [appId])
const userReview = reviews.find((review) => review.userId === user?.id)
const canReview = user && !userReview
// Update rating when reviews change
useEffect(() => {
if (reviews.length > 0) {
const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
const newAvgRating = Number(avgRating.toFixed(1))
updateAppRating(appId, newAvgRating)
onRatingUpdate(newAvgRating, reviews.length)
} else {
updateAppRating(appId, 0)
onRatingUpdate(0, 0)
}
}, [reviews, appId, updateAppRating, onRatingUpdate])
const handleSubmitReview = async () => {
if (!user || !newComment.trim()) return
setIsSubmitting(true)
try {
const response = await fetch(`/api/apps/${appId}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
rating: newRating,
comment: newComment.trim(),
}),
})
if (response.ok) {
const data = await response.json()
if (data.success) {
// Reload reviews from database
await loadReviews()
setNewComment("")
setNewRating(5)
setShowReviewForm(false)
} else {
console.error('提交評論失敗:', data.error)
alert('提交評論失敗,請稍後再試')
}
} else {
console.error('提交評論失敗:', response.statusText)
alert('提交評論失敗,請稍後再試')
}
} catch (error) {
console.error('提交評論錯誤:', error)
alert('提交評論時發生錯誤,請稍後再試')
} finally {
setIsSubmitting(false)
}
}
const handleEditReview = async (reviewId: string) => {
if (!user || !newComment.trim()) return
setIsSubmitting(true)
try {
const response = await fetch(`/api/apps/${appId}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
rating: newRating,
comment: newComment.trim(),
}),
})
if (response.ok) {
const data = await response.json()
if (data.success) {
// Reload reviews from database
await loadReviews()
setEditingReview(null)
setNewComment("")
setNewRating(5)
} else {
console.error('更新評論失敗:', data.error)
alert('更新評論失敗,請稍後再試')
}
} else {
console.error('更新評論失敗:', response.statusText)
alert('更新評論失敗,請稍後再試')
}
} catch (error) {
console.error('更新評論錯誤:', error)
alert('更新評論時發生錯誤,請稍後再試')
} finally {
setIsSubmitting(false)
}
}
const handleDeleteReview = async (reviewId: string) => {
// For now, we'll just reload reviews since we don't have a delete API yet
// In a real implementation, you would call a DELETE API endpoint
await loadReviews()
}
const handleHelpfulVote = (reviewId: string, isHelpful: boolean) => {
if (!user) return
// For now, we'll just reload reviews since we don't have a helpful vote API yet
// In a real implementation, you would call an API endpoint to handle helpful votes
loadReviews()
}
const startEdit = (review: Review) => {
setEditingReview(review.id)
setNewRating(review.rating)
setNewComment(review.comment)
setShowReviewForm(true)
}
const cancelEdit = () => {
setEditingReview(null)
setNewComment("")
setNewRating(5)
setShowReviewForm(false)
}
const getInitials = (name: string) => {
return name.split("").slice(0, 2).join("").toUpperCase()
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("zh-TW", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
const renderStars = (rating: number, interactive = false, onRate?: (rating: number) => void) => {
return (
<div className="flex items-center space-x-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${
star <= rating ? "text-yellow-400 fill-current" : "text-gray-300"
} ${interactive ? "cursor-pointer hover:text-yellow-400" : ""}`}
onClick={() => interactive && onRate && onRate(star)}
/>
))}
</div>
)
}
const sortedReviews = [...reviews].sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
case "helpful":
return b.helpful - a.helpful
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
}
})
return (
<div className="space-y-6">
{/* Review Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription>
{isLoadingReviews ? (
"載入評價中..."
) : reviews.length > 0 ? (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
{renderStars(Math.round(currentRating))}
<span className="font-semibold">{Number(currentRating).toFixed(1)}</span>
<span className="text-gray-500">({reviews.length} )</span>
</div>
</div>
) : (
"尚無評價,成為第一個評價的用戶!"
)}
</CardDescription>
</CardHeader>
<CardContent>
{/* Rating Distribution */}
{reviews.length > 0 && (
<div className="space-y-2 mb-6">
{[5, 4, 3, 2, 1].map((rating) => {
const count = reviews.filter((r) => r.rating === rating).length
const percentage = (count / reviews.length) * 100
return (
<div key={rating} className="flex items-center space-x-3">
<div className="flex items-center space-x-1 w-12">
<span className="text-sm">{rating}</span>
<Star className="w-3 h-3 text-yellow-400 fill-current" />
</div>
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className="bg-yellow-400 h-2 rounded-full transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-sm text-gray-500 w-8">{count}</span>
</div>
)
})}
</div>
)}
{/* Add Review Button */}
{canReview && (
<Button
onClick={() => setShowReviewForm(true)}
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
<Star className="w-4 h-4 mr-2" />
</Button>
)}
{userReview && (
<Alert>
<MessageSquare className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Review Form */}
<Dialog open={showReviewForm} onOpenChange={setShowReviewForm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingReview ? "編輯評價" : "撰寫評價"}</DialogTitle>
<DialogDescription> {appName} 使</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block"></label>
{renderStars(newRating, true, setNewRating)}
</div>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<Textarea
placeholder="請分享您的使用體驗..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={4}
maxLength={500}
/>
<div className="text-xs text-gray-500 mt-1">{newComment.length}/500 </div>
</div>
<div className="flex space-x-3">
<Button
onClick={editingReview ? () => handleEditReview(editingReview) : handleSubmitReview}
disabled={isSubmitting || !newComment.trim()}
className="flex-1"
>
{isSubmitting ? "提交中..." : editingReview ? "更新評價" : "提交評價"}
</Button>
<Button variant="outline" onClick={cancelEdit}>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Reviews List */}
{isLoadingReviews ? (
<Card>
<CardContent className="p-6">
<div className="text-center text-gray-500">...</div>
</CardContent>
</Card>
) : reviews.length > 0 ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle> ({reviews.length})</CardTitle>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500"></span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="text-sm border rounded px-2 py-1"
>
<option value="newest"></option>
<option value="oldest"></option>
<option value="helpful"></option>
</select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{sortedReviews.map((review, index) => (
<div key={review.id}>
<div className="flex items-start space-x-4">
<Avatar className="w-10 h-10">
<AvatarImage src={review.userAvatar || "/placeholder.svg"} alt={review.userName} />
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
{getInitials(review.userName)}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="font-medium">{review.userName}</span>
<Badge variant="secondary" className="text-xs">
{review.userDepartment}
</Badge>
{renderStars(review.rating)}
</div>
{user?.id === review.userId && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => startEdit(review)}>
<Edit className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteReview(review.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<p className="text-gray-700">{review.comment}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center space-x-4">
<span>
{formatDate(review.createdAt)}
{review.updatedAt && " (已編輯)"}
</span>
</div>
{user && user.id !== review.userId && (
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleHelpfulVote(review.id, true)}
className={`text-xs ${
review.userHelpfulVotes.includes(user.id)
? "text-green-600 bg-green-50"
: "text-gray-500"
}`}
>
<ThumbsUp className="w-3 h-3 mr-1" />
({review.helpful})
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleHelpfulVote(review.id, false)}
className={`text-xs ${
review.userNotHelpfulVotes.includes(user.id)
? "text-red-600 bg-red-50"
: "text-gray-500"
}`}
>
<ThumbsDown className="w-3 h-3 mr-1" />
({review.notHelpful})
</Button>
</div>
)}
</div>
</div>
</div>
{index < sortedReviews.length - 1 && <Separator className="mt-6" />}
</div>
))}
</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect, useCallback } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -27,6 +27,7 @@ interface Review {
notHelpful: number
userHelpfulVotes: string[] // user IDs who voted helpful
userNotHelpfulVotes: string[] // user IDs who voted not helpful
userVote?: boolean // true = 有幫助, false = 沒幫助, undefined = 未投票
}
interface ReviewSystemProps {
@@ -39,13 +40,16 @@ interface ReviewSystemProps {
export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }: ReviewSystemProps) {
const { user, updateAppRating } = useAuth()
// Load reviews from localStorage
const [reviews, setReviews] = useState<Review[]>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(`reviews_${appId}`)
return saved ? JSON.parse(saved) : []
}
return []
// Load reviews from database
const [reviews, setReviews] = useState<Review[]>([])
const [isLoadingReviews, setIsLoadingReviews] = useState(true)
const [pagination, setPagination] = useState({
page: 1,
limit: 5,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false
})
const [showReviewForm, setShowReviewForm] = useState(false)
@@ -55,55 +59,123 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
const [editingReview, setEditingReview] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<"newest" | "oldest" | "helpful">("newest")
const userReview = reviews.find((review) => review.userId === user?.id)
const canReview = user && !userReview
// Save reviews to localStorage and update app rating
const saveReviews = (updatedReviews: Review[]) => {
if (typeof window !== "undefined") {
localStorage.setItem(`reviews_${appId}`, JSON.stringify(updatedReviews))
// Load reviews from database
const loadReviews = useCallback(async (page: number = 1) => {
try {
setIsLoadingReviews(true)
const response = await fetch(`/api/apps/${appId}/reviews?page=${page}&limit=100`)
if (response.ok) {
const data = await response.json()
if (data.success) {
// Transform database format to component format
const transformedReviews = data.data.reviews.map((review: any) => ({
id: review.id,
userId: review.user_id || 'unknown',
userName: review.userName || review.user_name || '未知用戶',
userAvatar: review.userAvatar || review.user_avatar,
userDepartment: review.userDepartment || review.user_department || '未知部門',
rating: Number(review.rating) || 0, // 確保 rating 是數字
comment: review.review || review.comment || '', // 使用 review 字段
createdAt: review.ratedAt || review.rated_at || new Date().toISOString(),
helpful: 0,
notHelpful: 0,
userHelpfulVotes: [],
userNotHelpfulVotes: [],
}))
// 載入每個評論的投票統計
for (const review of transformedReviews) {
try {
const voteUrl = `/api/reviews/${review.id}/votes${user ? `?userId=${user.id}` : ''}`
const voteResponse = await fetch(voteUrl)
if (voteResponse.ok) {
const voteData = await voteResponse.json()
if (voteData.success) {
review.helpful = voteData.data.helpful
review.notHelpful = voteData.data.notHelpful
review.userVote = voteData.data.userVote // 用戶的投票狀態
}
} else {
console.error('投票 API 響應失敗:', voteResponse.status, voteResponse.statusText)
}
} catch (error) {
console.error('載入評論投票錯誤:', error)
}
}
setReviews(transformedReviews)
setPagination(data.data.pagination)
}
}
} catch (error) {
console.error('載入評論錯誤:', error)
} finally {
setIsLoadingReviews(false)
}
setReviews(updatedReviews)
}, [appId, user])
// Calculate new average rating and update in context
if (updatedReviews.length > 0) {
const avgRating = updatedReviews.reduce((sum, r) => sum + r.rating, 0) / updatedReviews.length
// Load reviews on component mount
useEffect(() => {
loadReviews()
}, [loadReviews])
const userReview = reviews.find((review) => review.userId === user?.id)
const canReview = user // 允許用戶多次評論,移除 !userReview 限制
// Update rating when reviews change
useEffect(() => {
if (reviews.length > 0) {
const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
const newAvgRating = Number(avgRating.toFixed(1))
updateAppRating(appId, newAvgRating)
onRatingUpdate(newAvgRating, updatedReviews.length)
onRatingUpdate(newAvgRating, reviews.length)
} else {
updateAppRating(appId, 0)
onRatingUpdate(0, 0)
}
}
}, [reviews, appId]) // 移除函數依賴項以避免無限循環
const handleSubmitReview = async () => {
if (!user || !newComment.trim()) return
setIsSubmitting(true)
const review: Review = {
id: `r${Date.now()}`,
userId: user.id,
userName: user.name,
userAvatar: user.avatar,
userDepartment: user.department,
rating: newRating,
comment: newComment.trim(),
createdAt: new Date().toISOString(),
helpful: 0,
notHelpful: 0,
userHelpfulVotes: [],
userNotHelpfulVotes: [],
try {
const response = await fetch(`/api/apps/${appId}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
rating: newRating,
comment: newComment.trim(),
}),
})
if (response.ok) {
const data = await response.json()
if (data.success) {
// Reload reviews from database
await loadReviews()
setNewComment("")
setNewRating(5)
setShowReviewForm(false)
} else {
console.error('提交評論失敗:', data.error)
alert('提交評論失敗,請稍後再試')
}
} else {
console.error('提交評論失敗:', response.statusText)
alert('提交評論失敗,請稍後再試')
}
} catch (error) {
console.error('提交評論錯誤:', error)
alert('提交評論時發生錯誤,請稍後再試')
} finally {
setIsSubmitting(false)
}
const updatedReviews = [...reviews, review]
saveReviews(updatedReviews)
setNewComment("")
setNewRating(5)
setShowReviewForm(false)
setIsSubmitting(false)
}
const handleEditReview = async (reviewId: string) => {
@@ -111,77 +183,86 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
setIsSubmitting(true)
const updatedReviews = reviews.map((review) =>
review.id === reviewId
? {
...review,
rating: newRating,
comment: newComment.trim(),
updatedAt: new Date().toISOString(),
}
: review,
)
try {
const response = await fetch(`/api/apps/${appId}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
rating: newRating,
comment: newComment.trim(),
reviewId: reviewId, // 傳遞 reviewId 用於更新
}),
})
saveReviews(updatedReviews)
setEditingReview(null)
setNewComment("")
setNewRating(5)
setIsSubmitting(false)
if (response.ok) {
const data = await response.json()
if (data.success) {
// Reload reviews from database
await loadReviews()
setEditingReview(null)
setNewComment("")
setNewRating(5)
} else {
console.error('更新評論失敗:', data.error)
alert('更新評論失敗,請稍後再試')
}
} else {
console.error('更新評論失敗:', response.statusText)
alert('更新評論失敗,請稍後再試')
}
} catch (error) {
console.error('更新評論錯誤:', error)
alert('更新評論時發生錯誤,請稍後再試')
} finally {
setIsSubmitting(false)
}
}
const handleDeleteReview = async (reviewId: string) => {
const updatedReviews = reviews.filter((review) => review.id !== reviewId)
saveReviews(updatedReviews)
// For now, we'll just reload reviews since we don't have a delete API yet
// In a real implementation, you would call a DELETE API endpoint
await loadReviews()
}
const handleHelpfulVote = (reviewId: string, isHelpful: boolean) => {
const handleHelpfulVote = async (reviewId: string, isHelpful: boolean) => {
if (!user) return
const updatedReviews = reviews.map((review) => {
if (review.id !== reviewId) return review
try {
const response = await fetch(`/api/reviews/${reviewId}/votes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
isHelpful: isHelpful,
}),
})
const helpfulVotes = [...review.userHelpfulVotes]
const notHelpfulVotes = [...review.userNotHelpfulVotes]
if (isHelpful) {
if (helpfulVotes.includes(user.id)) {
// Remove helpful vote
const index = helpfulVotes.indexOf(user.id)
helpfulVotes.splice(index, 1)
} else {
// Add helpful vote and remove not helpful if exists
helpfulVotes.push(user.id)
const notHelpfulIndex = notHelpfulVotes.indexOf(user.id)
if (notHelpfulIndex > -1) {
notHelpfulVotes.splice(notHelpfulIndex, 1)
}
}
} else {
if (notHelpfulVotes.includes(user.id)) {
// Remove not helpful vote
const index = notHelpfulVotes.indexOf(user.id)
notHelpfulVotes.splice(index, 1)
} else {
// Add not helpful vote and remove helpful if exists
notHelpfulVotes.push(user.id)
const helpfulIndex = helpfulVotes.indexOf(user.id)
if (helpfulIndex > -1) {
helpfulVotes.splice(helpfulIndex, 1)
}
if (response.ok) {
const data = await response.json()
if (data.success) {
// 更新本地評論數據
setReviews(prevReviews =>
prevReviews.map(review =>
review.id === reviewId
? {
...review,
helpful: data.data.helpful,
notHelpful: data.data.notHelpful,
userVote: data.data.userVote
}
: review
)
)
}
}
return {
...review,
helpful: helpfulVotes.length,
notHelpful: notHelpfulVotes.length,
userHelpfulVotes: helpfulVotes,
userNotHelpfulVotes: notHelpfulVotes,
}
})
saveReviews(updatedReviews)
} catch (error) {
console.error('投票錯誤:', error)
}
}
const startEdit = (review: Review) => {
@@ -198,18 +279,6 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
setShowReviewForm(false)
}
const sortedReviews = [...reviews].sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
case "helpful":
return b.helpful - a.helpful
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
}
})
const getInitials = (name: string) => {
return name.split("").slice(0, 2).join("").toUpperCase()
}
@@ -240,6 +309,18 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
)
}
const sortedReviews = [...reviews].sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
case "helpful":
return b.helpful - a.helpful
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
}
})
return (
<div className="space-y-6">
{/* Review Summary */}
@@ -250,11 +331,13 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
<span></span>
</CardTitle>
<CardDescription>
{reviews.length > 0 ? (
{isLoadingReviews ? (
"載入評價中..."
) : reviews.length > 0 ? (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
{renderStars(Math.round(currentRating))}
<span className="font-semibold">{currentRating}</span>
<span className="font-semibold">{Number(currentRating).toFixed(1)}</span>
<span className="text-gray-500">({reviews.length} )</span>
</div>
</div>
@@ -270,6 +353,7 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
{[5, 4, 3, 2, 1].map((rating) => {
const count = reviews.filter((r) => r.rating === rating).length
const percentage = (count / reviews.length) * 100
console.log(`評分 ${rating}: count=${count}, percentage=${percentage}%`) // 調試信息
return (
<div key={rating} className="flex items-center space-x-3">
<div className="flex items-center space-x-1 w-12">
@@ -300,12 +384,7 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
</Button>
)}
{userReview && (
<Alert>
<MessageSquare className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
)}
{/* 移除「已評價過」的警告,允許用戶多次評論 */}
</CardContent>
</Card>
@@ -352,11 +431,17 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
</Dialog>
{/* Reviews List */}
{reviews.length > 0 && (
{isLoadingReviews ? (
<Card>
<CardContent className="p-6">
<div className="text-center text-gray-500">...</div>
</CardContent>
</Card>
) : reviews.length > 0 ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle> ({reviews.length})</CardTitle>
<CardTitle> ({pagination.total})</CardTitle>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500"></span>
<select
@@ -431,12 +516,12 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
size="sm"
onClick={() => handleHelpfulVote(review.id, true)}
className={`text-xs ${
review.userHelpfulVotes.includes(user.id)
? "text-green-600 bg-green-50"
: "text-gray-500"
review.userVote === true
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-green-600"
}`}
>
<ThumbsUp className="w-3 h-3 mr-1" />
<ThumbsUp className={`w-3 h-3 mr-1 ${review.userVote === true ? "fill-current" : ""}`} />
({review.helpful})
</Button>
<Button
@@ -444,12 +529,12 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
size="sm"
onClick={() => handleHelpfulVote(review.id, false)}
className={`text-xs ${
review.userNotHelpfulVotes.includes(user.id)
? "text-red-600 bg-red-50"
: "text-gray-500"
review.userVote === false
? "text-red-600 bg-red-50 border-red-200"
: "text-gray-500 hover:text-red-600"
}`}
>
<ThumbsDown className="w-3 h-3 mr-1" />
<ThumbsDown className={`w-3 h-3 mr-1 ${review.userVote === false ? "fill-current" : ""}`} />
({review.notHelpful})
</Button>
</div>
@@ -462,9 +547,39 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
</div>
))}
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<div className="text-sm text-gray-500">
{((pagination.page - 1) * pagination.limit) + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} {pagination.total}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => loadReviews(pagination.page - 1)}
disabled={!pagination.hasPrev || isLoadingReviews}
>
</Button>
<span className="text-sm text-gray-500">
{pagination.page} / {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => loadReviews(pagination.page + 1)}
disabled={!pagination.hasNext || isLoadingReviews}
>
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
) : null}
</div>
)
}