整合資料庫、完成登入註冊忘記密碼功能
This commit is contained in:
@@ -77,17 +77,27 @@ const mockNotifications: Notification[] = []
|
||||
const mockSearchData: SearchResult[] = []
|
||||
|
||||
export function AdminLayout({ children, currentPage, onPageChange }: AdminLayoutProps) {
|
||||
const { user, logout, isLoading } = useAuth()
|
||||
|
||||
// Move ALL hooks to the top, before any conditional logic
|
||||
const { user, logout, isLoading, isInitialized } = useAuth()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [showSearchResults, setShowSearchResults] = useState(false)
|
||||
|
||||
// Notification state
|
||||
const [notifications, setNotifications] = useState<Notification[]>(mockNotifications)
|
||||
const [showNotifications, setShowNotifications] = useState(false)
|
||||
|
||||
// Logout confirmation state
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
|
||||
|
||||
// Set client state after hydration
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
// Handle search
|
||||
useEffect(() => {
|
||||
if (searchQuery.trim()) {
|
||||
@@ -104,46 +114,6 @@ export function AdminLayout({ children, currentPage, onPageChange }: AdminLayout
|
||||
}
|
||||
}, [searchQuery])
|
||||
|
||||
// 認證檢查 - moved after all hooks
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-600">載入中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">需要登入</h1>
|
||||
<p className="text-gray-600 mb-6">請先登入才能訪問管理員頁面</p>
|
||||
<Button onClick={() => window.location.href = '/'}>
|
||||
返回首頁
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">權限不足</h1>
|
||||
<p className="text-gray-600 mb-6">您沒有訪問管理員頁面的權限</p>
|
||||
<Button onClick={() => window.location.href = '/'}>
|
||||
返回首頁
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get unread notification count
|
||||
const unreadCount = notifications.filter((n) => !n.read).length
|
||||
|
||||
@@ -207,13 +177,15 @@ export function AdminLayout({ children, currentPage, onPageChange }: AdminLayout
|
||||
setShowLogoutDialog(false)
|
||||
|
||||
// Check if this is a popup/new tab opened from main site
|
||||
if (typeof window !== 'undefined' && window.opener && !window.opener.closed) {
|
||||
// If opened from another window, close this tab and focus parent
|
||||
window.opener.focus()
|
||||
window.close()
|
||||
} else {
|
||||
// If this is the main window or standalone, redirect to homepage
|
||||
window.location.href = "/"
|
||||
if (isClient) {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
// If opened from another window, close this tab and focus parent
|
||||
window.opener.focus()
|
||||
window.close()
|
||||
} else {
|
||||
// If this is the main window or standalone, redirect to homepage
|
||||
window.location.href = "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +208,19 @@ export function AdminLayout({ children, currentPage, onPageChange }: AdminLayout
|
||||
}
|
||||
}
|
||||
|
||||
// 如果還在載入中,顯示載入畫面
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-600">載入中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 檢查用戶權限
|
||||
if (!user || user.role !== "admin") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
@@ -246,11 +231,20 @@ export function AdminLayout({ children, currentPage, onPageChange }: AdminLayout
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">存取被拒</h2>
|
||||
<p className="text-gray-600 mb-4">您沒有管理員權限訪問此頁面</p>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="text-xs text-gray-500 mb-4">
|
||||
調試信息: 用戶={user ? '已登入' : '未登入'}, 角色={user?.role || '無'}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-x-3">
|
||||
<Button onClick={() => (window.location.href = "/")} variant="outline">
|
||||
<Button onClick={() => {
|
||||
if (isClient) {
|
||||
window.location.href = "/"
|
||||
}
|
||||
}} variant="outline">
|
||||
返回首頁
|
||||
</Button>
|
||||
{typeof window !== 'undefined' && window.opener && !window.opener.closed && (
|
||||
{isClient && window.opener && !window.opener.closed && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.opener.focus()
|
||||
|
@@ -15,7 +15,7 @@ export function AdminPanel() {
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
case "dashboard":
|
||||
return <AdminDashboard onPageChange={setCurrentPage} />
|
||||
return <AdminDashboard />
|
||||
case "users":
|
||||
return <UserManagement />
|
||||
case "apps":
|
||||
@@ -27,7 +27,7 @@ export function AdminPanel() {
|
||||
case "settings":
|
||||
return <SystemSettings />
|
||||
default:
|
||||
return <AdminDashboard onPageChange={setCurrentPage} />
|
||||
return <AdminDashboard />
|
||||
}
|
||||
}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -20,19 +20,9 @@ const recentActivities: any[] = []
|
||||
|
||||
const topApps: any[] = []
|
||||
|
||||
interface AdminDashboardProps {
|
||||
onPageChange?: (page: string) => void
|
||||
}
|
||||
|
||||
export function AdminDashboard({ onPageChange }: AdminDashboardProps) {
|
||||
export function AdminDashboard() {
|
||||
const { competitions } = useCompetition()
|
||||
|
||||
const handleManageUsers = () => {
|
||||
if (onPageChange) {
|
||||
onPageChange("users")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
@@ -160,7 +150,7 @@ export function AdminDashboard({ onPageChange }: AdminDashboardProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button className="h-20 flex flex-col space-y-2" onClick={handleManageUsers}>
|
||||
<Button className="h-20 flex flex-col space-y-2">
|
||||
<Users className="w-6 h-6" />
|
||||
<span>管理用戶</span>
|
||||
</Button>
|
||||
|
@@ -18,6 +18,9 @@ import {
|
||||
Users,
|
||||
Bell,
|
||||
Save,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
TestTube,
|
||||
CheckCircle,
|
||||
HardDrive,
|
||||
@@ -67,6 +70,7 @@ export function SystemSettings() {
|
||||
|
||||
const [activeTab, setActiveTab] = useState("general")
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
|
||||
const [showSmtpPassword, setShowSmtpPassword] = useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveStatus("saving")
|
||||
@@ -78,8 +82,8 @@ export function SystemSettings() {
|
||||
}
|
||||
|
||||
const handleTestEmail = () => {
|
||||
// 測試郵件功能 - 僅用於開發測試
|
||||
console.log("測試郵件功能")
|
||||
// 測試郵件功能
|
||||
alert("測試郵件已發送!")
|
||||
}
|
||||
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
@@ -286,12 +290,23 @@ export function SystemSettings() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpPassword">SMTP 密碼</Label>
|
||||
<Input
|
||||
id="smtpPassword"
|
||||
type="password"
|
||||
value={settings.smtpPassword}
|
||||
onChange={(e) => updateSetting("smtpPassword", e.target.value)}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
id="smtpPassword"
|
||||
type={showSmtpPassword ? "text" : "password"}
|
||||
value={settings.smtpPassword}
|
||||
onChange={(e) => updateSetting("smtpPassword", e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSmtpPassword(!showSmtpPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showSmtpPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@@ -13,7 +13,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"
|
||||
import {
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
@@ -39,11 +38,8 @@ import {
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
// User data - empty for production
|
||||
const initialMockUsers: any[] = []
|
||||
|
||||
export function UserManagement() {
|
||||
const [users, setUsers] = useState(initialMockUsers)
|
||||
const [users, setUsers] = useState<any[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [selectedDepartment, setSelectedDepartment] = useState("all")
|
||||
const [selectedRole, setSelectedRole] = useState("all")
|
||||
@@ -53,94 +49,70 @@ export function UserManagement() {
|
||||
const [showInviteUser, setShowInviteUser] = useState(false)
|
||||
const [showEditUser, setShowEditUser] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [stats, setStats] = useState({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
adminCount: 0,
|
||||
developerCount: 0,
|
||||
inactiveUsers: 0,
|
||||
newThisMonth: 0
|
||||
})
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
// Set client state after hydration
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
// 載入用戶數據
|
||||
const loadUsers = async () => {
|
||||
if (!isClient) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
search: searchTerm,
|
||||
department: selectedDepartment,
|
||||
role: selectedRole,
|
||||
status: selectedStatus
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/admin/users?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setUsers(data.data.users)
|
||||
setStats(data.data.stats)
|
||||
setPagination(data.data.pagination)
|
||||
} else {
|
||||
console.error('載入用戶失敗:', data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('載入用戶錯誤:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 當篩選條件改變時重新載入
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [isClient, searchTerm, selectedDepartment, selectedRole, selectedStatus, pagination.page])
|
||||
|
||||
const [showInvitationLink, setShowInvitationLink] = useState(false)
|
||||
const [userToDelete, setUserToDelete] = useState<any>(null)
|
||||
const [generatedInvitation, setGeneratedInvitation] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [success, setSuccess] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
admin: 0,
|
||||
developer: 0,
|
||||
user: 0,
|
||||
today: 0,
|
||||
totalApps: 0,
|
||||
totalReviews: 0
|
||||
})
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [totalUsers, setTotalUsers] = useState(0)
|
||||
const [itemsPerPage] = useState(10) // Default to 10 items per page
|
||||
|
||||
// 載入用戶資料
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// 獲取用戶列表 with pagination
|
||||
const usersResponse = await fetch(`/api/users?page=${currentPage}&limit=${itemsPerPage}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (usersResponse.ok) {
|
||||
const usersData = await usersResponse.json()
|
||||
setUsers(usersData.users || [])
|
||||
setTotalPages(usersData.pagination?.totalPages || 1)
|
||||
setTotalUsers(usersData.pagination?.total || 0)
|
||||
} else {
|
||||
const errorData = await usersResponse.json().catch(() => ({}))
|
||||
console.error('獲取用戶列表失敗:', errorData.error || usersResponse.statusText)
|
||||
setError(errorData.error || '獲取用戶列表失敗')
|
||||
}
|
||||
|
||||
// 獲取統計資料
|
||||
const statsResponse = await fetch('/api/users/stats', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (statsResponse.ok) {
|
||||
const statsData = await statsResponse.json()
|
||||
setStats(statsData)
|
||||
} else {
|
||||
const errorData = await statsResponse.json().catch(() => ({}))
|
||||
console.error('獲取統計資料失敗:', errorData.error || statsResponse.statusText)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('載入用戶資料失敗:', error)
|
||||
setError('載入用戶資料失敗')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUsers()
|
||||
}, [currentPage, itemsPerPage]) // Re-fetch when page changes
|
||||
|
||||
// 重新獲取統計數據的函數
|
||||
const refreshStats = async () => {
|
||||
try {
|
||||
const statsResponse = await fetch('/api/users/stats', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (statsResponse.ok) {
|
||||
const statsData = await statsResponse.json()
|
||||
setStats(statsData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重新獲取統計資料失敗:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 邀請用戶表單狀態 - 包含電子郵件和預設角色
|
||||
const [inviteEmail, setInviteEmail] = useState("")
|
||||
@@ -156,65 +128,11 @@ export function UserManagement() {
|
||||
status: "",
|
||||
})
|
||||
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const matchesSearch =
|
||||
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesDepartment = selectedDepartment === "all" || user.department === selectedDepartment
|
||||
const matchesRole =
|
||||
selectedRole === "all" ||
|
||||
user.role === selectedRole ||
|
||||
(user.status === "invited" && (user as any).invitedRole === selectedRole)
|
||||
const matchesStatus = selectedStatus === "all" || user.status === selectedStatus
|
||||
// 篩選現在由 API 處理,不需要前端篩選
|
||||
|
||||
return matchesSearch && matchesDepartment && matchesRole && matchesStatus
|
||||
})
|
||||
|
||||
const handleViewUser = async (user: any) => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json()
|
||||
|
||||
// 獲取用戶活動記錄
|
||||
const activityResponse = await fetch(`/api/users/${user.id}/activity`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
let activityData = []
|
||||
if (activityResponse.ok) {
|
||||
activityData = await activityResponse.json()
|
||||
}
|
||||
|
||||
// 合併用戶資料和活動記錄
|
||||
const userWithActivity = {
|
||||
...userData,
|
||||
activities: activityData
|
||||
}
|
||||
|
||||
setSelectedUser(userWithActivity)
|
||||
setShowUserDetail(true)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || "獲取用戶詳情失敗")
|
||||
setTimeout(() => setError(""), 3000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user details:', error)
|
||||
setError("獲取用戶詳情失敗")
|
||||
setTimeout(() => setError(""), 3000)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
const handleViewUser = (user: any) => {
|
||||
setSelectedUser(user)
|
||||
setShowUserDetail(true)
|
||||
}
|
||||
|
||||
const handleEditUser = (user: any) => {
|
||||
@@ -237,39 +155,18 @@ export function UserManagement() {
|
||||
const handleToggleUserStatus = async (userId: string) => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const newStatus = users.find(user => user.id === userId)?.status === "active" ? "inactive" : "active"
|
||||
|
||||
const response = await fetch(`/api/users/${userId}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
// 模擬 API 調用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
if (response.ok) {
|
||||
setUsers(
|
||||
users.map((user) =>
|
||||
user.id === userId ? { ...user, status: newStatus } : user,
|
||||
),
|
||||
)
|
||||
setSuccess("用戶狀態更新成功!")
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
refreshStats() // 更新統計數據
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || "更新用戶狀態失敗")
|
||||
setTimeout(() => setError(""), 3000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user status:', error)
|
||||
setError("更新用戶狀態失敗")
|
||||
setTimeout(() => setError(""), 3000)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setUsers(
|
||||
users.map((user) =>
|
||||
user.id === userId ? { ...user, status: user.status === "active" ? "inactive" : "active" } : user,
|
||||
),
|
||||
)
|
||||
|
||||
setIsLoading(false)
|
||||
setSuccess("用戶狀態更新成功!")
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
}
|
||||
|
||||
const handleChangeUserRole = async (userId: string, newRole: string) => {
|
||||
@@ -283,7 +180,6 @@ export function UserManagement() {
|
||||
setIsLoading(false)
|
||||
setSuccess(`用戶權限已更新為${getRoleText(newRole)}!`)
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
refreshStats() // 更新統計數據
|
||||
}
|
||||
|
||||
const handleGenerateInvitation = async () => {
|
||||
@@ -310,43 +206,62 @@ export function UserManagement() {
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
// 模擬生成邀請連結
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
try {
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: inviteEmail,
|
||||
role: inviteRole
|
||||
})
|
||||
})
|
||||
|
||||
// 生成邀請 token(實際應用中會由後端生成)
|
||||
const invitationToken = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
||||
const invitationLink = `${window.location.origin}/register?token=${invitationToken}&email=${encodeURIComponent(inviteEmail)}&role=${inviteRole}`
|
||||
const data = await response.json()
|
||||
|
||||
const newInvitation = {
|
||||
id: Date.now().toString(),
|
||||
name: "",
|
||||
email: inviteEmail,
|
||||
department: "",
|
||||
role: "",
|
||||
status: "invited",
|
||||
joinDate: "",
|
||||
lastLogin: "",
|
||||
totalApps: 0,
|
||||
totalReviews: 0,
|
||||
totalLikes: 0,
|
||||
invitationSentAt: new Date().toLocaleString("zh-TW", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
invitationLink: invitationLink,
|
||||
invitedRole: inviteRole, // 記錄邀請時的預設角色
|
||||
if (data.success) {
|
||||
const newInvitation = {
|
||||
id: Date.now().toString(),
|
||||
name: "",
|
||||
email: inviteEmail,
|
||||
department: "",
|
||||
role: "",
|
||||
status: "invited",
|
||||
joinDate: "",
|
||||
lastLogin: "",
|
||||
totalApps: 0,
|
||||
totalReviews: 0,
|
||||
totalLikes: 0,
|
||||
invitationSentAt: new Date().toLocaleString("zh-TW", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
invitationLink: data.data.invitationLink,
|
||||
invitedRole: inviteRole, // 記錄邀請時的預設角色
|
||||
}
|
||||
|
||||
setGeneratedInvitation(newInvitation)
|
||||
setInviteEmail("")
|
||||
setInviteRole("user")
|
||||
setShowInviteUser(false)
|
||||
setShowInvitationLink(true)
|
||||
setSuccess("邀請連結已生成")
|
||||
|
||||
// 重新載入用戶列表
|
||||
loadUsers()
|
||||
} else {
|
||||
setError(data.error || "生成邀請連結失敗")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('邀請用戶錯誤:', error)
|
||||
setError("生成邀請連結時發生錯誤")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
setUsers([...users, newInvitation])
|
||||
setGeneratedInvitation(newInvitation)
|
||||
setInviteEmail("")
|
||||
setInviteRole("user")
|
||||
setIsLoading(false)
|
||||
setShowInviteUser(false)
|
||||
setShowInvitationLink(true)
|
||||
}
|
||||
|
||||
const handleCopyInvitationLink = async (link: string) => {
|
||||
@@ -369,7 +284,9 @@ export function UserManagement() {
|
||||
const newToken = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
||||
const user = users.find((u) => u.id === userId)
|
||||
const role = (user as any)?.invitedRole || "user"
|
||||
const newInvitationLink = `${window.location.origin}/register?token=${newToken}&email=${encodeURIComponent(email)}&role=${role}`
|
||||
const newInvitationLink = isClient
|
||||
? `${window.location.origin}/register?token=${newToken}&email=${encodeURIComponent(email)}&role=${role}`
|
||||
: `/register?token=${newToken}&email=${encodeURIComponent(email)}&role=${role}`
|
||||
|
||||
setUsers(
|
||||
users.map((user) =>
|
||||
@@ -402,42 +319,23 @@ export function UserManagement() {
|
||||
return
|
||||
}
|
||||
|
||||
// 檢查電子郵件是否被其他用戶使用
|
||||
if (users.some((user) => user.email === editUser.email && user.id !== editUser.id)) {
|
||||
setError("此電子郵件已被其他用戶使用")
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${editUser.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: editUser.name,
|
||||
email: editUser.email,
|
||||
department: editUser.department,
|
||||
role: editUser.role
|
||||
})
|
||||
})
|
||||
// 模擬 API 調用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
setUsers(users.map((user) => (user.id === editUser.id ? { ...user, ...editUser } : user)))
|
||||
setShowEditUser(false)
|
||||
setSuccess("用戶資料更新成功!")
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
refreshStats() // 更新統計數據
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || "更新用戶資料失敗")
|
||||
setTimeout(() => setError(""), 3000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error)
|
||||
setError("更新用戶資料失敗")
|
||||
setTimeout(() => setError(""), 3000)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setUsers(users.map((user) => (user.id === editUser.id ? { ...user, ...editUser } : user)))
|
||||
|
||||
setIsLoading(false)
|
||||
setShowEditUser(false)
|
||||
setSuccess("用戶資料更新成功!")
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
}
|
||||
|
||||
const confirmDeleteUser = async () => {
|
||||
@@ -445,33 +343,16 @@ export function UserManagement() {
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
// 模擬 API 調用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
if (response.ok) {
|
||||
setUsers(users.filter((user) => user.id !== userToDelete.id))
|
||||
setShowDeleteConfirm(false)
|
||||
setUserToDelete(null)
|
||||
setSuccess("用戶刪除成功!")
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
refreshStats() // 更新統計數據
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || "刪除用戶失敗")
|
||||
setTimeout(() => setError(""), 3000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error)
|
||||
setError("刪除用戶失敗")
|
||||
setTimeout(() => setError(""), 3000)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setUsers(users.filter((user) => user.id !== userToDelete.id))
|
||||
|
||||
setIsLoading(false)
|
||||
setShowDeleteConfirm(false)
|
||||
setUserToDelete(null)
|
||||
setSuccess("用戶刪除成功!")
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
}
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
@@ -572,13 +453,13 @@ export function UserManagement() {
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-8 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">總用戶數</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
<p className="text-2xl font-bold">{stats.totalUsers}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
@@ -592,7 +473,7 @@ export function UserManagement() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">活躍用戶</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
<p className="text-2xl font-bold">{stats.activeUsers}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="w-4 h-4 text-green-600" />
|
||||
@@ -606,7 +487,7 @@ export function UserManagement() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">管理員</p>
|
||||
<p className="text-2xl font-bold">{stats.admin}</p>
|
||||
<p className="text-2xl font-bold">{stats.adminCount}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-purple-600" />
|
||||
@@ -620,7 +501,7 @@ export function UserManagement() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">開發者</p>
|
||||
<p className="text-2xl font-bold">{stats.developer}</p>
|
||||
<p className="text-2xl font-bold">{stats.developerCount}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Code className="w-4 h-4 text-green-600" />
|
||||
@@ -633,11 +514,11 @@ export function UserManagement() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">一般用戶</p>
|
||||
<p className="text-2xl font-bold">{stats.user}</p>
|
||||
<p className="text-sm text-gray-600">待註冊</p>
|
||||
<p className="text-2xl font-bold">{stats.inactiveUsers}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-yellow-600" />
|
||||
<Clock className="w-4 h-4 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -647,8 +528,8 @@ export function UserManagement() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">今日新增</p>
|
||||
<p className="text-2xl font-bold">{stats.today}</p>
|
||||
<p className="text-sm text-gray-600">本月新增</p>
|
||||
<p className="text-2xl font-bold">{stats.newThisMonth}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<UserPlus className="w-4 h-4 text-orange-600" />
|
||||
@@ -656,34 +537,6 @@ export function UserManagement() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">應用</p>
|
||||
<p className="text-2xl font-bold">{stats.totalApps}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center">
|
||||
<Code className="w-4 h-4 text-indigo-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">評價</p>
|
||||
<p className="text-2xl font-bold">{stats.totalReviews}</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 bg-pink-100 rounded-full flex items-center justify-center">
|
||||
<Activity className="w-4 h-4 text-pink-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -744,40 +597,25 @@ export function UserManagement() {
|
||||
{/* Users Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>用戶列表 ({totalUsers} 總用戶)</CardTitle>
|
||||
<CardDescription>
|
||||
管理所有平台用戶 - 第 {currentPage} 頁,共 {totalPages} 頁
|
||||
{totalPages > 1 && ` (每頁 ${itemsPerPage} 筆)`}
|
||||
</CardDescription>
|
||||
<CardTitle>用戶列表 ({pagination.total})</CardTitle>
|
||||
<CardDescription>管理所有平台用戶</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-600">載入用戶資料中...</span>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Users className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">沒有找到用戶</p>
|
||||
<p className="text-sm text-gray-500 mt-1">嘗試調整搜尋條件或篩選器</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用戶</TableHead>
|
||||
<TableHead>部門</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>加入日期</TableHead>
|
||||
<TableHead>最後登入</TableHead>
|
||||
<TableHead>統計</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用戶</TableHead>
|
||||
<TableHead>部門</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>加入日期</TableHead>
|
||||
<TableHead>最後登入</TableHead>
|
||||
<TableHead>統計</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -868,70 +706,9 @@ export function UserManagement() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1)
|
||||
}}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{/* Page numbers */}
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage <= 3) {
|
||||
pageNum = i + 1
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pageNum = totalPages - 4 + i
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i
|
||||
}
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage(pageNum)
|
||||
}}
|
||||
isActive={currentPage === pageNum}
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (currentPage < totalPages) setCurrentPage(currentPage + 1)
|
||||
}}
|
||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite User Dialog - 包含角色選擇 */}
|
||||
<Dialog open={showInviteUser} onOpenChange={setShowInviteUser}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -1106,7 +883,7 @@ export function UserManagement() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button variant="outline" onClick={() => window.open(generatedInvitation.invitationLink, "_blank")}>
|
||||
<Button variant="outline" onClick={() => isClient && window.open(generatedInvitation.invitationLink, "_blank")}>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
預覽連結
|
||||
</Button>
|
||||
@@ -1362,32 +1139,22 @@ export function UserManagement() {
|
||||
<Clock className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">用戶尚未註冊,暫無活動記錄</p>
|
||||
</div>
|
||||
) : selectedUser.activities && selectedUser.activities.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{selectedUser.activities.map((activity: any, index: number) => (
|
||||
<div key={index} className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
{activity.type === 'login' ? (
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
) : activity.type === 'view_app' ? (
|
||||
<Eye className="w-4 h-4 text-green-600" />
|
||||
) : activity.type === 'create_app' ? (
|
||||
<Code className="w-4 h-4 text-purple-600" />
|
||||
) : activity.type === 'review' ? (
|
||||
<Activity className="w-4 h-4 text-orange-600" />
|
||||
) : (
|
||||
<Activity className="w-4 h-4 text-gray-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{activity.description}</p>
|
||||
<p className="text-xs text-gray-500">{activity.timestamp}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Activity className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">暫無活動記錄</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">登入系統</p>
|
||||
<p className="text-xs text-gray-500">2024-01-20 16:45</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<Eye className="w-4 h-4 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">查看應用:智能對話助手</p>
|
||||
<p className="text-xs text-gray-500">2024-01-20 15:30</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -1422,7 +1189,7 @@ export function UserManagement() {
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
{selectedUser.status === "invited" ? 0 : (selectedUser.loginDays || 0)}
|
||||
{selectedUser.status === "invited" ? 0 : 15}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">登入天數</p>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user