實作個人收藏、個人活動紀錄
This commit is contained in:
@@ -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>
|
||||
|
Reference in New Issue
Block a user