Files
ai-showcase-platform/components/app-detail-dialog.tsx

766 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
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"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Star,
Eye,
Heart,
ThumbsUp,
Info,
MessageSquare,
User,
Calendar,
Building,
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"
interface AppDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
app: {
id: number
name: string
type: string
department: string
description: string
icon: any
creator: string
featured: boolean
judgeScore: number
}
}
// Usage statistics data - empty for production
const getAppUsageStats = (appId: string, startDate: string, endDate: string) => {
return {
dailyUsers: 0,
weeklyUsers: 0,
monthlyUsers: 0,
totalSessions: 0,
topDepartments: [],
trendData: [],
}
}
export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProps) {
const { user, addToRecentApps, getAppLikes, incrementViewCount, getViewCount, getAppRating } = useAuth()
const [currentRating, setCurrentRating] = useState(getAppRating(app.id.toString()))
const [reviewCount, setReviewCount] = useState(0)
const [appStats, setAppStats] = useState({
basic: {
views: 0,
likes: 0,
favorites: 0,
rating: 0,
reviewCount: 0
},
usage: {
dailyUsers: 0,
weeklyUsers: 0,
monthlyUsers: 0,
totalSessions: 0,
topDepartments: [],
trendData: []
}
})
const [isLoadingStats, setIsLoadingStats] = useState(false)
// Date range for usage trends
const [startDate, setStartDate] = useState(() => {
const date = new Date()
date.setDate(date.getDate() - 6) // Default to last 7 days
return date.toISOString().split("T")[0]
})
const [endDate, setEndDate] = useState(() => {
return new Date().toISOString().split("T")[0]
})
// 圖標映射函數
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 = {
: "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",
}
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
// 載入應用統計數據
const loadAppStats = useCallback(async (customStartDate?: string, customEndDate?: string) => {
if (!app.id) return
setIsLoadingStats(true)
try {
// 構建查詢參數
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) {
setAppStats(data.data)
setCurrentRating(data.data.basic.rating)
setReviewCount(data.data.basic.reviewCount)
}
} catch (error) {
console.error('載入應用統計數據錯誤:', error)
} 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, loadAppStats])
// 當日期範圍變更時重新載入使用趨勢數據
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
await incrementViewCount(app.id.toString())
// Reload stats after incrementing view count
await loadAppStats()
// Get app URL from database or fallback to default URLs
const appUrl = (app as any).appUrl || (app as any).app_url
if (appUrl) {
// 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('此應用暫無可用連結')
}
}
}
// Helper function to group data by month/year for section headers
const getDateSections = (trendData: any[]) => {
const sections: { [key: string]: { startIndex: number; endIndex: number; label: string } } = {}
trendData.forEach((day, index) => {
const date = new Date(day.date)
const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
const label = date.toLocaleDateString("zh-TW", { year: "numeric", month: "long" })
if (!sections[yearMonth]) {
sections[yearMonth] = { startIndex: index, endIndex: index, label }
} else {
sections[yearMonth].endIndex = index
}
})
return sections
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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 ${(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">
<DialogTitle className="text-2xl font-bold mb-2">{app.name}</DialogTitle>
<DialogDescription className="text-base mb-3">{app.description}</DialogDescription>
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="outline" className={getTypeColor(app.type)}>
{app.type}
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200">
{app.department}
</Badge>
{app.featured && (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 border-yellow-200">
<Star className="w-3 h-3 mr-1" />
</Badge>
)}
</div>
<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-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>
</div>
</DialogHeader>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview" className="flex items-center space-x-2">
<Info className="w-4 h-4" />
<span></span>
</TabsTrigger>
<TabsTrigger value="statistics" className="flex items-center space-x-2">
<BarChart3 className="w-4 h-4" />
<span>使</span>
</TabsTrigger>
<TabsTrigger value="reviews" className="flex items-center space-x-2">
<MessageSquare className="w-4 h-4" />
<span></span>
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center space-x-3">
<User className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{app.creator}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Building className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{app.department}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Calendar className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm text-gray-500"></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>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500 mb-2"></p>
<ul className="space-y-1 text-sm">
<li> {app.type} </li>
<li> {app.department} </li>
<li> </li>
<li> 使</li>
</ul>
</div>
</div>
</div>
<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 || '暫無詳細描述'}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="statistics" className="space-y-6">
{/* 基本統計數據 */}
<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>
<Eye className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">
{isLoadingStats ? '...' : views}
</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>
<ThumbsUp className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{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>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Star className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">
{isLoadingStats ? '...' : Number(rating).toFixed(1)}
</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>
<MessageSquare className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{isLoadingStats ? '...' : reviewsCount}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
</div>
{/* 使用趨勢 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle>
<Users className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{isLoadingStats ? '...' : appStats.usage.dailyUsers}
</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>
<TrendingUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{isLoadingStats ? '...' : appStats.usage.weeklyUsers}
</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>
<BarChart3 className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{isLoadingStats ? '...' : appStats.usage.totalSessions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>
</div>
{/* Usage Trends with Date Range */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>使</CardTitle>
<CardDescription>使</CardDescription>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<Label htmlFor="start-date" className="text-sm">
</Label>
<Input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-36"
max={endDate}
/>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="end-date" className="text-sm">
</Label>
<Input
id="end-date"
type="date"
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">
{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>
) : 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
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>
</Card>
{/* Department Usage */}
<Card>
<CardHeader>
<CardTitle>使</CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{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>
</TabsContent>
<TabsContent value="reviews" className="space-y-6">
<ReviewSystem
appId={app.id.toString()}
appName={app.name}
currentRating={currentRating}
onRatingUpdate={handleRatingUpdate}
/>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}