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

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

View File

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