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

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

View File

@@ -22,12 +22,16 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
// 獲取使用統計 // 獲取使用統計
const usageStats = await appService.getAppUsageStats(appId) const usageStats = await appService.getAppUsageStats(appId)
// 獲取收藏數量
const favoritesCount = await appService.getAppFavoritesCount(appId)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {
basic: { basic: {
views: app.views_count || 0, views: app.views_count || 0,
likes: app.likes_count || 0, likes: app.likes_count || 0,
favorites: favoritesCount,
rating: ratingStats.averageRating || 0, rating: ratingStats.averageRating || 0,
reviewCount: ratingStats.totalRatings || 0 reviewCount: ratingStats.totalRatings || 0
}, },

View File

@@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
}) })
// 獲取應用統計 // 獲取應用統計
const stats = await appService.getAppStats() const stats = await appService.getDashboardStats()
return NextResponse.json({ return NextResponse.json({
success: true, success: true,

View File

@@ -5,26 +5,38 @@ const userService = new UserService()
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// 獲取所有啟用狀態的用戶 // 獲取所有用戶(包括不同狀態)
const sql = ` const sql = `
SELECT id, name, email, department, role SELECT id, name, email, department, role, status, created_at, last_login
FROM users FROM users
WHERE status = 'active'
ORDER BY name ASC ORDER BY name ASC
`; `;
const users = await userService.query(sql); const users = await userService.query(sql);
return NextResponse.json({ // 為每個用戶獲取統計數據
success: true, const usersWithStats = await Promise.all(
data: { users.map(async (user) => {
users: users.map(user => ({ const stats = await userService.getUserAppAndReviewStats(user.id);
return {
id: user.id, id: user.id,
name: user.name, name: user.name,
email: user.email, email: user.email,
department: user.department, department: user.department,
role: user.role role: user.role,
})) status: user.status,
createdAt: user.created_at,
lastLogin: user.last_login,
appCount: stats.appCount,
reviewCount: stats.reviewCount
};
})
);
return NextResponse.json({
success: true,
data: {
users: usersWithStats
} }
}) })

View File

@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server'
import { AppService } from '@/lib/services/database-service'
const appService = new AppService()
// 添加收藏
export async function POST(request: NextRequest, { params }: { params: { id: string } }) {
try {
const { id: appId } = await params
const body = await request.json()
const { userId } = body
if (!userId) {
return NextResponse.json(
{ success: false, error: '用戶ID不能為空' },
{ status: 400 }
)
}
// 檢查應用是否存在
const app = await appService.getAppById(appId)
if (!app) {
return NextResponse.json(
{ success: false, error: '應用不存在' },
{ status: 404 }
)
}
// 添加收藏
const result = await appService.addFavorite(userId, appId)
if (result.success) {
return NextResponse.json({
success: true,
message: '收藏成功'
})
} else {
// 如果是重複收藏,返回 409 狀態碼
const statusCode = result.error === '已經收藏過此應用' ? 409 : 400
return NextResponse.json(
{ success: false, error: result.error },
{ status: statusCode }
)
}
} catch (error) {
console.error('添加收藏錯誤:', error)
return NextResponse.json(
{ success: false, error: '添加收藏時發生錯誤' },
{ status: 500 }
)
}
}
// 移除收藏
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
try {
const { id: appId } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json(
{ success: false, error: '用戶ID不能為空' },
{ status: 400 }
)
}
// 移除收藏
const result = await appService.removeFavorite(userId, appId)
if (result.success) {
return NextResponse.json({
success: true,
message: '取消收藏成功'
})
} else {
return NextResponse.json(
{ success: false, error: result.error },
{ status: 400 }
)
}
} catch (error) {
console.error('移除收藏錯誤:', error)
return NextResponse.json(
{ success: false, error: '移除收藏時發生錯誤' },
{ status: 500 }
)
}
}
// 檢查收藏狀態
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
try {
const { id: appId } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json(
{ success: false, error: '用戶ID不能為空' },
{ status: 400 }
)
}
// 檢查收藏狀態
const isFavorited = await appService.isFavorited(userId, appId)
return NextResponse.json({
success: true,
data: {
isFavorited
}
})
} catch (error) {
console.error('檢查收藏狀態錯誤:', error)
return NextResponse.json(
{ success: false, error: '檢查收藏狀態時發生錯誤' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from 'next/server'
import { AppService } from '@/lib/services/database-service'
const appService = new AppService()
// 獲取應用的統計數據
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: appId } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
// 獲取應用的統計數據
const stats = await appService.getAppStats(appId, userId || undefined)
return NextResponse.json({
success: true,
data: {
likesCount: stats.likes_count || 0,
viewsCount: stats.views_count || 0,
rating: stats.average_rating || 0,
reviewsCount: stats.reviews_count || 0,
userLiked: stats.userLiked || false
}
})
} catch (error) {
console.error('獲取應用統計數據錯誤:', error)
return NextResponse.json(
{ success: false, error: '獲取應用統計數據時發生錯誤' },
{ status: 500 }
)
}
}
// 更新應用統計數據(按讚、觀看等)
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: appId } = await params
const { action, userId } = await request.json()
if (!userId) {
return NextResponse.json(
{ success: false, error: '需要用戶身份驗證' },
{ status: 401 }
)
}
let result = false
switch (action) {
case 'like':
result = await appService.toggleAppLike(appId, userId)
break
case 'view':
result = await appService.incrementAppViews(appId, userId)
break
case 'favorite':
result = await appService.toggleAppFavorite(appId, userId)
break
default:
return NextResponse.json(
{ success: false, error: '無效的操作類型' },
{ status: 400 }
)
}
if (result) {
// 重新獲取更新後的統計數據
const stats = await appService.getAppStats(appId)
return NextResponse.json({
success: true,
data: {
likesCount: stats.likes_count || 0,
viewsCount: stats.views_count || 0,
rating: stats.average_rating || 0,
reviewsCount: stats.reviews_count || 0
}
})
} else {
return NextResponse.json(
{ success: false, error: '操作失敗' },
{ status: 500 }
)
}
} catch (error) {
console.error('更新應用統計數據錯誤:', error)
return NextResponse.json(
{ success: false, error: '更新應用統計數據時發生錯誤' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server'
import { AppService } from '@/lib/services/database-service'
const appService = new AppService()
// 獲取應用的評論列表
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: appId } = await params
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '5') // 預設5筆
const offset = parseInt(searchParams.get('offset') || '0')
const page = parseInt(searchParams.get('page') || '1')
const reviews = await appService.getAppReviews(appId, limit, offset)
const totalReviews = await appService.getAppReviewCount(appId)
const totalPages = Math.ceil(totalReviews / limit)
return NextResponse.json({
success: true,
data: {
reviews,
pagination: {
page,
limit,
total: totalReviews,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
}
})
} catch (error) {
console.error('獲取應用評論錯誤:', error)
return NextResponse.json(
{ success: false, error: '獲取應用評論時發生錯誤' },
{ status: 500 }
)
}
}
// 創建新的評論
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: appId } = await params
const { userId, rating, comment, reviewId } = await request.json()
if (!userId || !rating || !comment) {
return NextResponse.json(
{ success: false, error: '缺少必要參數' },
{ status: 400 }
)
}
// 檢查評分範圍
if (rating < 1 || rating > 5) {
return NextResponse.json(
{ success: false, error: '評分必須在 1-5 之間' },
{ status: 400 }
)
}
let resultReviewId: string | null = null
if (reviewId) {
// 更新現有評論
resultReviewId = await appService.updateAppReview(reviewId, appId, userId, rating, comment)
} else {
// 創建新評論
resultReviewId = await appService.createAppReview(appId, userId, rating, comment)
}
if (resultReviewId) {
// 獲取更新後的評論列表
const reviews = await appService.getAppReviews(appId, 10, 0)
return NextResponse.json({
success: true,
data: { reviewId: resultReviewId, reviews }
})
} else {
return NextResponse.json(
{ success: false, error: reviewId ? '更新評論失敗' : '創建評論失敗' },
{ status: 500 }
)
}
} catch (error) {
console.error('處理應用評論錯誤:', error)
return NextResponse.json(
{ success: false, error: '處理應用評論時發生錯誤' },
{ status: 500 }
)
}
}

View File

