實作個人收藏、個人活動紀錄
This commit is contained in:
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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">2024年1月15日</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>
|
||||
|
@@ -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>
|
||||
|
@@ -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",
|
||||
)}
|
||||
/>
|
||||
|
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@@ -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,
|
||||
|
496
components/reviews/review-system-fixed.tsx
Normal file
496
components/reviews/review-system-fixed.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user