實作個人收藏、個人活動紀錄
This commit is contained in:
@@ -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
|
||||||
},
|
},
|
||||||
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
124
app/api/apps/[id]/favorite/route.ts
Normal file
124
app/api/apps/[id]/favorite/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
98
app/api/apps/[id]/interactions/route.ts
Normal file
98
app/api/apps/[id]/interactions/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
99
app/api/apps/[id]/reviews/route.ts
Normal file
99
app/api/apps/[id]/reviews/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
},
|
},
|
||||||
|
69
app/api/reviews/[id]/votes/route.ts
Normal file
69
app/api/reviews/[id]/votes/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
79
app/api/user/activity/route.ts
Normal file
79
app/api/user/activity/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
44
app/api/user/favorites/route.ts
Normal file
44
app/api/user/favorites/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
41
app/api/user/interactions/route.ts
Normal file
41
app/api/user/interactions/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
62
app/page.tsx
62
app/page.tsx
@@ -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}>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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">2024年1月15日</p>
|
<p className="font-medium">
|
||||||
|
{(app as any).createdAt ? new Date((app as any).createdAt).toLocaleDateString('zh-TW', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
}) : '未知'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@@ -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>
|
||||||
|
@@ -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,
|
||||||
|
496
components/reviews/review-system-fixed.tsx
Normal file
496
components/reviews/review-system-fixed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
@@ -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,
|
||||||
|
@@ -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`)
|
||||||
|
@@ -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
67
scripts/check-app-status.js
Normal file
67
scripts/check-app-status.js
Normal 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);
|
92
scripts/fix-favorites-duplicates.js
Normal file
92
scripts/fix-favorites-duplicates.js
Normal 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
86
scripts/test-favorites.js
Normal 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);
|
Reference in New Issue
Block a user