應用 APP 功能實作

This commit is contained in:
2025-09-09 18:18:02 +08:00
parent 22bbe64349
commit 900e33aefa
22 changed files with 2745 additions and 242 deletions

View File

@@ -1,6 +1,39 @@
"use client"
import { useState } from "react"
import { useState, useEffect } from "react"
// 輔助函數:安全地格式化數字
const formatNumber = (value: any, decimals: number = 0): string => {
if (value == null) {
return decimals > 0 ? '0.' + '0'.repeat(decimals) : '0'
}
// 處理字符串和數字類型
const numValue = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(numValue)) {
return decimals > 0 ? '0.' + '0'.repeat(decimals) : '0'
}
return decimals > 0 ? numValue.toFixed(decimals) : numValue.toString()
}
const formatLocaleNumber = (value: any): string => {
if (value == null) {
return '0'
}
// 處理字符串和數字類型
const numValue = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(numValue)) {
return '0'
}
return numValue.toLocaleString()
}
import { useAuth } from "@/contexts/auth-context"
import { useToast } from "@/hooks/use-toast"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -29,6 +62,7 @@ import {
ExternalLink,
AlertTriangle,
X,
XCircle,
Check,
TrendingDown,
Link,
@@ -52,6 +86,7 @@ import {
Shield,
Settings,
Lightbulb,
Users
} from "lucide-react"
// Add available icons array after imports
@@ -83,12 +118,34 @@ const availableIcons = [
const mockApps: any[] = []
export function AppManagement() {
const [apps, setApps] = useState(mockApps)
const { user } = useAuth()
const { toast } = useToast()
const [apps, setApps] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState("")
const [selectedType, setSelectedType] = useState("all")
const [selectedStatus, setSelectedStatus] = useState("all")
const [selectedApp, setSelectedApp] = useState<any>(null)
const [showAppDetail, setShowAppDetail] = useState(false)
const [appStats, setAppStats] = useState({
basic: {
views: 0,
likes: 0,
rating: 0,
reviewCount: 0
},
usage: {
dailyUsers: 0,
weeklyUsers: 0,
monthlyUsers: 0,
totalSessions: 0,
topDepartments: [],
trendData: []
}
})
const [appReviews, setAppReviews] = useState<any[]>([])
const [isLoadingStats, setIsLoadingStats] = useState(false)
const [isLoadingReviews, setIsLoadingReviews] = useState(false)
const [showAddApp, setShowAddApp] = useState(false)
const [showEditApp, setShowEditApp] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
@@ -99,26 +156,163 @@ export function AppManagement() {
name: "",
type: "文字處理",
department: "HQBU",
creator: "",
creator_id: "",
description: "",
appUrl: "",
icon: "Bot",
iconColor: "from-blue-500 to-purple-500",
})
const filteredApps = apps.filter((app) => {
const matchesSearch =
app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.creator.toLowerCase().includes(searchTerm.toLowerCase())
const matchesType = selectedType === "all" || app.type === selectedType
const matchesStatus = selectedStatus === "all" || app.status === selectedStatus
return matchesSearch && matchesType && matchesStatus
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0,
totalPages: 0
})
const [stats, setStats] = useState({
totalApps: 0,
activeApps: 0,
inactiveApps: 0,
pendingApps: 0,
totalViews: 0,
totalLikes: 0,
newThisMonth: 0
})
const [users, setUsers] = useState<any[]>([])
const [isLoadingUsers, setIsLoadingUsers] = useState(false)
// 載入應用數據
const loadApps = async () => {
try {
setIsLoading(true)
const params = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
search: searchTerm,
type: selectedType,
status: selectedStatus
})
const response = await fetch(`/api/admin/apps?${params}`)
const data = await response.json()
if (data.success) {
setApps(data.data.apps)
setPagination(data.data.pagination)
setStats(data.data.stats)
}
} catch (error) {
console.error('載入應用數據錯誤:', error)
} finally {
setIsLoading(false)
}
}
// 載入用戶列表
const loadUsers = async () => {
try {
setIsLoadingUsers(true)
const response = await fetch('/api/admin/users/list')
const data = await response.json()
if (data.success) {
setUsers(data.data.users)
}
} catch (error) {
console.error('載入用戶列表錯誤:', error)
} finally {
setIsLoadingUsers(false)
}
}
// 初始載入
useEffect(() => {
loadApps()
loadUsers()
}, [pagination.page, searchTerm, selectedType, selectedStatus])
const filteredApps = apps
const handleViewApp = (app: any) => {
setSelectedApp(app)
setShowAppDetail(true)
loadAppStats(app.id)
loadAppReviews(app.id)
}
// 載入應用統計數據
const loadAppStats = async (appId: string) => {
setIsLoadingStats(true)
try {
const response = await fetch(`/api/admin/apps/${appId}/stats`)
const data = await response.json()
if (data.success) {
setAppStats(data.data)
}
} catch (error) {
console.error('載入應用統計數據錯誤:', error)
} finally {
setIsLoadingStats(false)
}
}
// 載入應用評價
const loadAppReviews = async (appId: string) => {
setIsLoadingReviews(true)
try {
const response = await fetch(`/api/admin/apps/${appId}/reviews`)
const data = await response.json()
if (data.success) {
setAppReviews(data.data.reviews)
}
} catch (error) {
console.error('載入應用評價錯誤:', error)
} finally {
setIsLoadingReviews(false)
}
}
// 刪除評價
const handleDeleteReview = async (reviewId: string) => {
if (!selectedApp) return
try {
const response = await fetch(`/api/admin/apps/${selectedApp.id}/reviews`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ reviewId })
})
const data = await response.json()
if (data.success) {
toast({
title: "刪除成功",
description: "評價已成功刪除",
variant: "success",
})
// 重新載入評價列表
loadAppReviews(selectedApp.id)
// 重新載入統計數據
loadAppStats(selectedApp.id)
} else {
toast({
title: "刪除失敗",
description: data.error || '刪除評價失敗',
variant: "destructive",
})
}
} catch (error) {
console.error('刪除評價錯誤:', error)
toast({
title: "錯誤",
description: '刪除評價時發生錯誤',
variant: "destructive",
})
}
}
const handleEditApp = (app: any) => {
@@ -127,7 +321,7 @@ export function AppManagement() {
name: app.name,
type: app.type,
department: app.department,
creator: app.creator,
creator_id: app.creator_id || "",
description: app.description,
appUrl: app.appUrl,
icon: app.icon || "Bot",
@@ -141,25 +335,74 @@ export function AppManagement() {
setShowDeleteConfirm(true)
}
const confirmDeleteApp = () => {
if (selectedApp) {
setApps(apps.filter((app) => app.id !== selectedApp.id))
setShowDeleteConfirm(false)
setSelectedApp(null)
const confirmDeleteApp = async () => {
try {
if (!selectedApp) return
const response = await fetch(`/api/admin/apps/${selectedApp.id}`, {
method: 'DELETE'
})
const data = await response.json()
if (data.success) {
await loadApps()
setShowDeleteConfirm(false)
setSelectedApp(null)
toast({
title: "刪除成功",
description: `應用「${selectedApp.name}」已永久刪除`,
variant: "success",
})
} else {
toast({
title: "刪除失敗",
description: data.error || '刪除應用失敗',
variant: "destructive",
})
}
} catch (error) {
console.error('刪除應用錯誤:', error)
toast({
title: "錯誤",
description: '刪除應用時發生錯誤',
variant: "destructive",
})
}
}
const handleToggleAppStatus = (appId: string) => {
setApps(
apps.map((app) =>
app.id === appId
? {
...app,
status: app.status === "published" ? "draft" : "published",
}
: app,
),
)
const handleToggleAppStatus = async (appId: string) => {
try {
const response = await fetch(`/api/admin/apps/${appId}/toggle-status`, {
method: 'POST'
})
const data = await response.json()
if (data.success) {
await loadApps()
// 根據新狀態顯示不同的成功訊息
const newStatus = data.data?.app?.is_active ? '發布' : '下架'
toast({
title: "狀態更新成功",
description: `應用已成功${newStatus}`,
variant: "success",
})
} else {
toast({
title: "狀態更新失敗",
description: data.error || '切換應用狀態失敗',
variant: "destructive",
})
}
} catch (error) {
console.error('切換應用狀態錯誤:', error)
toast({
title: "錯誤",
description: '切換應用狀態時發生錯誤',
variant: "destructive",
})
}
}
const handleApprovalAction = (app: any, action: "approve" | "reject") => {
@@ -187,45 +430,116 @@ export function AppManagement() {
}
}
const handleAddApp = () => {
const app = {
id: Date.now().toString(),
...newApp,
status: "pending",
createdAt: new Date().toISOString().split("T")[0],
views: 0,
likes: 0,
rating: 0,
reviews: 0,
const handleAddApp = async () => {
try {
if (!newApp.creator_id) {
toast({
title: "驗證失敗",
description: '請選擇創建者',
variant: "destructive",
})
return
}
const response = await fetch('/api/admin/apps', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newApp.name,
description: newApp.description,
creator_id: newApp.creator_id,
category: newApp.type, // 使用 type 作為 category
type: newApp.type,
app_url: newApp.appUrl,
icon: newApp.icon,
icon_color: newApp.iconColor
})
})
const data = await response.json()
if (data.success) {
// 重新載入應用列表
await loadApps()
setNewApp({
name: "",
type: "文字處理",
department: "HQBU",
creator_id: "",
description: "",
appUrl: "",
icon: "Bot",
iconColor: "from-blue-500 to-purple-500",
})
setShowAddApp(false)
} else {
toast({
title: "創建失敗",
description: data.error || '創建應用失敗',
variant: "destructive",
})
}
} catch (error) {
console.error('創建應用錯誤:', error)
toast({
title: "錯誤",
description: '創建應用時發生錯誤',
variant: "destructive",
})
}
setApps([...apps, app])
setNewApp({
name: "",
type: "文字處理",
department: "HQBU",
creator: "",
description: "",
appUrl: "",
icon: "Bot",
iconColor: "from-blue-500 to-purple-500",
})
setShowAddApp(false)
}
const handleUpdateApp = () => {
if (selectedApp) {
setApps(
apps.map((app) =>
app.id === selectedApp.id
? {
...app,
...newApp,
}
: app,
),
)
setShowEditApp(false)
setSelectedApp(null)
const handleUpdateApp = async () => {
try {
if (!selectedApp) return
if (!newApp.creator_id) {
toast({
title: "驗證失敗",
description: '請選擇創建者',
variant: "destructive",
})
return
}
const response = await fetch(`/api/admin/apps/${selectedApp.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newApp.name,
description: newApp.description,
category: newApp.type,
type: newApp.type,
app_url: newApp.appUrl,
icon: newApp.icon,
icon_color: newApp.iconColor
})
})
const data = await response.json()
if (data.success) {
await loadApps()
setShowEditApp(false)
setSelectedApp(null)
} else {
toast({
title: "更新失敗",
description: data.error || '更新應用失敗',
variant: "destructive",
})
}
} catch (error) {
console.error('更新應用錯誤:', error)
toast({
title: "錯誤",
description: '更新應用時發生錯誤',
variant: "destructive",
})
}
}
@@ -316,10 +630,10 @@ export function AppManagement() {
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{apps.filter((a) => a.status === "pending").length}</p>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{apps.filter((a) => a.status === "draft").length}</p>
</div>
<Clock className="w-8 h-8 text-yellow-600" />
<XCircle className="w-8 h-8 text-red-600" />
</div>
</CardContent>
</Card>
@@ -344,11 +658,19 @@ export function AppManagement() {
<SelectValue placeholder="類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="all"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="音樂生成"></SelectItem>
<SelectItem value="程式開發"></SelectItem>
<SelectItem value="影像處理"></SelectItem>
<SelectItem value="對話系統"></SelectItem>
<SelectItem value="數據分析"></SelectItem>
<SelectItem value="設計工具"></SelectItem>
<SelectItem value="語音技術"></SelectItem>
<SelectItem value="教育工具"></SelectItem>
</SelectContent>
</Select>
@@ -451,8 +773,8 @@ export function AppManagement() {
<TableCell>
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500" />
<span className="font-medium">{app.rating}</span>
<span className="text-sm text-gray-500">({app.reviews})</span>
<span className="font-medium">{formatNumber(app.rating, 1)}</span>
<span className="text-sm text-gray-500">({app.reviewCount || 0})</span>
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">{app.createdAt}</TableCell>
@@ -507,7 +829,7 @@ export function AppManagement() {
)}
<DropdownMenuItem className="text-red-600" onClick={() => handleDeleteApp(app)}>
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -540,12 +862,25 @@ export function AppManagement() {
</div>
<div className="space-y-2">
<Label htmlFor="creator"> *</Label>
<Input
id="creator"
value={newApp.creator}
onChange={(e) => setNewApp({ ...newApp, creator: e.target.value })}
placeholder="輸入創建者姓名"
/>
<Select
value={newApp.creator_id}
onValueChange={(value) => setNewApp({ ...newApp, creator_id: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇創建者" />
</SelectTrigger>
<SelectContent>
{isLoadingUsers ? (
<SelectItem value="" disabled>...</SelectItem>
) : (
users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
@@ -557,10 +892,19 @@ export function AppManagement() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="all"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="音樂生成"></SelectItem>
<SelectItem value="程式開發"></SelectItem>
<SelectItem value="影像處理"></SelectItem>
<SelectItem value="對話系統"></SelectItem>
<SelectItem value="數據分析"></SelectItem>
<SelectItem value="設計工具"></SelectItem>
<SelectItem value="語音技術"></SelectItem>
<SelectItem value="教育工具"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -644,7 +988,7 @@ export function AppManagement() {
<Button variant="outline" onClick={() => setShowAddApp(false)}>
</Button>
<Button onClick={handleAddApp} disabled={!newApp.name || !newApp.creator || !newApp.description}>
<Button onClick={handleAddApp} disabled={!newApp.name || !newApp.creator_id || !newApp.description}>
</Button>
</div>
@@ -673,12 +1017,25 @@ export function AppManagement() {
</div>
<div className="space-y-2">
<Label htmlFor="edit-creator"> *</Label>
<Input
id="edit-creator"
value={newApp.creator}
onChange={(e) => setNewApp({ ...newApp, creator: e.target.value })}
placeholder="輸入創建者姓名"
/>
<Select
value={newApp.creator_id}
onValueChange={(value) => setNewApp({ ...newApp, creator_id: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇創建者" />
</SelectTrigger>
<SelectContent>
{isLoadingUsers ? (
<SelectItem value="" disabled>...</SelectItem>
) : (
users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
@@ -690,10 +1047,18 @@ export function AppManagement() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="音樂生成"></SelectItem>
<SelectItem value="程式開發"></SelectItem>
<SelectItem value="影像處理"></SelectItem>
<SelectItem value="對話系統"></SelectItem>
<SelectItem value="數據分析"></SelectItem>
<SelectItem value="設計工具"></SelectItem>
<SelectItem value="語音技術"></SelectItem>
<SelectItem value="教育工具"></SelectItem>
</SelectContent>
</Select>
</div>
@@ -775,7 +1140,7 @@ export function AppManagement() {
<Button variant="outline" onClick={() => setShowEditApp(false)}>
</Button>
<Button onClick={handleUpdateApp} disabled={!newApp.name || !newApp.creator || !newApp.description}>
<Button onClick={handleUpdateApp} disabled={!newApp.name || !newApp.creator_id || !newApp.description}>
</Button>
</div>
@@ -791,7 +1156,7 @@ export function AppManagement() {
<AlertTriangle className="w-5 h-5 text-red-500" />
<span></span>
</DialogTitle>
<DialogDescription>{selectedApp?.name}</DialogDescription>
<DialogDescription>{selectedApp?.name}</DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowDeleteConfirm(false)}>
@@ -878,7 +1243,7 @@ export function AppManagement() {
})()}
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<div className="flex items-center space-x-2 mb-2 justify-between">
<h3 className="text-xl font-semibold">{selectedApp.name}</h3>
{selectedApp.appUrl && (
<Button variant="outline" size="sm" onClick={() => window.open(selectedApp.appUrl, "_blank")}>
@@ -939,11 +1304,14 @@ export function AppManagement() {
</TabsContent>
<TabsContent value="stats" className="space-y-4">
{/* 基本統計數據 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{selectedApp.views}</p>
<p className="text-2xl font-bold text-blue-600">
{isLoadingStats ? '...' : (appStats?.basic?.views || 0)}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
@@ -951,7 +1319,9 @@ export function AppManagement() {
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-red-600">{selectedApp.likes}</p>
<p className="text-2xl font-bold text-red-600">
{isLoadingStats ? '...' : (appStats?.basic?.likes || 0)}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
@@ -959,7 +1329,9 @@ export function AppManagement() {
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-yellow-600">{selectedApp.rating}</p>
<p className="text-2xl font-bold text-yellow-600">
{isLoadingStats ? '...' : formatNumber(appStats?.basic?.rating, 1)}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
@@ -967,20 +1339,139 @@ export function AppManagement() {
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-green-600">{selectedApp.reviews}</p>
<p className="text-2xl font-bold text-green-600">
{isLoadingStats ? '...' : (appStats?.basic?.reviewCount || 0)}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</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 || 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>
<TrendingUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{isLoadingStats ? '...' : (appStats?.usage?.weeklyUsers || 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>
<BarChart3 className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{isLoadingStats ? '...' : formatLocaleNumber(appStats?.usage?.totalSessions)}
</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>
</div>
{/* 部門使用統計 */}
{appStats?.usage?.topDepartments && appStats.usage.topDepartments.length > 0 && (
<Card>
<CardHeader>
<CardTitle>使</CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{appStats.usage.topDepartments.map((dept: any, index: number) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm font-medium">{dept.department}</span>
<span className="text-sm text-gray-500">{dept.count} </span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="reviews" className="space-y-4">
<div className="text-center py-8">
<MessageSquare className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2"></h3>
<p className="text-gray-500"></p>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"></h3>
<div className="text-sm text-gray-500">
{isLoadingReviews ? '載入中...' : `${appReviews.length} 條評價`}
</div>
</div>
{isLoadingReviews ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="text-gray-500 mt-2">...</p>
</div>
) : appReviews.length === 0 ? (
<div className="text-center py-8">
<MessageSquare className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2"></h3>
<p className="text-gray-500"></p>
</div>
) : (
<div className="space-y-4">
{appReviews.map((review) => (
<Card key={review.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<div className="flex items-center space-x-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${
i < review.rating
? 'text-yellow-400 fill-current'
: 'text-gray-300'
}`}
/>
))}
</div>
<span className="text-sm font-medium">{review.userName}</span>
<span className="text-xs text-gray-500">({review.userDepartment})</span>
</div>
<p className="text-gray-700 mb-2">{review.review}</p>
<p className="text-xs text-gray-500">
{review.ratedAt ? new Date(review.ratedAt).toLocaleString('zh-TW') : '未知時間'}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteReview(review.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
)}

View File

@@ -57,6 +57,23 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
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,
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(() => {
@@ -88,6 +105,34 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
setReviewCount(newReviewCount)
}
// 載入應用統計數據
const loadAppStats = async () => {
if (!app.id) return
setIsLoadingStats(true)
try {
const response = await fetch(`/api/apps/${app.id}/stats`)
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)
}
}
// 當對話框打開時載入統計數據
React.useEffect(() => {
if (open && app.id) {
loadAppStats()
}
}, [open, app.id])
const handleTryApp = () => {
if (user) {
addToRecentApps(app.id.toString())
@@ -245,19 +290,74 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardContent>
</Card>
<div className="flex items-center space-x-4">
<div className="flex items-center justify-between">
<FavoriteButton appId={app.id.toString()} size="default" showText={true} className="px-6" />
<Button
onClick={handleTryApp}
className="flex-1 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
</Button>
<FavoriteButton appId={app.id.toString()} size="default" showText={true} className="px-6" />
</div>
</TabsContent>
<TabsContent value="statistics" className="space-y-6">
{/* Usage Overview */}
{/* 基本統計數據 */}
<div className="grid grid-cols-1 md:grid-cols-4 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 ? '...' : appStats.basic.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>
<Heart className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{isLoadingStats ? '...' : appStats.basic.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>
<Star className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">
{isLoadingStats ? '...' : appStats.basic.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 ? '...' : appStats.basic.reviewCount}
</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">
@@ -265,7 +365,9 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<Users className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{usageStats.dailyUsers}</div>
<div className="text-2xl font-bold">
{isLoadingStats ? '...' : appStats.usage.dailyUsers}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
@@ -276,7 +378,9 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<TrendingUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{usageStats.weeklyUsers}</div>
<div className="text-2xl font-bold">
{isLoadingStats ? '...' : appStats.usage.weeklyUsers}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
@@ -287,7 +391,9 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<BarChart3 className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{usageStats.totalSessions.toLocaleString()}</div>
<div className="text-2xl font-bold">
{isLoadingStats ? '...' : appStats.usage.totalSessions.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>

View File

@@ -704,11 +704,19 @@ export function PopularityRankings() {
<SelectValue placeholder="應用類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="all"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="音樂生成"></SelectItem>
<SelectItem value="程式開發"></SelectItem>
<SelectItem value="影像處理"></SelectItem>
<SelectItem value="對話系統"></SelectItem>
<SelectItem value="數據分析"></SelectItem>
<SelectItem value="設計工具"></SelectItem>
<SelectItem value="語音技術"></SelectItem>
<SelectItem value="教育工具"></SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -202,12 +202,18 @@ export function RegistrationDialog({ open, onOpenChange }: RegistrationDialogPro
<SelectValue placeholder="選擇應用類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="數據分析"></SelectItem>
<SelectItem value="自動化工具"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="音樂生成"></SelectItem>
<SelectItem value="程式開發"></SelectItem>
<SelectItem value="影像處理"></SelectItem>
<SelectItem value="對話系統"></SelectItem>
<SelectItem value="數據分析"></SelectItem>
<SelectItem value="設計工具"></SelectItem>
<SelectItem value="語音技術"></SelectItem>
<SelectItem value="教育工具"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>

View File

@@ -1,113 +1,183 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all duration-300 ease-in-out transform",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive border-destructive bg-destructive text-destructive-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
success:
"border-green-200 bg-green-50 text-green-900",
warning:
"border-yellow-200 bg-yellow-50 text-yellow-900",
},
},
defaultVariants: {
variant: "default",
},
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof toastVariants> & {
onOpenChange?: (open: boolean) => void
open?: boolean
}
>(({ className, variant, onOpenChange, open, ...props }, ref) => {
const [isVisible, setIsVisible] = React.useState(true)
const [isClosing, setIsClosing] = React.useState(false)
// 處理關閉動畫
const handleClose = React.useCallback(() => {
if (isClosing) return // 防止重複觸發
setIsClosing(true)
setIsVisible(false)
// 等待動畫完成後調用 onOpenChange
setTimeout(() => {
onOpenChange?.(false)
}, 300) // 與 CSS 動畫時間一致
}, [onOpenChange, isClosing])
// 處理外部觸發的關閉
React.useEffect(() => {
if (open === false && !isClosing) {
handleClose()
}
}, [open, isClosing, handleClose])
return (
<div
ref={ref}
className={cn(
toastVariants({ variant }),
// 動畫類別
isVisible
? "translate-x-0 opacity-100"
: "translate-x-full opacity-0",
className
)}
style={{
transition: "transform 300ms ease-in-out, opacity 300ms ease-in-out"
}}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
Toast.displayName = "Toast"
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
<button
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
ToastAction.displayName = "ToastAction"
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & {
onClose?: () => void
}
>(({ className, onClose, ...props }, ref) => (
<button
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
className
)}
toast-close=""
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose?.()
}}
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
</button>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
ToastClose.displayName = "ToastClose"
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
<div
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
ToastTitle.displayName = "ToastTitle"
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
<div
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
ToastDescription.displayName = "ToastDescription"
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastProps = React.ComponentProps<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
const ToastProvider = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("pointer-events-none fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", className)}
{...props}
/>
))
ToastProvider.displayName = "ToastProvider"
const ToastViewport = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = "ToastViewport"
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastAction,
ToastClose,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
ToastProvider,
ToastViewport,
}

View File

@@ -1,24 +1,35 @@
"use client"
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/hooks/use-toast"
export function Toaster() {
const { toasts } = useToast()
const { toasts, dismiss } = useToast()
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props} onOpenChange={() => dismiss(id)}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose onClose={() => dismiss(id)} />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
}