@@ -6,6 +6,11 @@ const appService = new AppService()
export async function GET(request: NextRequest, { params }: { params: { id: string } }) { export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
try { try {
const { id: appId } = await params const { id: appId } = await params
const { searchParams } = new URL(request.url)
// 獲取日期範圍參數
const startDate = searchParams.get('startDate')
const endDate = searchParams.get('endDate')
// 獲取應用基本統計 // 獲取應用基本統計
const app = await appService.getAppById(appId) const app = await appService.getAppById(appId)
@@ -19,8 +24,11 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
// 獲取評分統計 // 獲取評分統計
const ratingStats = await appService.getAppRatingStats(appId) const ratingStats = await appService.getAppRatingStats(appId)
// 獲取使用趨勢數據 // 獲取使用趨勢數據(支援日期範圍)
const usageStats = await appService.getAppUsageStats(appId) const usageStats = await appService.getAppUsageStats(appId, startDate || undefined, endDate || undefined)
// 獲取收藏數量
const favoritesCount = await appService.getAppFavoritesCount(appId)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@@ -28,6 +36,7 @@ export async function GET(request: NextRequest, { params }: { params: { id: stri
basic: { basic: {
views: app.views_count || 0, views: app.views_count || 0,
likes: app.likes_count || 0, likes: app.likes_count || 0,
favorites: favoritesCount,
rating: ratingStats.averageRating || 0, rating: ratingStats.averageRating || 0,
reviewCount: ratingStats.totalRatings || 0 reviewCount: ratingStats.totalRatings || 0
}, },

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server'
import { AppService } from '@/lib/services/database-service'
const appService = new AppService()
// 獲取評論的投票統計
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: reviewId } = await params
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const votes = await appService.getReviewVotes(reviewId, userId || undefined)
return NextResponse.json({
success: true,
data: votes
})
} catch (error) {
console.error('獲取評論投票錯誤:', error)
return NextResponse.json(
{ success: false, error: '獲取評論投票時發生錯誤' },
{ status: 500 }
)
}
}
// 投票或取消投票
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: reviewId } = await params
const { userId, isHelpful } = await request.json()
if (!userId || typeof isHelpful !== 'boolean') {
return NextResponse.json(
{ success: false, error: '缺少必要參數' },
{ status: 400 }
)
}
const result = await appService.toggleReviewVote(reviewId, userId, isHelpful)
if (result.success) {
// 獲取更新後的投票統計
const votes = await appService.getReviewVotes(reviewId, userId)
return NextResponse.json({
success: true,
data: votes
})
} else {
return NextResponse.json(
{ success: false, error: result.error || '投票失敗' },
{ status: 500 }
)
}
} catch (error) {
console.error('投票評論錯誤:', error)
return NextResponse.json(
{ success: false, error: '投票評論時發生錯誤' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server'
import { AppService } from '@/lib/services/database-service'
const appService = new AppService()
// 獲取用戶活動記錄
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json(
{ success: false, error: '用戶ID不能為空' },
{ status: 400 }
)
}
// 獲取用戶最近使用的應用
const recentApps = await appService.getUserRecentApps(userId, 10)
// 獲取用戶統計數據
const userStats = await appService.getUserActivityStats(userId)
// 獲取類別使用統計
const categoryStats = await appService.getUserCategoryStats(userId)
return NextResponse.json({
success: true,
data: {
recentApps,
userStats,
categoryStats
}
})
} catch (error) {
console.error('獲取用戶活動記錄錯誤:', error)
return NextResponse.json(
{ success: false, error: '獲取活動記錄時發生錯誤' },
{ status: 500 }
)
}
}
// 記錄用戶活動
export async function POST(request: NextRequest) {
try {
const { userId, action, resourceType, resourceId, details } = await request.json()
if (!userId || !action || !resourceType) {
return NextResponse.json(
{ success: false, error: '缺少必要參數' },
{ status: 400 }
)
}
// 記錄活動到資料庫
const activityId = await appService.logUserActivity({
userId,
action,
resourceType,
resourceId,
details
})
return NextResponse.json({
success: true,
data: { activityId }
})
} catch (error) {
console.error('記錄用戶活動錯誤:', error)
return NextResponse.json(
{ success: false, error: '記錄活動時發生錯誤' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { AppService } from '@/lib/services/database-service'
const appService = new AppService()
// 獲取用戶收藏列表
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
if (!userId) {
return NextResponse.json(
{ success: false, error: '用戶ID不能為空' },
{ status: 400 }
)
}
const offset = (page - 1) * limit
const result = await appService.getUserFavorites(userId, limit, offset)
return NextResponse.json({
success: true,
data: {
apps: result.apps,
pagination: {
page,
limit,
total: result.total,
totalPages: Math.ceil(result.total / limit)
}
}
})
} catch (error) {
console.error('獲取用戶收藏列表錯誤:', error)
return NextResponse.json(
{ success: false, error: '獲取收藏列表時發生錯誤' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import { AppService } from '@/lib/services/database-service'
const appService = new AppService()
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json(
{ success: false, error: '缺少用戶ID' },
{ status: 400 }
)
}
// 直接查詢收藏的應用ID不依賴 apps 表的 is_active 狀態)
const favoriteAppIds = await appService.getUserFavoriteAppIds(userId)
console.log('用戶收藏的應用ID列表:', favoriteAppIds)
// 獲取用戶的按讚列表
const likedAppIds = await appService.getUserLikedApps(userId)
console.log('用戶按讚的應用ID列表:', likedAppIds)
return NextResponse.json({
success: true,
data: {
favorites: favoriteAppIds,
likes: likedAppIds
}
})
} catch (error) {
console.error('獲取用戶互動狀態錯誤:', error)
return NextResponse.json(
{ success: false, error: '獲取用戶互動狀態時發生錯誤' },
{ status: 500 }
)
}
}

View File

@@ -198,6 +198,29 @@ export default function AIShowcasePlatform() {
setTotalPages(data.data.pagination.totalPages) setTotalPages(data.data.pagination.totalPages)
setTotalApps(data.data.pagination.total) setTotalApps(data.data.pagination.total)
setDepartments(data.data.departments || []) setDepartments(data.data.departments || [])
// 為每個應用載入統計數據
if (data.data.apps && data.data.apps.length > 0) {
const updatedApps = await Promise.all(
data.data.apps.map(async (app: any) => {
try {
const userId = user?.id
const statsResponse = await fetch(`/api/apps/${app.id}/interactions${userId ? `?userId=${userId}` : ''}`)
if (statsResponse.ok) {
const statsData = await statsResponse.json()
if (statsData.success) {
console.log(`載入應用 ${app.name} 的統計數據:`, statsData.data)
return { ...app, ...statsData.data }
}
}
} catch (error) {
console.error(`載入應用 ${app.name} 統計數據錯誤:`, error)
}
return app
})
)
setAiApps(updatedApps)
}
} else { } else {
console.error('載入應用數據失敗:', data.error) console.error('載入應用數據失敗:', data.error)
setAiApps([]) setAiApps([])
@@ -306,6 +329,13 @@ export default function AIShowcasePlatform() {
setShowAppDetail(true) setShowAppDetail(true)
} }
const handleAppDetailClose = async () => {
setShowAppDetail(false)
setSelectedApp(null)
// 重新載入應用數據以更新統計信息
await loadApps()
}
const handleSwitchToForgotPassword = () => { const handleSwitchToForgotPassword = () => {
setShowLogin(false) setShowLogin(false)
setShowForgotPassword(true) setShowForgotPassword(true)
@@ -316,8 +346,8 @@ export default function AIShowcasePlatform() {
setShowLogin(true) setShowLogin(true)
} }
const handleTryApp = (appId: string) => { const handleTryApp = async (appId: string) => {
incrementViewCount(appId) await incrementViewCount(appId)
addToRecentApps(appId) addToRecentApps(appId)
console.log(`Opening app ${appId}`) console.log(`Opening app ${appId}`)
} }
@@ -972,17 +1002,18 @@ export default function AIShowcasePlatform() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{currentApps.map((app) => { {currentApps.map((app) => {
const IconComponent = getIconComponent(app.icon || 'Bot') const IconComponent = getIconComponent(app.icon || 'Bot')
const likes = getAppLikes(app.id.toString()) const likes = Number(app.likesCount) || 0
const views = getViewCount(app.id.toString()) const views = Number(app.viewsCount) || 0
const rating = getAppRating(app.id.toString()) const rating = Number(app.rating) || 0
const reviewsCount = Number(app.reviewsCount) || 0
return ( return (
<Card key={app.id} className="group hover:shadow-lg transition-all duration-300 border-0 shadow-md"> <Card key={app.id} className="group hover:shadow-lg transition-all duration-300 border-0 shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-r from-gray-100 to-gray-200 rounded-lg flex items-center justify-center"> <div className={`w-10 h-10 bg-gradient-to-r ${app.iconColor || 'from-gray-100 to-gray-200'} rounded-lg flex items-center justify-center`}>
<IconComponent className="w-5 h-5 text-gray-600" /> <IconComponent className={`w-5 h-5 ${app.iconColor ? 'text-white' : 'text-gray-600'}`} />
</div> </div>
<div> <div>
<CardTitle className="text-base group-hover:text-blue-600 transition-colors"> <CardTitle className="text-base group-hover:text-blue-600 transition-colors">
@@ -991,6 +1022,9 @@ export default function AIShowcasePlatform() {
<p className="text-xs text-gray-500">by {app.creator}</p> <p className="text-xs text-gray-500">by {app.creator}</p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2">
<FavoriteButton appId={app.id.toString()} />
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
@@ -1007,7 +1041,12 @@ export default function AIShowcasePlatform() {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-3 text-xs text-gray-500"> <div className="flex items-center space-x-3 text-xs text-gray-500">
<LikeButton appId={app.id.toString()} size="sm" /> <LikeButton
appId={app.id.toString()}
size="sm"
likeCount={likes}
userLiked={app.userLiked}
/>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
<span>{views}</span> <span>{views}</span>
@@ -1016,9 +1055,12 @@ export default function AIShowcasePlatform() {
<Star className="w-3 h-3 text-yellow-500" /> <Star className="w-3 h-3 text-yellow-500" />
<span>{rating.toFixed(1)}</span> <span>{rating.toFixed(1)}</span>
</div> </div>
<div className="flex items-center space-x-1">
<MessageSquare className="w-3 h-3" />
<span>{reviewsCount}</span>
</div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<FavoriteButton appId={app.id.toString()} />
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -1054,7 +1096,7 @@ export default function AIShowcasePlatform() {
)} )}
{/* App Detail Dialog */} {/* App Detail Dialog */}
{selectedApp && <AppDetailDialog open={showAppDetail} onOpenChange={setShowAppDetail} app={selectedApp} />} {selectedApp && <AppDetailDialog open={showAppDetail} onOpenChange={handleAppDetailClose} app={selectedApp} />}
{/* Favorites Dialog */} {/* Favorites Dialog */}
<Dialog open={showFavorites} onOpenChange={setShowFavorites}> <Dialog open={showFavorites} onOpenChange={setShowFavorites}>

View File

@@ -131,6 +131,7 @@ export function AppManagement() {
basic: { basic: {
views: 0, views: 0,
likes: 0, likes: 0,
favorites: 0,
rating: 0, rating: 0,
reviewCount: 0 reviewCount: 0
}, },
@@ -1305,7 +1306,7 @@ export function AppManagement() {
<TabsContent value="stats" className="space-y-4"> <TabsContent value="stats" className="space-y-4">
{/* 基本統計數據 */} {/* 基本統計數據 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-center"> <div className="text-center">
@@ -1322,6 +1323,16 @@ export function AppManagement() {
<p className="text-2xl font-bold text-red-600"> <p className="text-2xl font-bold text-red-600">
{isLoadingStats ? '...' : (appStats?.basic?.likes || 0)} {isLoadingStats ? '...' : (appStats?.basic?.likes || 0)}
</p> </p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-purple-600">
{isLoadingStats ? '...' : (appStats?.basic?.favorites || 0)}
</p>
<p className="text-sm text-gray-600"></p> <p className="text-sm text-gray-600"></p>
</div> </div>
</CardContent> </CardContent>

View File

@@ -903,8 +903,8 @@ export function UserManagement() {
<TableCell className="text-sm text-gray-600">{user.lastLogin || "-"}</TableCell> <TableCell className="text-sm text-gray-600">{user.lastLogin || "-"}</TableCell>
<TableCell> <TableCell>
<div className="text-sm"> <div className="text-sm">
<p>{user.totalApps} </p> <p>{user.appCount || 0} </p>
<p className="text-gray-500">{user.totalReviews} </p> <p className="text-gray-500">{user.reviewCount || 0} </p>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useCallback } from "react"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -13,6 +13,7 @@ import {
Star, Star,
Eye, Eye,
Heart, Heart,
ThumbsUp,
Info, Info,
MessageSquare, MessageSquare,
User, User,
@@ -21,6 +22,23 @@ import {
TrendingUp, TrendingUp,
Users, Users,
BarChart3, BarChart3,
Brain,
ImageIcon,
Mic,
Settings,
Zap,
Target,
Bookmark,
Lightbulb,
Search,
Plus,
X,
ChevronLeft,
ChevronRight,
ArrowLeft,
Trophy,
Award,
Medal,
} from "lucide-react" } from "lucide-react"
import { FavoriteButton } from "./favorite-button" import { FavoriteButton } from "./favorite-button"
import { ReviewSystem } from "./reviews/review-system" import { ReviewSystem } from "./reviews/review-system"
@@ -61,6 +79,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
basic: { basic: {
views: 0, views: 0,
likes: 0, likes: 0,
favorites: 0,
rating: 0, rating: 0,
reviewCount: 0 reviewCount: 0
}, },
@@ -85,10 +104,42 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
return new Date().toISOString().split("T")[0] return new Date().toISOString().split("T")[0]
}) })
const IconComponent = app.icon // 圖標映射函數
const likes = getAppLikes(app.id.toString()) const getIconComponent = (iconName: string) => {
const views = getViewCount(app.id.toString()) const iconMap: { [key: string]: any } = {
const usageStats = getAppUsageStats(app.id.toString(), startDate, endDate) '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 getTypeColor = (type: string) => {
const colors = { 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" 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 if (!app.id) return
setIsLoadingStats(true) setIsLoadingStats(true)
try { 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() const data = await response.json()
if (data.success) { if (data.success) {
@@ -124,25 +176,91 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
} finally { } finally {
setIsLoadingStats(false) 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(() => { useEffect(() => {
if (open && app.id) { if (open && app.id) {
loadAppStats() loadAppStats()
} }
}, [open, app.id]) }, [open, app.id, loadAppStats])
// 當日期範圍變更時重新載入使用趨勢數據
useEffect(() => {
if (open && app.id && startDate && endDate) {
handleDateRangeChange()
}
}, [startDate, endDate, open, app.id, handleDateRangeChange])
const handleTryApp = async () => {
console.log('handleTryApp 被調用', { user: user?.id, appId: app.id })
const handleTryApp = () => {
if (user) { if (user) {
addToRecentApps(app.id.toString()) 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 // Increment view count when trying the app
incrementViewCount(app.id.toString()) await incrementViewCount(app.id.toString())
// Open external app URL in new tab // Reload stats after incrementing view count
const appUrls: Record<string, string> = { 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", "1": "https://dify.example.com/chat-assistant",
"2": "https://image-gen.example.com", "2": "https://image-gen.example.com",
"3": "https://speech.example.com", "3": "https://speech.example.com",
@@ -151,9 +269,14 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
"6": "https://ai-writing.example.com", "6": "https://ai-writing.example.com",
} }
const appUrl = appUrls[app.id.toString()] const fallbackUrl = defaultUrls[app.id.toString()]
if (appUrl) { if (fallbackUrl) {
window.open(appUrl, "_blank", "noopener,noreferrer") 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"> <DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-start space-x-4"> <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" /> <IconComponent className="w-8 h-8 text-white" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -201,9 +326,10 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</Badge> </Badge>
)} )}
</div> </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-6 text-sm text-gray-600">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Heart className="w-4 h-4" /> <ThumbsUp className="w-4 h-4" />
<span>{likes} </span> <span>{likes} </span>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
@@ -212,8 +338,18 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500" /> <Star className="w-4 h-4 text-yellow-500" />
<span>{currentRating.toFixed(1)}</span> <span>{Number(rating).toFixed(1)}</span>
{reviewCount > 0 && <span className="text-gray-500">({reviewCount} )</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>
</div> </div>
@@ -262,7 +398,13 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<Calendar className="w-5 h-5 text-gray-500" /> <Calendar className="w-5 h-5 text-gray-500" />
<div> <div>
<p className="text-sm text-gray-500"></p> <p className="text-sm text-gray-500"></p>
<p className="font-medium">2024115</p> <p className="font-medium">
{(app as any).createdAt ? new Date((app as any).createdAt).toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : '未知'}
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -270,10 +412,10 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<div> <div>
<p className="text-sm text-gray-500 mb-2"></p> <p className="text-sm text-gray-500 mb-2"></p>
<ul className="space-y-1 text-sm"> <ul className="space-y-1 text-sm">
<li> </li> <li> {app.type} </li>
<li> </li> <li> {app.department} </li>
<li> </li> <li> </li>
<li> </li> <li> 使</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -282,28 +424,17 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<p className="text-sm text-gray-500 mb-2"></p> <p className="text-sm text-gray-500 mb-2"></p>
<p className="text-gray-700 leading-relaxed"> <p className="text-gray-700 leading-relaxed">
{app.description} {app.description || '暫無詳細描述'}
使
</p> </p>
</div> </div>
</CardContent> </CardContent>
</Card> </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>
<TabsContent value="statistics" className="space-y-6"> <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> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle> <CardTitle className="text-sm font-medium"></CardTitle>
@@ -311,7 +442,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-blue-600"> <div className="text-2xl font-bold text-blue-600">
{isLoadingStats ? '...' : appStats.basic.views} {isLoadingStats ? '...' : views}
</div> </div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</CardContent> </CardContent>
@@ -319,12 +450,25 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle> <CardTitle className="text-sm font-medium"></CardTitle>
<Heart className="h-4 w-4 text-red-500" /> <ThumbsUp className="h-4 w-4 text-red-500" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-red-600"> <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> </div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</CardContent> </CardContent>
@@ -337,7 +481,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-yellow-600"> <div className="text-2xl font-bold text-yellow-600">
{isLoadingStats ? '...' : appStats.basic.rating.toFixed(1)} {isLoadingStats ? '...' : Number(rating).toFixed(1)}
</div> </div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</CardContent> </CardContent>
@@ -350,7 +494,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-green-600"> <div className="text-2xl font-bold text-green-600">
{isLoadingStats ? '...' : appStats.basic.reviewCount} {isLoadingStats ? '...' : reviewsCount}
</div> </div>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</CardContent> </CardContent>
@@ -418,6 +562,7 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
className="w-36" className="w-36"
max={endDate}
/> />
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -430,13 +575,39 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
className="w-36" 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"
>
{isLoadingStats ? (
<>
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500 mr-1"></div>
</>
) : (
'重新載入'
)}
</Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <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 */} {/* Chart Container with Horizontal Scroll */}
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto">
<div <div
@@ -474,14 +645,14 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
{/* Chart Bars */} {/* Chart Bars */}
<div className="h-full flex items-end justify-between space-x-2" style={{ paddingTop: "40px" }}> <div className="h-full flex items-end justify-between space-x-2" style={{ paddingTop: "40px" }}>
{usageStats.trendData.map((day, index) => { {usageStats.trendData.map((day: any, index: number) => {
const maxUsers = Math.max(...usageStats.trendData.map((d) => d.users)) const maxUsers = Math.max(...usageStats.trendData.map((d: any) => d.users))
const minUsers = Math.min(...usageStats.trendData.map((d) => d.users)) const minUsers = Math.min(...usageStats.trendData.map((d: any) => d.users))
const range = maxUsers - minUsers const range = maxUsers - minUsers
const normalizedHeight = range > 0 ? ((day.users - minUsers) / range) * 70 + 15 : 40 const normalizedHeight = range > 0 ? ((day.users - minUsers) / range) * 70 + 15 : 40
const currentDate = new Date(day.date) const currentDate = new Date(day.date)
const prevDate = index > 0 ? new Date(usageStats.trendData[index - 1].date) : null 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 // Check if this is the start of a new month/year for divider
const isNewMonth = const isNewMonth =
@@ -525,9 +696,19 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</div> </div>
{/* Scroll Hint */} {/* Scroll Hint */}
{usageStats.trendData.length > 20 && ( {usageStats.trendData && usageStats.trendData.length > 20 && (
<div className="text-xs text-gray-500 text-center">💡 </div> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -540,18 +721,30 @@ export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProp
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{usageStats.topDepartments.map((dept) => ( {usageStats.topDepartments && usageStats.topDepartments.length > 0 ? (
<div key={dept.name} className="flex items-center justify-between"> 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="flex items-center space-x-3">
<div className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" /> <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> <span className="font-medium">{dept.department || '未知部門'}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">{dept.users} </span> <span className="text-sm text-gray-600">{dept.count} </span>
<span className="text-sm font-medium">{dept.percentage}%</span> <span className="text-sm font-medium">{percentage}%</span>
</div> </div>
</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> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -6,7 +6,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { BarChart3, Clock, Heart, ImageIcon, MessageSquare, FileText, TrendingUp, Trash2, RefreshCw } from "lucide-react" import {
BarChart3, Clock, Heart, ImageIcon, MessageSquare, FileText, TrendingUp, Trash2, RefreshCw,
Bot, Mic, Settings, Zap, Star, Eye, Target, Users, Lightbulb, Search, Database, Shield, Cpu,
Globe, Layers, PieChart, Activity, Calendar, Code, Command, Compass, CreditCard, Download,
Edit, ExternalLink, Filter, Flag, Folder, Gift, Home, Info, Key, Link, Lock, Mail, MapPin,
Minus, Monitor, MoreHorizontal, MoreVertical, MousePointer, Navigation, Pause, Play, Plus,
Power, Save, Send, Share, Smartphone, Tablet, Upload, Volume2, Wifi, X, ZoomIn, ZoomOut
} from "lucide-react"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
interface ActivityRecordsDialogProps { interface ActivityRecordsDialogProps {
@@ -30,74 +37,47 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
const [recentApps, setRecentApps] = useState<any[]>([]) const [recentApps, setRecentApps] = useState<any[]>([])
const [categoryData, setCategoryData] = useState<any[]>([]) const [categoryData, setCategoryData] = useState<any[]>([])
const [userStats, setUserStats] = useState({
totalUsage: 0,
favoriteApps: 0,
daysJoined: 0
})
const [isLoading, setIsLoading] = useState(false)
const [isResetting, setIsResetting] = useState(false) const [isResetting, setIsResetting] = useState(false)
if (!user) return null if (!user) return null
// Calculate user statistics // Load user activity data from API
const calculateUserStats = () => { const loadUserActivity = async () => {
if (!user) return { if (!user) return
setIsLoading(true)
try {
const response = await fetch(`/api/user/activity?userId=${user.id}`)
const data = await response.json()
if (data.success) {
setRecentApps(data.data.recentApps || [])
setCategoryData(data.data.categoryStats || [])
setUserStats(data.data.userStats || {
totalUsage: 0, totalUsage: 0,
totalDuration: 0,
favoriteApps: 0, favoriteApps: 0,
daysJoined: 0 daysJoined: 0
})
} }
} catch (error) {
// Calculate total usage count (views) console.error('載入用戶活動數據錯誤:', error)
const totalUsage = Object.values(user.recentApps || []).length } finally {
setIsLoading(false)
// Calculate total duration (simplified - 5 minutes per app view)
const totalDuration = totalUsage * 5 // minutes
// Get favorite apps count
const favoriteApps = user.favoriteApps?.length || 0
// Calculate days joined
const joinDate = new Date(user.joinDate)
const now = new Date()
// Check if joinDate is valid
let daysJoined = 0
if (!isNaN(joinDate.getTime())) {
daysJoined = Math.floor((now.getTime() - joinDate.getTime()) / (1000 * 60 * 60 * 24))
}
return {
totalUsage,
totalDuration,
favoriteApps,
daysJoined: Math.max(0, daysJoined)
} }
} }
const stats = calculateUserStats() // Load data when dialog opens
// Load recent apps from user's recent apps
useEffect(() => { useEffect(() => {
if (user?.recentApps) { if (open && user) {
// Convert recent app IDs to app objects (simplified) loadUserActivity()
const recentAppsData = user.recentApps.slice(0, 10).map((appId, index) => ({
id: appId,
name: `應用 ${appId}`,
author: "系統",
category: "AI應用",
usageCount: getViewCount(appId),
timeSpent: "5分鐘",
lastUsed: `${index + 1}天前`,
icon: MessageSquare,
color: "bg-blue-500"
}))
setRecentApps(recentAppsData)
} else {
setRecentApps([])
} }
}, [user, getViewCount]) }, [open, user])
// Load category data (simplified)
useEffect(() => {
// This would normally be calculated from actual usage data
setCategoryData([])
}, [user])
// Reset user activity data // Reset user activity data
const resetActivityData = async () => { const resetActivityData = async () => {
@@ -155,38 +135,150 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
<h3 className="text-lg font-semibold mb-2">使</h3> <h3 className="text-lg font-semibold mb-2">使</h3>
<p className="text-sm text-muted-foreground mb-4"> AI </p> <p className="text-sm text-muted-foreground mb-4"> AI </p>
{recentApps.length > 0 ? ( {isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-2"></div>
<p className="text-gray-500">...</p>
</div>
) : recentApps.length > 0 ? (
<div className="grid gap-4"> <div className="grid gap-4">
{recentApps.map((app) => { {recentApps.map((app) => {
const IconComponent = app.icon // 圖標映射函數
const getIconComponent = (iconName: string) => {
const iconMap: { [key: string]: any } = {
'Bot': Bot,
'ImageIcon': ImageIcon,
'Mic': Mic,
'MessageSquare': MessageSquare,
'Settings': Settings,
'Zap': Zap,
'TrendingUp': TrendingUp,
'Star': Star,
'Heart': Heart,
'Eye': Eye,
'Target': Target,
'Users': Users,
'Lightbulb': Lightbulb,
'Search': Search,
'BarChart3': BarChart3,
'Database': Database,
'Shield': Shield,
'Cpu': Cpu,
'FileText': FileText,
'Globe': Globe,
'Layers': Layers,
'PieChart': PieChart,
'Activity': Activity,
'Calendar': Calendar,
'Clock': Clock,
'Code': Code,
'Command': Command,
'Compass': Compass,
'CreditCard': CreditCard,
'Download': Download,
'Edit': Edit,
'ExternalLink': ExternalLink,
'Filter': Filter,
'Flag': Flag,
'Folder': Folder,
'Gift': Gift,
'Home': Home,
'Info': Info,
'Key': Key,
'Link': Link,
'Lock': Lock,
'Mail': Mail,
'MapPin': MapPin,
'Minus': Minus,
'Monitor': Monitor,
'MoreHorizontal': MoreHorizontal,
'MoreVertical': MoreVertical,
'MousePointer': MousePointer,
'Navigation': Navigation,
'Pause': Pause,
'Play': Play,
'Plus': Plus,
'Power': Power,
'RefreshCw': RefreshCw,
'Save': Save,
'Send': Send,
'Share': Share,
'Smartphone': Smartphone,
'Tablet': Tablet,
'Trash2': Trash2,
'Upload': Upload,
'Volume2': Volume2,
'Wifi': Wifi,
'X': X,
'ZoomIn': ZoomIn,
'ZoomOut': ZoomOut,
}
return iconMap[iconName] || MessageSquare
}
const IconComponent = getIconComponent(app.icon || 'MessageSquare')
const lastUsedDate = new Date(app.lastUsed)
const now = new Date()
const diffTime = Math.abs(now.getTime() - lastUsedDate.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return ( return (
<Card key={app.id} className="hover:shadow-md transition-shadow"> <Card key={app.id} className="hover:shadow-md transition-shadow">
<CardContent className="flex items-center justify-between p-4"> <CardContent className="flex items-center justify-between p-4">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className={`p-3 rounded-lg ${app.color}`}> <div className={`p-3 rounded-lg bg-gradient-to-r ${app.iconColor || 'from-blue-500 to-purple-500'}`}>
<IconComponent className="w-6 h-6 text-white" /> <IconComponent className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
<h4 className="font-semibold">{app.name}</h4> <h4 className="font-semibold">{app.name}</h4>
<p className="text-sm text-muted-foreground">by {app.author}</p> <p className="text-sm text-muted-foreground">by {app.creator}</p>
<div className="flex items-center gap-4 mt-1"> <div className="flex items-center gap-4 mt-1">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{app.category} {app.type}
</Badge> </Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1"> <span className="text-xs text-muted-foreground flex items-center gap-1">
<BarChart3 className="w-3 h-3" /> <BarChart3 className="w-3 h-3" />
使 {app.usageCount} 使 {app.usageCount}
</span> </span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{app.timeSpent}
</span>
</div> </div>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-xs text-muted-foreground mb-2">{app.lastUsed}</p> <p className="text-xs text-muted-foreground mb-2">
<Button size="sm" variant="outline"> {diffDays === 1 ? '昨天' : diffDays < 7 ? `${diffDays}天前` : `${Math.ceil(diffDays / 7)}週前`}
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
// 記錄活動
if (user) {
fetch('/api/user/activity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: user.id,
action: 'view',
resourceType: 'app',
resourceId: app.id,
details: JSON.stringify({ appName: app.name })
})
}).catch(console.error)
}
// 增加查看次數
fetch(`/api/apps/${app.id}/interactions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'view', userId: user?.id })
}).catch(console.error)
// 打開應用
if (app.appUrl && app.appUrl !== '#') {
window.open(app.appUrl, '_blank')
}
}}
>
</Button> </Button>
</div> </div>
@@ -237,35 +329,16 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
</div> </div>
{/* Statistics Cards */} {/* Statistics Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle> <CardTitle className="text-sm font-medium">使</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" /> <TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{isNaN(stats.totalUsage) ? 0 : stats.totalUsage}</div> <div className="text-2xl font-bold">{isNaN(userStats.totalUsage) ? 0 : userStats.totalUsage}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{(isNaN(stats.totalUsage) ? 0 : stats.totalUsage) > 0 ? "累計使用" : "尚未使用任何應用"} {(isNaN(userStats.totalUsage) ? 0 : userStats.totalUsage) > 0 ? "累計使用" : "尚未使用任何應用"}
</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>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{isNaN(stats.totalDuration) ? "0分鐘" : (
stats.totalDuration >= 60
? `${(stats.totalDuration / 60).toFixed(1)}小時`
: `${stats.totalDuration}分鐘`
)}
</div>
<p className="text-xs text-muted-foreground">
{(isNaN(stats.totalDuration) ? 0 : stats.totalDuration) > 0 ? "累計時長" : "尚未開始使用"}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -276,9 +349,9 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
<Heart className="h-4 w-4 text-muted-foreground" /> <Heart className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{isNaN(stats.favoriteApps) ? 0 : stats.favoriteApps}</div> <div className="text-2xl font-bold">{isNaN(userStats.favoriteApps) ? 0 : userStats.favoriteApps}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{(isNaN(stats.favoriteApps) ? 0 : stats.favoriteApps) > 0 ? "個人收藏" : "尚未收藏任何應用"} {(isNaN(userStats.favoriteApps) ? 0 : userStats.favoriteApps) > 0 ? "個人收藏" : "尚未收藏任何應用"}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -289,9 +362,9 @@ export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDia
<CardDescription></CardDescription> <CardDescription></CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{isNaN(stats.daysJoined) ? 0 : stats.daysJoined}</div> <div className="text-2xl font-bold">{isNaN(userStats.daysJoined) ? 0 : userStats.daysJoined}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{(isNaN(stats.daysJoined) ? 0 : stats.daysJoined) > 0 ? "已加入平台" : "今天剛加入"} {(isNaN(userStats.daysJoined) ? 0 : userStats.daysJoined) > 0 ? "已加入平台" : "今天剛加入"}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Heart } from "lucide-react" import { Heart } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -20,17 +21,31 @@ export function FavoriteButton({
size = "md", size = "md",
variant = "ghost", variant = "ghost",
}: FavoriteButtonProps) { }: FavoriteButtonProps) {
const { user, isFavorite, toggleFavorite } = useAuth()
const [isFavorited, setIsFavorited] = useState(initialFavorited) const [isFavorited, setIsFavorited] = useState(initialFavorited)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
// 使用 isFavorite 的實時結果
const currentFavorited = user ? isFavorite(appId) : isFavorited
// 載入收藏狀態
useEffect(() => {
if (user) {
const favorited = isFavorite(appId)
setIsFavorited(favorited)
}
}, [user, appId, isFavorite])
const handleToggle = async () => { const handleToggle = async () => {
if (!user) {
console.warn('用戶未登入,無法收藏')
return
}
setIsLoading(true) setIsLoading(true)
try { try {
// Simulate API call const newFavoriteState = await toggleFavorite(appId)
await new Promise((resolve) => setTimeout(resolve, 300))
const newFavoriteState = !isFavorited
setIsFavorited(newFavoriteState) setIsFavorited(newFavoriteState)
// Call the callback if provided // Call the callback if provided
@@ -58,16 +73,16 @@ export function FavoriteButton({
<Button <Button
variant={variant} variant={variant}
size="icon" size="icon"
className={cn(sizeClasses[size], "transition-all duration-200", isFavorited && "text-red-500 hover:text-red-600")} className={cn(sizeClasses[size], "transition-all duration-200", currentFavorited && "text-red-500 hover:text-red-600")}
onClick={handleToggle} onClick={handleToggle}
disabled={isLoading} disabled={isLoading}
title={isFavorited ? "取消收藏" : "加入收藏"} title={currentFavorited ? "取消收藏" : "加入收藏"}
> >
<Heart <Heart
className={cn( className={cn(
iconSizes[size], iconSizes[size],
"transition-all duration-200", "transition-all duration-200",
isFavorited && "fill-current", currentFavorited && "fill-current",
isLoading && "animate-pulse", isLoading && "animate-pulse",
)} )}
/> />

View File

@@ -1,28 +1,158 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect } from "react"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Heart, ExternalLink } from "lucide-react" import { Heart, ExternalLink, Star, Eye, ThumbsUp, MessageSquare, Brain, ImageIcon, Mic, MessageSquare as MessageSquareIcon, Settings, Zap, TrendingUp, Target, Users, Lightbulb, Search } from "lucide-react"
// Favorite apps data - empty for production
const mockFavoriteApps: any[] = []
export function FavoritesPage() { export function FavoritesPage() {
const { user } = useAuth() const { user } = useAuth()
const [sortBy, setSortBy] = useState("name") const [sortBy, setSortBy] = useState("favorited")
const [filterDepartment, setFilterDepartment] = useState("all") const [filterDepartment, setFilterDepartment] = useState("all")
const [favoriteApps, setFavoriteApps] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const handleUseApp = (app: any) => { // 載入收藏列表
// Open app in new tab const loadFavorites = async (page: number = 1) => {
window.open(app.url, "_blank") if (!user) return
console.log(`Opening app: ${app.name}`)
setIsLoading(true)
try {
const response = await fetch(`/api/user/favorites?userId=${user.id}&page=${page}&limit=12`)
const data = await response.json()
if (data.success) {
setFavoriteApps(data.data.apps)
setTotalPages(data.data.pagination.totalPages)
setCurrentPage(page)
}
} catch (error) {
console.error('載入收藏列表錯誤:', error)
} finally {
setIsLoading(false)
}
} }
const filteredAndSortedApps = mockFavoriteApps // 初始載入
useEffect(() => {
if (user) {
loadFavorites()
}
}, [user])
const handleUseApp = async (app: any) => {
console.log('handleUseApp 被調用', { user: user?.id, appId: app.id, appName: app.name })
try {
// Increment view count when using the app
if (user) {
const response = await fetch(`/api/apps/${app.id}/interactions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'view',
userId: user.id
})
})
if (response.ok) {
// 記錄用戶活動
try {
console.log('開始記錄用戶活動...')
const activityResponse = 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,
details: {
appName: app.name,
timestamp: new Date().toISOString()
}
})
})
if (activityResponse.ok) {
console.log('活動記錄成功')
} else {
console.error('活動記錄失敗:', activityResponse.status, activityResponse.statusText)
}
} catch (error) {
console.error('記錄活動失敗:', error)
}
// Reload favorites to update view count
await loadFavorites(currentPage)
} else {
console.error('增加查看次數失敗:', response.status, response.statusText)
}
} else {
console.log('用戶未登入,跳過活動記錄')
}
} catch (error) {
console.error('增加查看次數失敗:', error)
}
// Open app in new tab
if (app.appUrl) {
const url = app.appUrl.startsWith('http') ? app.appUrl : `https://${app.appUrl}`
window.open(url, "_blank", "noopener,noreferrer")
} else {
console.log(`Opening app: ${app.name}`)
}
}
// 圖標映射函數
const getIconComponent = (iconName: string) => {
const iconMap: { [key: string]: any } = {
'Bot': Brain,
'ImageIcon': ImageIcon,
'Mic': Mic,
'MessageSquare': MessageSquareIcon,
'Settings': Settings,
'Zap': Zap,
'TrendingUp': TrendingUp,
'Star': Star,
'Heart': Heart,
'Eye': Eye,
'Target': Target,
'Users': Users,
'Lightbulb': Lightbulb,
'Search': Search,
}
return iconMap[iconName] || Heart
}
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",
: "bg-pink-100 text-pink-800 border-pink-200",
: "bg-indigo-100 text-indigo-800 border-indigo-200",
: "bg-cyan-100 text-cyan-800 border-cyan-200",
: "bg-teal-100 text-teal-800 border-teal-200",
: "bg-yellow-100 text-yellow-800 border-yellow-200",
: "bg-rose-100 text-rose-800 border-rose-200",
: "bg-emerald-100 text-emerald-800 border-emerald-200",
: "bg-violet-100 text-violet-800 border-violet-200",
}
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
const filteredAndSortedApps = favoriteApps
.filter((app) => filterDepartment === "all" || app.department === filterDepartment) .filter((app) => filterDepartment === "all" || app.department === filterDepartment)
.sort((a, b) => { .sort((a, b) => {
switch (sortBy) { switch (sortBy) {
@@ -32,6 +162,8 @@ export function FavoritesPage() {
return a.creator.localeCompare(b.creator) return a.creator.localeCompare(b.creator)
case "department": case "department":
return a.department.localeCompare(b.department) return a.department.localeCompare(b.department)
case "favorited":
return new Date(b.favoritedAt).getTime() - new Date(a.favoritedAt).getTime()
default: default:
return 0 return 0
} }
@@ -39,89 +171,161 @@ export function FavoritesPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */}
<div className="flex items-center justify-end">
<div className="text-sm text-gray-500">
{favoriteApps.length}
</div>
</div>
{/* Filter and Sort Controls */} {/* Filter and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between"> <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex gap-3"> <div className="flex flex-col sm:flex-row gap-4">
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
<SelectTrigger className="w-48">
<SelectValue placeholder="選擇部門" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="HR">HR</SelectItem>
<SelectItem value="Finance">Finance</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}> <Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-32"> <SelectTrigger className="w-48">
<SelectValue placeholder="排序方式" /> <SelectValue placeholder="排序方式" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="name"></SelectItem> <SelectItem value="name"></SelectItem>
<SelectItem value="creator"></SelectItem> <SelectItem value="creator"></SelectItem>
<SelectItem value="department"></SelectItem> <SelectItem value="department"></SelectItem>
</SelectContent> <SelectItem value="favorited"></SelectItem>
</Select>
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
<SelectTrigger className="w-32">
<SelectValue placeholder="部門篩選" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="text-sm text-gray-500"> {filteredAndSortedApps.length} </div>
</div> </div>
{/* Favorites Grid */} {/* Loading State */}
{filteredAndSortedApps.length > 0 ? ( {isLoading && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-2"></div>
<p className="text-gray-500">...</p>
</div>
)}
{/* Apps Grid */}
{!isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAndSortedApps.map((app) => ( {filteredAndSortedApps.map((app) => {
<Card key={app.id} className="h-full flex flex-col hover:shadow-lg transition-shadow"> const IconComponent = getIconComponent(app.icon || 'Heart')
<CardContent className="p-6 flex flex-col h-full"> return (
{/* Header with heart icon */} <Card key={app.id} className="group hover:shadow-lg transition-all duration-300">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex-1 pr-2">{app.name}</h3> <div className="flex items-center space-x-3">
<Heart className="w-5 h-5 text-red-500 fill-current flex-shrink-0" /> <div className={`w-12 h-12 bg-gradient-to-r ${app.iconColor || 'from-purple-500 to-pink-500'} rounded-lg flex items-center justify-center`}>
<IconComponent className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-semibold text-lg group-hover:text-purple-600 transition-colors">
{app.name}
</h3>
<p className="text-sm text-gray-500">by {app.creator}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleUseApp(app)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<ExternalLink className="w-4 h-4" />
</Button>
</div> </div>
{/* Description */} <p className="text-gray-600 mb-4 line-clamp-2">{app.description}</p>
<p className="text-gray-600 text-sm mb-4 line-clamp-2 flex-grow">{app.description}</p>
{/* Developer and Department */} <div className="flex flex-wrap gap-2 mb-4">
<div className="flex items-center justify-between mb-4"> <Badge variant="secondary" className={getTypeColor(app.type)}>
<span className="text-sm text-gray-700">: {app.creator}</span> {app.type}
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200">
{app.department}
</Badge> </Badge>
<Badge variant="outline">{app.department}</Badge>
</div> </div>
{/* Tags */} {/* 統計數據區塊 - 優化佈局 */}
<div className="flex flex-wrap gap-2 mb-6 flex-grow"> <div className="space-y-4">
{app.tags.map((tag, index) => ( {/* 評分 - 突出顯示 */}
<Badge key={index} variant="secondary" className="text-xs"> <div className="flex items-center justify-center bg-gradient-to-r from-yellow-50 to-orange-50 rounded-lg px-4 py-3 border border-yellow-200">
{tag} <Star className="w-5 h-5 text-yellow-500 mr-2" />
</Badge> <span className="text-lg font-bold text-gray-800">{Number(app.rating).toFixed(1)}</span>
))} <span className="text-sm text-gray-500 ml-1"></span>
</div> </div>
{/* Action Button */} {/* 其他統計數據 - 3列佈局 */}
<div className="mt-auto flex-shrink-0"> <div className="grid grid-cols-3 gap-2">
<Button className="w-full bg-black hover:bg-gray-800 text-white" onClick={() => handleUseApp(app)}> <div className="flex flex-col items-center space-y-1 text-sm text-gray-600 bg-gray-50 rounded-lg px-2 py-2">
<ExternalLink className="w-4 h-4 mr-2" /> <Eye className="w-4 h-4 text-blue-500" />
<span className="font-medium text-base">{app.views}</span>
<span className="text-xs text-gray-400"></span>
</div>
<div className="flex flex-col items-center space-y-1 text-sm text-gray-600 bg-gray-50 rounded-lg px-2 py-2">
<ThumbsUp className="w-4 h-4 text-red-500" />
<span className="font-medium text-base">{app.likes}</span>
<span className="text-xs text-gray-400"></span>
</div>
<div className="flex flex-col items-center space-y-1 text-sm text-gray-600 bg-gray-50 rounded-lg px-2 py-2">
<MessageSquare className="w-4 h-4 text-green-500" />
<span className="font-medium text-base">{app.reviewCount}</span>
<span className="text-xs text-gray-400"></span>
</div>
</div>
{/* 使用應用按鈕 */}
<Button
onClick={() => handleUseApp(app)}
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white py-2.5"
>
使 使
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} )
})}
</div> </div>
) : ( )}
{/* Empty State */}
{!isLoading && filteredAndSortedApps.length === 0 && (
<div className="text-center py-12"> <div className="text-center py-12">
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-600 mb-2"></h3> <h3 className="text-lg font-semibold text-gray-500 mb-2"></h3>
<p className="text-gray-500"> <p className="text-gray-400"> AI </p>
{filterDepartment !== "all" </div>
? "該部門暫無收藏的應用,請嘗試其他篩選條件" )}
: "您還沒有收藏任何應用,快去探索並收藏您喜歡的 AI 應用吧!"}
</p> {/* Pagination */}
{!isLoading && totalPages > 1 && (
<div className="flex justify-center space-x-2">
<Button
variant="outline"
onClick={() => loadFavorites(currentPage - 1)}
disabled={currentPage === 1}
>
</Button>
<span className="flex items-center px-4 text-sm text-gray-500">
{currentPage} {totalPages}
</span>
<Button
variant="outline"
onClick={() => loadFavorites(currentPage + 1)}
disabled={currentPage === totalPages}
>
</Button>
</div> </div>
)} )}
</div> </div>

View File

@@ -2,7 +2,7 @@
import type React from "react" import type React from "react"
import { useState } from "react" import { useState, useEffect } from "react"
import { ThumbsUp } from "lucide-react" import { ThumbsUp } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
@@ -14,15 +14,40 @@ interface LikeButtonProps {
size?: "sm" | "default" | "lg" size?: "sm" | "default" | "lg"
className?: string className?: string
showCount?: boolean showCount?: boolean
likeCount?: number
userLiked?: boolean
} }
export function LikeButton({ appId, size = "default", className, showCount = true }: LikeButtonProps) { export function LikeButton({ appId, size = "default", className, showCount = true, likeCount: propLikeCount, userLiked: propUserLiked }: LikeButtonProps) {
const { user, likeApp, getAppLikes, hasLikedToday } = useAuth() const { user, toggleLike, getAppLikes, isLiked } = useAuth()
const { toast } = useToast() const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [localLikeCount, setLocalLikeCount] = useState(propLikeCount || 0)
const [localUserLiked, setLocalUserLiked] = useState(propUserLiked || false)
const likeCount = getAppLikes(appId) const likeCount = localLikeCount
const hasLiked = user ? hasLikedToday(appId) : false const hasLiked = user ? isLiked(appId) : localUserLiked
// 載入用戶的按讚狀態
useEffect(() => {
if (user) {
const liked = isLiked(appId)
setLocalUserLiked(liked)
}
}, [user, appId, isLiked])
// 同步外部 props 變化
useEffect(() => {
if (propLikeCount !== undefined) {
setLocalLikeCount(propLikeCount)
}
}, [propLikeCount])
useEffect(() => {
if (propUserLiked !== undefined) {
setLocalUserLiked(propUserLiked)
}
}, [propUserLiked])
const handleLike = async (e: React.MouseEvent) => { const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
@@ -36,25 +61,37 @@ export function LikeButton({ appId, size = "default", className, showCount = tru
return return
} }
if (hasLiked) {
toast({
title: "今日已按讚",
description: "您今天已經為這個應用按過讚了",
variant: "destructive",
})
return
}
setIsLoading(true) setIsLoading(true)
// 立即更新本地狀態
const newLikedState = !hasLiked
const newLikeCount = hasLiked ? likeCount - 1 : likeCount + 1
setLocalUserLiked(newLikedState)
setLocalLikeCount(newLikeCount)
try { try {
await likeApp(appId) const success = await toggleLike(appId)
if (newLikedState) {
// 剛剛按讚了
toast({ toast({
title: "按讚成功!", title: "按讚成功!",
description: "感謝您的支持", description: "感謝您的支持",
}) })
} catch (error) { } else {
// 剛剛取消按讚了
toast({ toast({
title: "按讚失敗", title: "取消按讚",
description: "已取消對該應用的按讚",
})
}
} catch (error) {
console.error("按讚操作失敗:", error)
// 如果操作失敗,回滾本地狀態
setLocalUserLiked(hasLiked)
setLocalLikeCount(likeCount)
toast({
title: "操作失敗",
description: "請稍後再試", description: "請稍後再試",
variant: "destructive", variant: "destructive",
}) })
@@ -85,7 +122,7 @@ export function LikeButton({ appId, size = "default", className, showCount = tru
sizeClasses[size], sizeClasses[size],
"flex items-center", "flex items-center",
hasLiked hasLiked
? "text-blue-600 hover:text-blue-700 hover:bg-blue-50" ? "text-blue-600 bg-blue-50 border-blue-200 hover:text-blue-700 hover:bg-blue-100"
: "text-gray-500 hover:text-blue-600 hover:bg-blue-50", : "text-gray-500 hover:text-blue-600 hover:bg-blue-50",
"transition-all duration-200", "transition-all duration-200",
className, className,

View File

@@ -0,0 +1,496 @@
"use client"
import { useState, useEffect } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Separator } from "@/components/ui/separator"
import { Star, MessageSquare, ThumbsUp, ThumbsDown, Edit, Trash2, MoreHorizontal } from "lucide-react"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
interface Review {
id: string
userId: string
userName: string
userAvatar?: string
userDepartment: string
rating: number
comment: string
createdAt: string
updatedAt?: string
helpful: number
notHelpful: number
userHelpfulVotes: string[] // user IDs who voted helpful
userNotHelpfulVotes: string[] // user IDs who voted not helpful
}
interface ReviewSystemProps {
appId: string
appName: string
currentRating: number
onRatingUpdate: (newRating: number, reviewCount: number) => void
}
export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }: ReviewSystemProps) {
const { user, updateAppRating } = useAuth()
// Load reviews from database
const [reviews, setReviews] = useState<Review[]>([])
const [isLoadingReviews, setIsLoadingReviews] = useState(true)
const [showReviewForm, setShowReviewForm] = useState(false)
const [newRating, setNewRating] = useState(5)
const [newComment, setNewComment] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [editingReview, setEditingReview] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<"newest" | "oldest" | "helpful">("newest")
// Load reviews from database
const loadReviews = async () => {
try {
setIsLoadingReviews(true)
const response = await fetch(`/api/apps/${appId}/reviews?limit=100`) // 載入更多評論
if (response.ok) {
const data = await response.json()
if (data.success) {
// Transform database format to component format
const transformedReviews = data.data.reviews.map((review: any) => ({
id: review.id,
userId: review.user_id || 'unknown',
userName: review.userName || review.user_name || '未知用戶',
userAvatar: review.userAvatar || review.user_avatar,
userDepartment: review.userDepartment || review.user_department || '未知部門',
rating: review.rating,
comment: review.review || review.comment || '',
createdAt: review.ratedAt || review.rated_at || new Date().toISOString(),
helpful: 0,
notHelpful: 0,
userHelpfulVotes: [],
userNotHelpfulVotes: [],
}))
setReviews(transformedReviews)
}
}
} catch (error) {
console.error('載入評論錯誤:', error)
} finally {
setIsLoadingReviews(false)
}
}
// Load reviews on component mount
useEffect(() => {
loadReviews()
}, [appId])
const userReview = reviews.find((review) => review.userId === user?.id)
const canReview = user && !userReview
// Update rating when reviews change
useEffect(() => {
if (reviews.length > 0) {
const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
const newAvgRating = Number(avgRating.toFixed(1))
updateAppRating(appId, newAvgRating)
onRatingUpdate(newAvgRating, reviews.length)
} else {
updateAppRating(appId, 0)
onRatingUpdate(0, 0)
}
}, [reviews, appId, updateAppRating, onRatingUpdate])
const handleSubmitReview = async () => {
if (!user || !newComment.trim()) return
setIsSubmitting(true)
try {
const response = await fetch(`/api/apps/${appId}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
rating: newRating,
comment: newComment.trim(),
}),
})
if (response.ok) {
const data = await response.json()
if (data.success) {
// Reload reviews from database
await loadReviews()
setNewComment("")
setNewRating(5)
setShowReviewForm(false)
} else {
console.error('提交評論失敗:', data.error)
alert('提交評論失敗,請稍後再試')
}
} else {
console.error('提交評論失敗:', response.statusText)
alert('提交評論失敗,請稍後再試')
}
} catch (error) {
console.error('提交評論錯誤:', error)
alert('提交評論時發生錯誤,請稍後再試')
} finally {
setIsSubmitting(false)
}
}
const handleEditReview = async (reviewId: string) => {
if (!user || !newComment.trim()) return
setIsSubmitting(true)
try {
const response = await fetch(`/api/apps/${appId}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
rating: newRating,
comment: newComment.trim(),
}),
})
if (response.ok) {
const data = await response.json()
if (data.success) {
// Reload reviews from database
await loadReviews()
setEditingReview(null)
setNewComment("")
setNewRating(5)
} else {
console.error('更新評論失敗:', data.error)
alert('更新評論失敗,請稍後再試')
}
} else {
console.error('更新評論失敗:', response.statusText)
alert('更新評論失敗,請稍後再試')
}
} catch (error) {
console.error('更新評論錯誤:', error)
alert('更新評論時發生錯誤,請稍後再試')
} finally {
setIsSubmitting(false)
}
}
const handleDeleteReview = async (reviewId: string) => {
// For now, we'll just reload reviews since we don't have a delete API yet
// In a real implementation, you would call a DELETE API endpoint
await loadReviews()
}
const handleHelpfulVote = (reviewId: string, isHelpful: boolean) => {
if (!user) return
// For now, we'll just reload reviews since we don't have a helpful vote API yet
// In a real implementation, you would call an API endpoint to handle helpful votes
loadReviews()
}
const startEdit = (review: Review) => {
setEditingReview(review.id)
setNewRating(review.rating)
setNewComment(review.comment)
setShowReviewForm(true)
}
const cancelEdit = () => {
setEditingReview(null)
setNewComment("")
setNewRating(5)
setShowReviewForm(false)
}
const getInitials = (name: string) => {
return name.split("").slice(0, 2).join("").toUpperCase()
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("zh-TW", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
const renderStars = (rating: number, interactive = false, onRate?: (rating: number) => void) => {
return (
<div className="flex items-center space-x-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${
star <= rating ? "text-yellow-400 fill-current" : "text-gray-300"
} ${interactive ? "cursor-pointer hover:text-yellow-400" : ""}`}
onClick={() => interactive && onRate && onRate(star)}
/>
))}
</div>
)
}
const sortedReviews = [...reviews].sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
case "helpful":
return b.helpful - a.helpful
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
}
})
return (
<div className="space-y-6">
{/* Review Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription>
{isLoadingReviews ? (
"載入評價中..."
) : reviews.length > 0 ? (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
{renderStars(Math.round(currentRating))}
<span className="font-semibold">{Number(currentRating).toFixed(1)}</span>
<span className="text-gray-500">({reviews.length} )</span>
</div>
</div>
) : (
"尚無評價,成為第一個評價的用戶!"
)}
</CardDescription>
</CardHeader>
<CardContent>
{/* Rating Distribution */}
{reviews.length > 0 && (
<div className="space-y-2 mb-6">
{[5, 4, 3, 2, 1].map((rating) => {
const count = reviews.filter((r) => r.rating === rating).length
const percentage = (count / reviews.length) * 100
return (
<div key={rating} className="flex items-center space-x-3">
<div className="flex items-center space-x-1 w-12">
<span className="text-sm">{rating}</span>
<Star className="w-3 h-3 text-yellow-400 fill-current" />
</div>
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className="bg-yellow-400 h-2 rounded-full transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-sm text-gray-500 w-8">{count}</span>
</div>
)
})}
</div>
)}
{/* Add Review Button */}
{canReview && (
<Button
onClick={() => setShowReviewForm(true)}
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
<Star className="w-4 h-4 mr-2" />
</Button>
)}
{userReview && (
<Alert>
<MessageSquare className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Review Form */}
<Dialog open={showReviewForm} onOpenChange={setShowReviewForm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingReview ? "編輯評價" : "撰寫評價"}</DialogTitle>
<DialogDescription> {appName} 使</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block"></label>
{renderStars(newRating, true, setNewRating)}
</div>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<Textarea
placeholder="請分享您的使用體驗..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={4}
maxLength={500}
/>
<div className="text-xs text-gray-500 mt-1">{newComment.length}/500 </div>
</div>
<div className="flex space-x-3">
<Button
onClick={editingReview ? () => handleEditReview(editingReview) : handleSubmitReview}
disabled={isSubmitting || !newComment.trim()}
className="flex-1"
>
{isSubmitting ? "提交中..." : editingReview ? "更新評價" : "提交評價"}
</Button>
<Button variant="outline" onClick={cancelEdit}>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Reviews List */}
{isLoadingReviews ? (
<Card>
<CardContent className="p-6">
<div className="text-center text-gray-500">...</div>
</CardContent>
</Card>
) : reviews.length > 0 ? (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle> ({reviews.length})</CardTitle>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500"></span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="text-sm border rounded px-2 py-1"
>
<option value="newest"></option>
<option value="oldest"></option>
<option value="helpful"></option>
</select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{sortedReviews.map((review, index) => (
<div key={review.id}>
<div className="flex items-start space-x-4">
<Avatar className="w-10 h-10">
<AvatarImage src={review.userAvatar || "/placeholder.svg"} alt={review.userName} />
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
{getInitials(review.userName)}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="font-medium">{review.userName}</span>
<Badge variant="secondary" className="text-xs">
{review.userDepartment}
</Badge>
{renderStars(review.rating)}
</div>
{user?.id === review.userId && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => startEdit(review)}>
<Edit className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteReview(review.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<p className="text-gray-700">{review.comment}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center space-x-4">
<span>
{formatDate(review.createdAt)}
{review.updatedAt && " (已編輯)"}
</span>
</div>
{user && user.id !== review.userId && (
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleHelpfulVote(review.id, true)}
className={`text-xs ${
review.userHelpfulVotes.includes(user.id)
? "text-green-600 bg-green-50"
: "text-gray-500"
}`}
>
<ThumbsUp className="w-3 h-3 mr-1" />
({review.helpful})
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleHelpfulVote(review.id, false)}
className={`text-xs ${
review.userNotHelpfulVotes.includes(user.id)
? "text-red-600 bg-red-50"
: "text-gray-500"
}`}
>
<ThumbsDown className="w-3 h-3 mr-1" />
({review.notHelpful})
</Button>
</div>
)}
</div>
</div>
</div>
{index < sortedReviews.length - 1 && <Separator className="mt-6" />}
</div>
))}
</div>
</CardContent>
</Card>
) : null}
</div>
)
}

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useState, useEffect, useCallback } from "react"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -27,6 +27,7 @@ interface Review {
notHelpful: number notHelpful: number
userHelpfulVotes: string[] // user IDs who voted helpful userHelpfulVotes: string[] // user IDs who voted helpful
userNotHelpfulVotes: string[] // user IDs who voted not helpful userNotHelpfulVotes: string[] // user IDs who voted not helpful
userVote?: boolean // true = 有幫助, false = 沒幫助, undefined = 未投票
} }
interface ReviewSystemProps { interface ReviewSystemProps {
@@ -39,13 +40,16 @@ interface ReviewSystemProps {
export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }: ReviewSystemProps) { export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }: ReviewSystemProps) {
const { user, updateAppRating } = useAuth() const { user, updateAppRating } = useAuth()
// Load reviews from localStorage // Load reviews from database
const [reviews, setReviews] = useState<Review[]>(() => { const [reviews, setReviews] = useState<Review[]>([])
if (typeof window !== "undefined") { const [isLoadingReviews, setIsLoadingReviews] = useState(true)
const saved = localStorage.getItem(`reviews_${appId}`) const [pagination, setPagination] = useState({
return saved ? JSON.parse(saved) : [] page: 1,
} limit: 5,
return [] total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false
}) })
const [showReviewForm, setShowReviewForm] = useState(false) const [showReviewForm, setShowReviewForm] = useState(false)
@@ -55,133 +59,210 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
const [editingReview, setEditingReview] = useState<string | null>(null) const [editingReview, setEditingReview] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<"newest" | "oldest" | "helpful">("newest") const [sortBy, setSortBy] = useState<"newest" | "oldest" | "helpful">("newest")
const userReview = reviews.find((review) => review.userId === user?.id) // Load reviews from database
const canReview = user && !userReview const loadReviews = useCallback(async (page: number = 1) => {
try {
setIsLoadingReviews(true)
const response = await fetch(`/api/apps/${appId}/reviews?page=${page}&limit=100`)
if (response.ok) {
const data = await response.json()
if (data.success) {
// Transform database format to component format
const transformedReviews = data.data.reviews.map((review: any) => ({
id: review.id,
userId: review.user_id || 'unknown',
userName: review.userName || review.user_name || '未知用戶',
userAvatar: review.userAvatar || review.user_avatar,
userDepartment: review.userDepartment || review.user_department || '未知部門',
rating: Number(review.rating) || 0, // 確保 rating 是數字
comment: review.review || review.comment || '', // 使用 review 字段
createdAt: review.ratedAt || review.rated_at || new Date().toISOString(),
helpful: 0,
notHelpful: 0,
userHelpfulVotes: [],
userNotHelpfulVotes: [],
}))
// Save reviews to localStorage and update app rating // 載入每個評論的投票統計
const saveReviews = (updatedReviews: Review[]) => { for (const review of transformedReviews) {
if (typeof window !== "undefined") { try {
localStorage.setItem(`reviews_${appId}`, JSON.stringify(updatedReviews)) const voteUrl = `/api/reviews/${review.id}/votes${user ? `?userId=${user.id}` : ''}`
const voteResponse = await fetch(voteUrl)
if (voteResponse.ok) {
const voteData = await voteResponse.json()
if (voteData.success) {
review.helpful = voteData.data.helpful
review.notHelpful = voteData.data.notHelpful
review.userVote = voteData.data.userVote // 用戶的投票狀態
}
} else {
console.error('投票 API 響應失敗:', voteResponse.status, voteResponse.statusText)
}
} catch (error) {
console.error('載入評論投票錯誤:', error)
}
} }
setReviews(updatedReviews)
// Calculate new average rating and update in context setReviews(transformedReviews)
if (updatedReviews.length > 0) { setPagination(data.data.pagination)
const avgRating = updatedReviews.reduce((sum, r) => sum + r.rating, 0) / updatedReviews.length }
}
} catch (error) {
console.error('載入評論錯誤:', error)
} finally {
setIsLoadingReviews(false)
}
}, [appId, user])
// Load reviews on component mount
useEffect(() => {
loadReviews()
}, [loadReviews])
const userReview = reviews.find((review) => review.userId === user?.id)
const canReview = user // 允許用戶多次評論,移除 !userReview 限制
// Update rating when reviews change
useEffect(() => {
if (reviews.length > 0) {
const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
const newAvgRating = Number(avgRating.toFixed(1)) const newAvgRating = Number(avgRating.toFixed(1))
updateAppRating(appId, newAvgRating) updateAppRating(appId, newAvgRating)
onRatingUpdate(newAvgRating, updatedReviews.length) onRatingUpdate(newAvgRating, reviews.length)
} else { } else {
updateAppRating(appId, 0) updateAppRating(appId, 0)
onRatingUpdate(0, 0) onRatingUpdate(0, 0)
} }
} }, [reviews, appId]) // 移除函數依賴項以避免無限循環
const handleSubmitReview = async () => { const handleSubmitReview = async () => {
if (!user || !newComment.trim()) return if (!user || !newComment.trim()) return
setIsSubmitting(true) setIsSubmitting(true)
const review: Review = { try {
id: `r${Date.now()}`, const response = await fetch(`/api/apps/${appId}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id, userId: user.id,
userName: user.name,
userAvatar: user.avatar,
userDepartment: user.department,
rating: newRating, rating: newRating,
comment: newComment.trim(), comment: newComment.trim(),
createdAt: new Date().toISOString(), }),
helpful: 0, })
notHelpful: 0,
userHelpfulVotes: [],
userNotHelpfulVotes: [],
}
const updatedReviews = [...reviews, review]
saveReviews(updatedReviews)
if (response.ok) {
const data = await response.json()
if (data.success) {
// Reload reviews from database
await loadReviews()
setNewComment("") setNewComment("")
setNewRating(5) setNewRating(5)
setShowReviewForm(false) setShowReviewForm(false)
} else {
console.error('提交評論失敗:', data.error)
alert('提交評論失敗,請稍後再試')
}
} else {
console.error('提交評論失敗:', response.statusText)
alert('提交評論失敗,請稍後再試')
}
} catch (error) {
console.error('提交評論錯誤:', error)
alert('提交評論時發生錯誤,請稍後再試')
} finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
}
const handleEditReview = async (reviewId: string) => { const handleEditReview = async (reviewId: string) => {
if (!user || !newComment.trim()) return if (!user || !newComment.trim()) return
setIsSubmitting(true) setIsSubmitting(true)
const updatedReviews = reviews.map((review) => try {
review.id === reviewId const response = await fetch(`/api/apps/${appId}/reviews`, {
? { method: 'POST',
...review, headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id,
rating: newRating, rating: newRating,
comment: newComment.trim(), comment: newComment.trim(),
updatedAt: new Date().toISOString(), reviewId: reviewId, // 傳遞 reviewId 用於更新
} }),
: review, })
)
saveReviews(updatedReviews)
if (response.ok) {
const data = await response.json()
if (data.success) {
// Reload reviews from database
await loadReviews()
setEditingReview(null) setEditingReview(null)
setNewComment("") setNewComment("")
setNewRating(5) setNewRating(5)
} else {
console.error('更新評論失敗:', data.error)
alert('更新評論失敗,請稍後再試')
}
} else {
console.error('更新評論失敗:', response.statusText)
alert('更新評論失敗,請稍後再試')
}
} catch (error) {
console.error('更新評論錯誤:', error)
alert('更新評論時發生錯誤,請稍後再試')
} finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
}
const handleDeleteReview = async (reviewId: string) => { const handleDeleteReview = async (reviewId: string) => {
const updatedReviews = reviews.filter((review) => review.id !== reviewId) // For now, we'll just reload reviews since we don't have a delete API yet
saveReviews(updatedReviews) // In a real implementation, you would call a DELETE API endpoint
await loadReviews()
} }
const handleHelpfulVote = (reviewId: string, isHelpful: boolean) => { const handleHelpfulVote = async (reviewId: string, isHelpful: boolean) => {
if (!user) return if (!user) return
const updatedReviews = reviews.map((review) => { try {
if (review.id !== reviewId) return review const response = await fetch(`/api/reviews/${reviewId}/votes`, {
method: 'POST',
const helpfulVotes = [...review.userHelpfulVotes] headers: {
const notHelpfulVotes = [...review.userNotHelpfulVotes] 'Content-Type': 'application/json',
},
if (isHelpful) { body: JSON.stringify({
if (helpfulVotes.includes(user.id)) { userId: user.id,
// Remove helpful vote isHelpful: isHelpful,
const index = helpfulVotes.indexOf(user.id) }),
helpfulVotes.splice(index, 1)
} else {
// Add helpful vote and remove not helpful if exists
helpfulVotes.push(user.id)
const notHelpfulIndex = notHelpfulVotes.indexOf(user.id)
if (notHelpfulIndex > -1) {
notHelpfulVotes.splice(notHelpfulIndex, 1)
}
}
} else {
if (notHelpfulVotes.includes(user.id)) {
// Remove not helpful vote
const index = notHelpfulVotes.indexOf(user.id)
notHelpfulVotes.splice(index, 1)
} else {
// Add not helpful vote and remove helpful if exists
notHelpfulVotes.push(user.id)
const helpfulIndex = helpfulVotes.indexOf(user.id)
if (helpfulIndex > -1) {
helpfulVotes.splice(helpfulIndex, 1)
}
}
}
return {
...review,
helpful: helpfulVotes.length,
notHelpful: notHelpfulVotes.length,
userHelpfulVotes: helpfulVotes,
userNotHelpfulVotes: notHelpfulVotes,
}
}) })
saveReviews(updatedReviews) if (response.ok) {
const data = await response.json()
if (data.success) {
// 更新本地評論數據
setReviews(prevReviews =>
prevReviews.map(review =>
review.id === reviewId
? {
...review,
helpful: data.data.helpful,
notHelpful: data.data.notHelpful,
userVote: data.data.userVote
}
: review
)
)
}
}
} catch (error) {
console.error('投票錯誤:', error)
}
} }
const startEdit = (review: Review) => { const startEdit = (review: Review) => {
@@ -198,18 +279,6 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
setShowReviewForm(false) setShowReviewForm(false)
} }
const sortedReviews = [...reviews].sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
case "helpful":
return b.helpful - a.helpful
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
}
})
const getInitials = (name: string) => { const getInitials = (name: string) => {
return name.split("").slice(0, 2).join("").toUpperCase() return name.split("").slice(0, 2).join("").toUpperCase()
} }
@@ -240,6 +309,18 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
) )
} }
const sortedReviews = [...reviews].sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
case "helpful":
return b.helpful - a.helpful
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
}
})
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Review Summary */} {/* Review Summary */}
@@ -250,11 +331,13 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
<span></span> <span></span>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{reviews.length > 0 ? ( {isLoadingReviews ? (
"載入評價中..."
) : reviews.length > 0 ? (
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{renderStars(Math.round(currentRating))} {renderStars(Math.round(currentRating))}
<span className="font-semibold">{currentRating}</span> <span className="font-semibold">{Number(currentRating).toFixed(1)}</span>
<span className="text-gray-500">({reviews.length} )</span> <span className="text-gray-500">({reviews.length} )</span>
</div> </div>
</div> </div>
@@ -270,6 +353,7 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
{[5, 4, 3, 2, 1].map((rating) => { {[5, 4, 3, 2, 1].map((rating) => {
const count = reviews.filter((r) => r.rating === rating).length const count = reviews.filter((r) => r.rating === rating).length
const percentage = (count / reviews.length) * 100 const percentage = (count / reviews.length) * 100
console.log(`評分 ${rating}: count=${count}, percentage=${percentage}%`) // 調試信息
return ( return (
<div key={rating} className="flex items-center space-x-3"> <div key={rating} className="flex items-center space-x-3">
<div className="flex items-center space-x-1 w-12"> <div className="flex items-center space-x-1 w-12">
@@ -300,12 +384,7 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
</Button> </Button>
)} )}
{userReview && ( {/* 移除「已評價過」的警告,允許用戶多次評論 */}
<Alert>
<MessageSquare className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -352,11 +431,17 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
</Dialog> </Dialog>
{/* Reviews List */} {/* Reviews List */}
{reviews.length > 0 && ( {isLoadingReviews ? (
<Card>
<CardContent className="p-6">
<div className="text-center text-gray-500">...</div>
</CardContent>
</Card>
) : reviews.length > 0 ? (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle> ({reviews.length})</CardTitle> <CardTitle> ({pagination.total})</CardTitle>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-sm text-gray-500"></span> <span className="text-sm text-gray-500"></span>
<select <select
@@ -431,12 +516,12 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
size="sm" size="sm"
onClick={() => handleHelpfulVote(review.id, true)} onClick={() => handleHelpfulVote(review.id, true)}
className={`text-xs ${ className={`text-xs ${
review.userHelpfulVotes.includes(user.id) review.userVote === true
? "text-green-600 bg-green-50" ? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500" : "text-gray-500 hover:text-green-600"
}`} }`}
> >
<ThumbsUp className="w-3 h-3 mr-1" /> <ThumbsUp className={`w-3 h-3 mr-1 ${review.userVote === true ? "fill-current" : ""}`} />
({review.helpful}) ({review.helpful})
</Button> </Button>
<Button <Button
@@ -444,12 +529,12 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
size="sm" size="sm"
onClick={() => handleHelpfulVote(review.id, false)} onClick={() => handleHelpfulVote(review.id, false)}
className={`text-xs ${ className={`text-xs ${
review.userNotHelpfulVotes.includes(user.id) review.userVote === false
? "text-red-600 bg-red-50" ? "text-red-600 bg-red-50 border-red-200"
: "text-gray-500" : "text-gray-500 hover:text-red-600"
}`} }`}
> >
<ThumbsDown className="w-3 h-3 mr-1" /> <ThumbsDown className={`w-3 h-3 mr-1 ${review.userVote === false ? "fill-current" : ""}`} />
({review.notHelpful}) ({review.notHelpful})
</Button> </Button>
</div> </div>
@@ -462,9 +547,39 @@ export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }:
</div> </div>
))} ))}
</div> </div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<div className="text-sm text-gray-500">
{((pagination.page - 1) * pagination.limit) + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} {pagination.total}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => loadReviews(pagination.page - 1)}
disabled={!pagination.hasPrev || isLoadingReviews}
>
</Button>
<span className="text-sm text-gray-500">
{pagination.page} / {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => loadReviews(pagination.page + 1)}
disabled={!pagination.hasNext || isLoadingReviews}
>
</Button>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)} ) : null}
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { createContext, useContext, useState, useEffect, type ReactNode } from "react" import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"
interface User { interface User {
id: string id: string
@@ -49,6 +49,7 @@ interface AuthContextType {
isInitialized: boolean isInitialized: boolean
// New like functionality // New like functionality
toggleLike: (appId: string) => Promise<boolean> toggleLike: (appId: string) => Promise<boolean>
isLiked: (appId: string) => boolean
hasLikedToday: (appId: string) => boolean hasLikedToday: (appId: string) => boolean
getAppLikesInPeriod: (appId: string, startDate: string, endDate: string) => number getAppLikesInPeriod: (appId: string, startDate: string, endDate: string) => number
getUserLikeHistory: () => Array<{ appId: string; date: string }> getUserLikeHistory: () => Array<{ appId: string; date: string }>
@@ -95,6 +96,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return {} return {}
}) })
// App statistics state (from database)
const [appStats, setAppStats] = useState<Record<string, {
likesCount: number
viewsCount: number
rating: number
reviewsCount: number
}>>({})
// User interaction states (from database) - 不使用 localStorage總是從資料庫載入
const [userLikes, setUserLikes] = useState<Record<string, string[]>>({})
const [userFavorites, setUserFavorites] = useState<Record<string, string[]>>({})
const [appLikes, setAppLikes] = useState<Record<string, AppLike[]>>(() => { const [appLikes, setAppLikes] = useState<Record<string, AppLike[]>>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const saved = localStorage.getItem("appLikes") const saved = localStorage.getItem("appLikes")
@@ -104,7 +117,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}) })
// New like system state with localStorage persistence // New like system state with localStorage persistence
const [userLikes, setUserLikes] = useState<Record<string, Array<{ appId: string; date: string }>>>(() => { const [userLikesOld, setUserLikesOld] = useState<Record<string, Array<{ appId: string; date: string }>>>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const saved = localStorage.getItem("userLikes") const saved = localStorage.getItem("userLikes")
return saved ? JSON.parse(saved) : {} return saved ? JSON.parse(saved) : {}
@@ -126,20 +139,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const storedUser = localStorage.getItem("user") const storedUser = localStorage.getItem("user")
if (storedUser) { if (storedUser) {
setUser(JSON.parse(storedUser)) const userData = JSON.parse(storedUser)
setUser(userData)
// 立即載入用戶的互動狀態,不使用 localStorage
fetchUserInteractions(userData.id)
} }
} }
setIsLoading(false) setIsLoading(false)
setIsInitialized(true) setIsInitialized(true)
}, []) }, [])
// Save likes to localStorage when they change // 當用戶登入時載入互動狀態
useEffect(() => {
if (user) {
fetchUserInteractions(user.id)
}
}, [user])
// Save old likes to localStorage when they change (保留舊系統的 localStorage)
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("userLikes", JSON.stringify(userLikes))
localStorage.setItem("appLikesOld", JSON.stringify(appLikesOld)) localStorage.setItem("appLikesOld", JSON.stringify(appLikesOld))
} }
}, [userLikes, appLikesOld]) }, [appLikesOld])
const login = async (email: string, password: string): Promise<boolean> => { const login = async (email: string, password: string): Promise<boolean> => {
setIsLoading(true) setIsLoading(true)
@@ -164,6 +186,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
setUser(userWithExtras) setUser(userWithExtras)
localStorage.setItem("user", JSON.stringify(userWithExtras)) localStorage.setItem("user", JSON.stringify(userWithExtras))
// 載入用戶的互動狀態
await fetchUserInteractions(data.user.id)
setIsLoading(false) setIsLoading(false)
return true return true
} else { } else {
@@ -262,29 +288,90 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const toggleFavorite = async (appId: string): Promise<boolean> => { const toggleFavorite = async (appId: string): Promise<boolean> => {
if (!user) return false if (!user) return false
const isFavorited = user.favoriteApps.includes(appId) try {
const updatedFavorites = isFavorited const isFavorited = userFavorites[user.id]?.includes(appId) || false
? user.favoriteApps.filter((id) => id !== appId)
: [...user.favoriteApps, appId]
// Update global likes counter (keeping for backward compatibility)
if (isFavorited) { if (isFavorited) {
appLikesCounter[appId] = Math.max(0, appLikesCounter[appId] - 1) // 移除收藏
} else { const response = await fetch(`/api/apps/${appId}/favorite?userId=${user.id}`, {
appLikesCounter[appId] = (appLikesCounter[appId] || 0) + 1 method: 'DELETE'
} })
const success = await updateProfile({ if (response.ok) {
const data = await response.json()
if (data.success) {
// 更新本地狀態
setUserFavorites(prev => ({
...prev,
[user.id]: (prev[user.id] || []).filter(id => id !== appId)
}))
// 更新用戶的 favoriteApps
const updatedFavorites = user.favoriteApps.filter((id) => id !== appId)
await updateProfile({
favoriteApps: updatedFavorites, favoriteApps: updatedFavorites,
}) })
return success return false
}
}
} else {
// 添加收藏
const response = await fetch(`/api/apps/${appId}/favorite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: user.id
})
})
if (response.ok) {
const data = await response.json()
if (data.success) {
// 更新本地狀態
setUserFavorites(prev => ({
...prev,
[user.id]: [...(prev[user.id] || []), appId]
}))
// 更新用戶的 favoriteApps
const updatedFavorites = [...user.favoriteApps, appId]
await updateProfile({
favoriteApps: updatedFavorites,
})
return true
}
} else if (response.status === 409) {
// 已經收藏過,更新本地狀態
setUserFavorites(prev => ({
...prev,
[user.id]: [...(prev[user.id] || []), appId]
}))
return true
}
}
} catch (error) {
console.error('切換收藏狀態錯誤:', error)
} }
const isFavorite = (appId: string): boolean => { return false
return user?.favoriteApps.includes(appId) || false
} }
const isFavorite = useCallback((appId: string): boolean => {
if (!user) return false
const userFavs = userFavorites[user.id] || []
return userFavs.includes(appId)
}, [user, userFavorites])
const isLiked = useCallback((appId: string): boolean => {
if (!user) return false
const userLikedApps = userLikes[user.id] || []
return userLikedApps.includes(appId)
}, [user, userLikes])
const addToRecentApps = (appId: string): void => { const addToRecentApps = (appId: string): void => {
if (!user) return if (!user) return
@@ -295,46 +382,93 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
const getAppLikes = (appId: string): number => { const getAppLikes = (appId: string): number => {
return appLikesCounter[appId] || 0 return appStats[appId]?.likesCount || 0
}
// 從資料庫獲取應用統計數據
const fetchAppStats = async (appId: string) => {
try {
const response = await fetch(`/api/apps/${appId}/interactions`)
if (response.ok) {
const data = await response.json()
if (data.success) {
setAppStats(prev => ({
...prev,
[appId]: data.data
}))
}
}
} catch (error) {
console.error('獲取應用統計數據錯誤:', error)
}
}
// 從資料庫獲取用戶的按讚和收藏狀態
const fetchUserInteractions = async (userId: string) => {
try {
const response = await fetch(`/api/user/interactions?userId=${userId}`)
if (response.ok) {
const data = await response.json()
if (data.success) {
setUserLikes(prev => ({ ...prev, [userId]: data.data.likes }))
setUserFavorites(prev => ({ ...prev, [userId]: data.data.favorites }))
}
}
} catch (error) {
console.error('獲取用戶互動狀態錯誤:', error)
}
} }
// New like functionality // New like functionality
const toggleLike = async (appId: string): Promise<boolean> => { const toggleLike = async (appId: string): Promise<boolean> => {
if (!user) return false if (!user) return false
const today = new Date().toISOString().split("T")[0] try {
const userLikeHistory = userLikes[user.id] || [] const isCurrentlyLiked = userLikes[user.id]?.includes(appId) || false
// Check if user has already liked this app today const response = await fetch(`/api/apps/${appId}/interactions`, {
const hasLikedTodayOld = userLikeHistory.some((like) => like.appId === appId && like.date === today) method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'like',
userId: user.id
})
})
if (hasLikedTodayOld) { if (response.ok) {
return false // Cannot like again today const data = await response.json()
if (data.success) {
setAppStats(prev => ({
...prev,
[appId]: data.data
}))
// 更新用戶的按讚狀態
const newLikedState = !isCurrentlyLiked
setUserLikes(prev => ({
...prev,
[user.id]: newLikedState
? [...(prev[user.id] || []), appId]
: (prev[user.id] || []).filter(id => id !== appId)
}))
return newLikedState
}
}
} catch (error) {
console.error('切換按讚狀態錯誤:', error)
} }
// Add like to user's history return false
const updatedUserLikes = {
...userLikes,
[user.id]: [...userLikeHistory, { appId, date: today }],
}
setUserLikes(updatedUserLikes)
// Add like to app's likes
const appLikeHistory = appLikesOld[appId] || []
const updatedAppLikes = {
...appLikesOld,
[appId]: [...appLikeHistory, { userId: user.id, date: today }],
}
setAppLikesOld(updatedAppLikes)
return true
} }
const hasLikedTodayOld = (appId: string): boolean => { const hasLikedTodayOld = (appId: string): boolean => {
if (!user) return false if (!user) return false
const today = new Date().toISOString().split("T")[0] const today = new Date().toISOString().split("T")[0]
const userLikeHistory = userLikes[user.id] || [] const userLikeHistory = userLikesOld[user.id] || []
return userLikeHistory.some((like) => like.appId === appId && like.date === today) return userLikeHistory.some((like) => like.appId === appId && like.date === today)
} }
@@ -348,22 +482,41 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const getUserLikeHistory = (): Array<{ appId: string; date: string }> => { const getUserLikeHistory = (): Array<{ appId: string; date: string }> => {
if (!user) return [] if (!user) return []
return userLikes[user.id] || [] return userLikesOld[user.id] || []
} }
// View count functionality // View count functionality
const incrementViewCount = (appId: string): void => { const incrementViewCount = async (appId: string): Promise<void> => {
setAppViews((prev) => { if (!user) return
const newViews = { ...prev, [appId]: (prev[appId] || 0) + 1 }
if (typeof window !== "undefined") { try {
localStorage.setItem("appViews", JSON.stringify(newViews)) const response = await fetch(`/api/apps/${appId}/interactions`, {
} method: 'POST',
return newViews headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'view',
userId: user.id
}) })
})
if (response.ok) {
const data = await response.json()
if (data.success) {
setAppStats(prev => ({
...prev,
[appId]: data.data
}))
}
}
} catch (error) {
console.error('增加觀看次數錯誤:', error)
}
} }
const getViewCount = (appId: string): number => { const getViewCount = (appId: string): number => {
return appViews[appId] || 0 return appStats[appId]?.viewsCount || 0
} }
// Rating functionality // Rating functionality
@@ -378,7 +531,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
const getAppRating = (appId: string): number => { const getAppRating = (appId: string): number => {
return appRatings[appId] || 0 return appStats[appId]?.rating || 0
} }
const getLikeCount = (appId: string): number => { const getLikeCount = (appId: string): number => {
@@ -432,6 +585,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return user?.role === "admin" return user?.role === "admin"
} }
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
@@ -454,6 +608,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isInitialized, isInitialized,
// New like functionality // New like functionality
toggleLike, toggleLike,
isLiked,
hasLikedTodayOld, hasLikedTodayOld,
getAppLikesInPeriod, getAppLikesInPeriod,
getUserLikeHistory, getUserLikeHistory,

View File

@@ -156,6 +156,7 @@ CREATE TABLE `apps` (
`type` VARCHAR(100) NOT NULL, `type` VARCHAR(100) NOT NULL,
`icon` VARCHAR(50) DEFAULT 'Bot', `icon` VARCHAR(50) DEFAULT 'Bot',
`icon_color` VARCHAR(100) DEFAULT 'from-blue-500 to-purple-500', `icon_color` VARCHAR(100) DEFAULT 'from-blue-500 to-purple-500',
`app_url` VARCHAR(500) NULL,
`likes_count` INT DEFAULT 0, `likes_count` INT DEFAULT 0,
`views_count` INT DEFAULT 0, `views_count` INT DEFAULT 0,
`rating` DECIMAL(3,2) DEFAULT 0.00, `rating` DECIMAL(3,2) DEFAULT 0.00,
@@ -359,13 +360,30 @@ CREATE TABLE `user_ratings` (
`rated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `rated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE, FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE,
UNIQUE KEY `unique_user_app_rating` (`user_id`, `app_id`),
INDEX `idx_user` (`user_id`), INDEX `idx_user` (`user_id`),
INDEX `idx_app` (`app_id`), INDEX `idx_app` (`app_id`),
INDEX `idx_rating` (`rating`) INDEX `idx_rating` (`rating`)
); );
-- 21. AI助手聊天會話表 -- 21. 評論投票表 (review_votes)
-- =====================================================
CREATE TABLE `review_votes` (
`id` VARCHAR(36) PRIMARY KEY,
`review_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
`is_helpful` BOOLEAN NOT NULL, -- true = 有幫助, false = 沒幫助
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`review_id`) REFERENCES `user_ratings`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
UNIQUE KEY `unique_user_review_vote` (`user_id`, `review_id`),
INDEX `idx_review` (`review_id`),
INDEX `idx_user` (`user_id`),
INDEX `idx_helpful` (`is_helpful`)
);
-- =====================================================
-- 22. AI助手聊天會話表
CREATE TABLE `chat_sessions` ( CREATE TABLE `chat_sessions` (
`id` VARCHAR(36) PRIMARY KEY, `id` VARCHAR(36) PRIMARY KEY,
`user_id` VARCHAR(36) NOT NULL, `user_id` VARCHAR(36) NOT NULL,

View File

@@ -178,6 +178,7 @@ CREATE TABLE `apps` (
`type` VARCHAR(100) NOT NULL, `type` VARCHAR(100) NOT NULL,
`icon` VARCHAR(50) DEFAULT 'Bot', `icon` VARCHAR(50) DEFAULT 'Bot',
`icon_color` VARCHAR(100) DEFAULT 'from-blue-500 to-purple-500', `icon_color` VARCHAR(100) DEFAULT 'from-blue-500 to-purple-500',
`app_url` VARCHAR(500) NULL,
`likes_count` INT DEFAULT 0, `likes_count` INT DEFAULT 0,
`views_count` INT DEFAULT 0, `views_count` INT DEFAULT 0,
`rating` DECIMAL(3,2) DEFAULT 0.00, `rating` DECIMAL(3,2) DEFAULT 0.00,
@@ -220,7 +221,25 @@ CREATE TABLE `proposals` (
); );
-- ===================================================== -- =====================================================
-- 11. 競賽參與應用表 (competition_apps) -- 11. 評論投票表 (review_votes)
-- =====================================================
CREATE TABLE `review_votes` (
`id` VARCHAR(36) PRIMARY KEY,
`review_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
`is_helpful` BOOLEAN NOT NULL, -- true = 有幫助, false = 沒幫助
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`review_id`) REFERENCES `user_ratings`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
UNIQUE KEY `unique_user_review_vote` (`user_id`, `review_id`),
INDEX `idx_review` (`review_id`),
INDEX `idx_user` (`user_id`),
INDEX `idx_helpful` (`is_helpful`)
);
-- =====================================================
-- 12. 競賽參與應用表 (competition_apps)
-- ===================================================== -- =====================================================
CREATE TABLE `competition_apps` ( CREATE TABLE `competition_apps` (
`id` VARCHAR(36) PRIMARY KEY, `id` VARCHAR(36) PRIMARY KEY,
@@ -415,7 +434,6 @@ CREATE TABLE `user_ratings` (
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE, FOREIGN KEY (`app_id`) REFERENCES `apps`(`id`) ON DELETE CASCADE,
UNIQUE KEY `unique_user_app_rating` (`user_id`, `app_id`),
INDEX `idx_user` (`user_id`), INDEX `idx_user` (`user_id`),
INDEX `idx_app` (`app_id`), INDEX `idx_app` (`app_id`),
INDEX `idx_rating` (`rating`) INDEX `idx_rating` (`rating`)

View File

@@ -128,6 +128,9 @@ export interface App {
team_id?: string; team_id?: string;
category: string; category: string;
type: string; type: string;
icon?: string;
icon_color?: string;
app_url?: string;
likes_count: number; likes_count: number;
views_count: number; views_count: number;
rating: number; rating: number;
@@ -326,6 +329,9 @@ export interface AppStatistics {
description?: string; description?: string;
category: string; category: string;
type: string; type: string;
icon?: string;
icon_color?: string;
app_url?: string;
likes_count: number; likes_count: number;
views_count: number; views_count: number;
rating: number; rating: number;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
const mysql = require('mysql2/promise');
// 資料庫連接配置
const dbConfig = {
host: 'mysql.theaken.com',
port: 33306,
user: 'AI_Platform',
password: 'Aa123456',
database: 'db_AI_Platform',
charset: 'utf8mb4'
};
async function checkAppStatus() {
let connection;
try {
console.log('連接到資料庫...');
connection = await mysql.createConnection(dbConfig);
const appId = '7f7395f4-ad9f-4d14-9e2c-84962ecbcfd7';
// 檢查應用狀態
console.log('檢查應用狀態...');
const appSql = 'SELECT id, name, is_active FROM apps WHERE id = ?';
const appResult = await connection.execute(appSql, [appId]);
console.log('應用狀態:', appResult[0]);
// 檢查活動日誌
console.log('\n檢查活動日誌...');
const activitySql = 'SELECT * FROM activity_logs WHERE resource_id = ? ORDER BY created_at DESC LIMIT 3';
const activityResult = await connection.execute(activitySql, [appId]);
console.log('活動日誌:', activityResult[0]);
// 測試 JOIN 查詢
console.log('\n測試 JOIN 查詢...');
const joinSql = `
SELECT
a.*,
u.name as creator_name,
u.department as creator_department,
al.created_at as last_used,
al.details
FROM activity_logs al
JOIN apps a ON al.resource_id = a.id
LEFT JOIN users u ON a.creator_id = u.id
WHERE al.user_id = ?
AND al.action = 'view'
AND al.resource_type = 'app'
AND a.is_active = TRUE
ORDER BY al.created_at DESC
LIMIT ?
`;
const joinResult = await connection.execute(joinSql, ['7fbe6712-fcce-45b8-9889-608232161315', 10]);
console.log('JOIN 查詢結果:', joinResult[0]);
} catch (error) {
console.error('檢查過程中發生錯誤:', error);
} finally {
if (connection) {
await connection.end();
console.log('資料庫連接已關閉');
}
}
}
// 執行檢查
checkAppStatus().catch(console.error);

View File

@@ -0,0 +1,92 @@
const mysql = require('mysql2/promise');
// 資料庫連接配置
const dbConfig = {
host: 'mysql.theaken.com',
port: 33306,
user: 'AI_Platform',
password: 'Aa123456',
database: 'db_AI_Platform',
charset: 'utf8mb4'
};
async function fixFavoritesDuplicates() {
let connection;
try {
console.log('連接到資料庫...');
connection = await mysql.createConnection(dbConfig);
console.log('檢查重複的收藏記錄...');
// 查找重複記錄
const [duplicates] = await connection.execute(`
SELECT user_id, app_id, COUNT(*) as count
FROM user_favorites
GROUP BY user_id, app_id
HAVING COUNT(*) > 1
`);
if (duplicates.length === 0) {
console.log('沒有發現重複的收藏記錄');
return;
}
console.log(`發現 ${duplicates.length} 組重複記錄:`);
for (const duplicate of duplicates) {
console.log(`用戶 ${duplicate.user_id} 對應用 ${duplicate.app_id}${duplicate.count} 條記錄`);
// 保留最早的記錄,刪除其他重複記錄
const [records] = await connection.execute(`
SELECT id, created_at
FROM user_favorites
WHERE user_id = ? AND app_id = ?
ORDER BY created_at ASC
`, [duplicate.user_id, duplicate.app_id]);
if (records.length > 1) {
// 保留第一條記錄,刪除其他記錄
const keepRecord = records[0];
const deleteIds = records.slice(1).map(r => r.id);
console.log(`保留記錄 ID: ${keepRecord.id} (創建時間: ${keepRecord.created_at})`);
console.log(`刪除記錄 IDs: ${deleteIds.join(', ')}`);
await connection.execute(`
DELETE FROM user_favorites
WHERE id IN (${deleteIds.map(() => '?').join(',')})
`, deleteIds);
console.log(`已清理 ${deleteIds.length} 條重複記錄`);
}
}
console.log('重複記錄清理完成!');
// 驗證修復結果
const [remainingDuplicates] = await connection.execute(`
SELECT user_id, app_id, COUNT(*) as count
FROM user_favorites
GROUP BY user_id, app_id
HAVING COUNT(*) > 1
`);
if (remainingDuplicates.length === 0) {
console.log('✅ 所有重複記錄已成功清理');
} else {
console.log('❌ 仍有重複記錄存在:', remainingDuplicates);
}
} catch (error) {
console.error('修復過程中發生錯誤:', error);
} finally {
if (connection) {
await connection.end();
console.log('資料庫連接已關閉');
}
}
}
// 執行修復
fixFavoritesDuplicates().catch(console.error);

86
scripts/test-favorites.js Normal file
View File

@@ -0,0 +1,86 @@
const mysql = require('mysql2/promise');
// 資料庫連接配置
const dbConfig = {
host: 'mysql.theaken.com',
port: 33306,
user: 'AI_Platform',
password: 'Aa123456',
database: 'db_AI_Platform',
charset: 'utf8mb4'
};
async function testFavorites() {
let connection;
try {
console.log('連接到資料庫...');
connection = await mysql.createConnection(dbConfig);
// 測試添加收藏
const testUserId = 'test-user-123';
const testAppId = 'test-app-456';
console.log('測試添加收藏...');
// 先清理可能存在的測試數據
await connection.execute(
'DELETE FROM user_favorites WHERE user_id = ? AND app_id = ?',
[testUserId, testAppId]
);
// 第一次添加收藏
const favoriteId1 = require('crypto').randomUUID();
await connection.execute(`
INSERT INTO user_favorites (id, user_id, app_id, created_at)
VALUES (?, ?, ?, NOW())
`, [favoriteId1, testUserId, testAppId]);
console.log('✅ 第一次添加收藏成功');
// 檢查是否已收藏
const [checkResult] = await connection.execute(`
SELECT COUNT(*) as count
FROM user_favorites
WHERE user_id = ? AND app_id = ?
`, [testUserId, testAppId]);
console.log(`收藏記錄數量: ${checkResult[0].count}`);
// 嘗試重複添加收藏(應該失敗)
try {
const favoriteId2 = require('crypto').randomUUID();
await connection.execute(`
INSERT INTO user_favorites (id, user_id, app_id, created_at)
VALUES (?, ?, ?, NOW())
`, [favoriteId2, testUserId, testAppId]);
console.log('❌ 重複添加收藏應該失敗但成功了');
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
console.log('✅ 重複添加收藏正確地被阻止');
} else {
console.log('❌ 重複添加收藏失敗,但錯誤類型不正確:', error.message);
}
}
// 清理測試數據
await connection.execute(
'DELETE FROM user_favorites WHERE user_id = ? AND app_id = ?',
[testUserId, testAppId]
);
console.log('✅ 測試數據已清理');
} catch (error) {
console.error('測試過程中發生錯誤:', error);
} finally {
if (connection) {
await connection.end();
console.log('資料庫連接已關閉');
}
}
}
// 執行測試
testFavorites().catch(console.error);