925 lines
39 KiB
TypeScript
925 lines
39 KiB
TypeScript
"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,
|
||
Bot,
|
||
Code,
|
||
Database,
|
||
Palette,
|
||
Volume2,
|
||
Camera,
|
||
Smartphone,
|
||
Monitor,
|
||
Globe,
|
||
FileText,
|
||
} from "lucide-react"
|
||
import { FavoriteButton } from "./favorite-button"
|
||
import { ReviewSystem } from "./reviews/review-system"
|
||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
|
||
|
||
interface AppDetailDialogProps {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
app: {
|
||
id: string
|
||
name: string
|
||
type: string
|
||
department: string
|
||
description: string
|
||
icon: any
|
||
iconColor?: string
|
||
creator: string
|
||
featured: boolean
|
||
judgeScore: number
|
||
likesCount?: number
|
||
viewsCount?: number
|
||
rating?: number
|
||
reviewsCount?: number
|
||
createdAt?: string
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
const [selectedDepartment, setSelectedDepartment] = useState<string | null>(null)
|
||
|
||
// 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 COLORS = ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#84cc16', '#f97316']
|
||
|
||
// 圖標映射函數
|
||
const getIconComponent = (iconName: string) => {
|
||
const iconMap: { [key: string]: any } = {
|
||
'Brain': Brain,
|
||
'Bot': Bot,
|
||
'Code': Code,
|
||
'Database': Database,
|
||
'Palette': Palette,
|
||
'Volume2': Volume2,
|
||
'Search': Search,
|
||
'BarChart3': BarChart3,
|
||
'Mic': Mic,
|
||
'ImageIcon': ImageIcon,
|
||
'MessageSquare': MessageSquare,
|
||
'Zap': Zap,
|
||
'TrendingUp': TrendingUp,
|
||
'Star': Star,
|
||
'Heart': Heart,
|
||
'Eye': Eye,
|
||
'Trophy': Trophy,
|
||
'Award': Award,
|
||
'Medal': Medal,
|
||
'Target': Target,
|
||
'Users': Users,
|
||
'Lightbulb': Lightbulb,
|
||
'Plus': Plus,
|
||
'X': X,
|
||
'ChevronLeft': ChevronLeft,
|
||
'ChevronRight': ChevronRight,
|
||
'ArrowLeft': ArrowLeft,
|
||
'Settings': Settings,
|
||
'Camera': Camera,
|
||
'Smartphone': Smartphone,
|
||
'Monitor': Monitor,
|
||
'Globe': Globe,
|
||
'FileText': FileText,
|
||
}
|
||
return iconMap[iconName] || Bot // 預設使用 Bot 圖標
|
||
}
|
||
|
||
const IconComponent = getIconComponent(app.icon || 'Bot')
|
||
// 優先使用從 API 載入的實際數據,如果沒有則使用 app 對象的數據
|
||
const likes = appStats.basic?.likes || (app as any).likesCount || 0
|
||
const views = appStats.basic?.views || (app as any).viewsCount || 0
|
||
const rating = appStats.basic?.rating || (app as any).rating || 0
|
||
const reviewsCount = appStats.basic?.reviewCount || (app as any).reviewsCount || 0
|
||
const favorites = appStats.basic?.favorites || 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, department?: string) => {
|
||
if (!app.id) return
|
||
|
||
setIsLoadingStats(true)
|
||
try {
|
||
// 構建查詢參數
|
||
const params = new URLSearchParams()
|
||
if (customStartDate) params.append('startDate', customStartDate)
|
||
if (customEndDate) params.append('endDate', customEndDate)
|
||
if (department) params.append('department', department)
|
||
|
||
const url = `/api/apps/${app.id}/stats${params.toString() ? `?${params.toString()}` : ''}`
|
||
|
||
const response = await fetch(url)
|
||
|
||
if (!response.ok) {
|
||
console.error('❌ API 響應錯誤:', response.status, response.statusText)
|
||
return
|
||
}
|
||
|
||
const data = await response.json()
|
||
|
||
if (data.success) {
|
||
setAppStats(data.data)
|
||
setCurrentRating(data.data.basic.rating)
|
||
setReviewCount(data.data.basic.reviewCount)
|
||
} else {
|
||
console.error('❌ 應用統計數據加載失敗:', data)
|
||
}
|
||
} 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, selectedDepartment || undefined)
|
||
}
|
||
}, [startDate, endDate, selectedDepartment, loadAppStats])
|
||
|
||
// 處理日期變更時重置部門選擇
|
||
const handleDateChange = useCallback((newStartDate: string, newEndDate: string) => {
|
||
setStartDate(newStartDate)
|
||
setEndDate(newEndDate)
|
||
setSelectedDepartment(null) // 重置部門選擇
|
||
}, [])
|
||
|
||
// 處理部門選擇
|
||
const handleDepartmentSelect = useCallback(async (department: string | null) => {
|
||
setSelectedDepartment(department)
|
||
if (startDate && endDate) {
|
||
await loadAppStats(startDate, endDate, department || undefined)
|
||
}
|
||
}, [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 () => {
|
||
|
||
if (user) {
|
||
addToRecentApps(app.id.toString())
|
||
|
||
// 記錄用戶活動
|
||
try {
|
||
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) {
|
||
} else {
|
||
console.error('活動記錄失敗:', response.status, response.statusText)
|
||
}
|
||
} catch (error) {
|
||
console.error('記錄活動失敗:', error)
|
||
}
|
||
} else {
|
||
}
|
||
|
||
// 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 ? '...' : favorites}
|
||
</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>
|
||
|
||
{/* Date Range Filter */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>數據篩選</CardTitle>
|
||
<CardDescription>選擇日期範圍查看部門使用分布和使用趨勢</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||
<div className="flex flex-col sm:flex-row gap-3 flex-1">
|
||
<div className="flex items-center space-x-2">
|
||
<Label htmlFor="start-date" className="text-sm whitespace-nowrap min-w-[60px]">
|
||
開始日期
|
||
</Label>
|
||
<Input
|
||
id="start-date"
|
||
type="date"
|
||
value={startDate}
|
||
onChange={(e) => handleDateChange(e.target.value, endDate)}
|
||
className="w-36"
|
||
max={endDate}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Label htmlFor="end-date" className="text-sm whitespace-nowrap min-w-[60px]">
|
||
結束日期
|
||
</Label>
|
||
<Input
|
||
id="end-date"
|
||
type="date"
|
||
value={endDate}
|
||
onChange={(e) => handleDateChange(startDate, e.target.value)}
|
||
className="w-36"
|
||
min={startDate}
|
||
max={new Date().toISOString().split("T")[0]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={handleDateRangeChange}
|
||
disabled={isLoadingStats || !startDate || !endDate}
|
||
size="sm"
|
||
variant="outline"
|
||
className="whitespace-nowrap"
|
||
>
|
||
{isLoadingStats ? (
|
||
<>
|
||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500 mr-1"></div>
|
||
載入中
|
||
</>
|
||
) : (
|
||
'重新載入'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Analytics Layout */}
|
||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||
{/* Department Usage Pie Chart */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>部門使用分布</CardTitle>
|
||
<CardDescription>點擊部門查看該部門的使用趨勢</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{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.topDepartments && usageStats.topDepartments.length > 0 ? (
|
||
<div className="space-y-4">
|
||
<ResponsiveContainer width="100%" height={300}>
|
||
<PieChart>
|
||
<Pie
|
||
data={usageStats.topDepartments.map((dept: any, index: number) => ({
|
||
name: dept.department || '未知部門',
|
||
value: dept.count,
|
||
color: COLORS[index % COLORS.length]
|
||
}))}
|
||
cx="50%"
|
||
cy="50%"
|
||
labelLine={false}
|
||
label={({ name, percent }) => `${name} ${((percent || 0) * 100).toFixed(0)}%`}
|
||
outerRadius={80}
|
||
fill="#8884d8"
|
||
dataKey="value"
|
||
onClick={(data) => {
|
||
const department = data.name === '未知部門' ? null : data.name
|
||
handleDepartmentSelect(department)
|
||
}}
|
||
className="cursor-pointer"
|
||
>
|
||
{usageStats.topDepartments.map((dept: any, index: number) => (
|
||
<Cell
|
||
key={`cell-${index}`}
|
||
fill={COLORS[index % COLORS.length]}
|
||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||
/>
|
||
))}
|
||
</Pie>
|
||
<Tooltip formatter={(value: any) => [`${value} 人`, '使用人數']} />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
|
||
{/* Department Legend */}
|
||
<div className="space-y-2">
|
||
{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
|
||
const isSelected = selectedDepartment === dept.department
|
||
|
||
return (
|
||
<div
|
||
key={dept.department || index}
|
||
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${
|
||
isSelected ? 'bg-blue-50 border border-blue-200' : 'hover:bg-gray-50'
|
||
}`}
|
||
onClick={() => {
|
||
const department = dept.department === '未知部門' ? null : dept.department
|
||
handleDepartmentSelect(department)
|
||
}}
|
||
>
|
||
<div className="flex items-center space-x-3">
|
||
<div
|
||
className="w-3 h-3 rounded-full"
|
||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||
/>
|
||
<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>
|
||
</div>
|
||
) : (
|
||
<div className="h-80 flex items-center justify-center bg-gray-50 rounded-lg">
|
||
<div className="text-center text-gray-500">
|
||
<Building className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||
<p>暫無部門使用數據</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Usage Trends */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>使用趨勢</CardTitle>
|
||
<CardDescription>
|
||
{selectedDepartment ? `${selectedDepartment} 部門的使用趨勢` : '查看指定時間範圍內的使用者活躍度'}
|
||
</CardDescription>
|
||
</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(400, usageStats.trendData.length * 80)}px`, // Dynamic width based on data points
|
||
}}
|
||
>
|
||
{/* Month/Year Section Headers - Full Width */}
|
||
<div className="absolute top-2 left-4 right-4 flex">
|
||
{(() => {
|
||
const sections = getDateSections(usageStats.trendData)
|
||
const totalBars = usageStats.trendData.length
|
||
const barWidth = 60 // 每個柱子寬度
|
||
const barGap = 12 // 柱子間距
|
||
const chartLeft = 20 // paddingLeft
|
||
const totalChartWidth = totalBars * barWidth + (totalBars - 1) * barGap
|
||
|
||
return Object.entries(sections).map(([key, section]) => {
|
||
// 計算該月份在柱狀圖中的實際位置
|
||
const sectionStartBar = section.startIndex
|
||
const sectionEndBar = section.endIndex
|
||
const sectionBarCount = sectionEndBar - sectionStartBar + 1
|
||
|
||
// 計算該月份標籤的起始位置(相對於圖表區域)
|
||
const sectionLeft = sectionStartBar * (barWidth + barGap)
|
||
const sectionWidth = sectionBarCount * barWidth + (sectionBarCount - 1) * barGap
|
||
|
||
// 轉換為相對於整個容器的百分比(從左邊界開始)
|
||
const containerWidth = chartLeft + totalChartWidth
|
||
const leftPercent = ((sectionLeft + chartLeft) / containerWidth) * 100
|
||
const widthPercent = (sectionWidth / containerWidth) * 100
|
||
|
||
return (
|
||
<div
|
||
key={key}
|
||
className="absolute text-xs font-medium text-gray-700 bg-blue-50 px-3 py-1 rounded shadow-sm border border-blue-200"
|
||
style={{
|
||
left: `${leftPercent}%`,
|
||
width: `${widthPercent}%`,
|
||
textAlign: "center",
|
||
minWidth: "60px", // 確保標籤有最小寬度
|
||
}}
|
||
>
|
||
{section.label}
|
||
</div>
|
||
)
|
||
})
|
||
})()}
|
||
</div>
|
||
|
||
{/* Y-axis labels and grid lines */}
|
||
<div className="absolute left-2 top-12 bottom-8 flex flex-col justify-between text-xs text-gray-500">
|
||
<span>{Math.max(...usageStats.trendData.map((d: any) => d.users))}</span>
|
||
<span>{Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.75)}</span>
|
||
<span>{Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.5)}</span>
|
||
<span>{Math.round(Math.max(...usageStats.trendData.map((d: any) => d.users)) * 0.25)}</span>
|
||
<span>0</span>
|
||
</div>
|
||
|
||
{/* Grid lines */}
|
||
<div className="absolute left-10 top-12 bottom-8 right-4 flex flex-col justify-between">
|
||
{[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => (
|
||
<div key={index} className="w-full h-px bg-gray-200 opacity-50"></div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Chart Bars */}
|
||
<div className="h-full flex items-end justify-start gap-3" style={{ paddingTop: "40px", paddingLeft: "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 flex-col items-center group relative"
|
||
style={{ width: "80px" }}
|
||
>
|
||
{/* 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-8 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 shadow-sm"
|
||
style={{ height: `${normalizedHeight}%` }}
|
||
>
|
||
{/* Value label */}
|
||
<div className="absolute -top-6 left-1/2 transform -translate-x-1/2 text-xs font-medium text-gray-700 bg-white/90 px-2 py-1 rounded shadow-sm border">
|
||
{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>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="reviews" className="space-y-6">
|
||
<ReviewSystem
|
||
appId={app.id.toString()}
|
||
appName={app.name}
|
||
currentRating={currentRating}
|
||
onRatingUpdate={handleRatingUpdate}
|
||
/>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|