From 2765d9df54d254405eb643fbc3070f27a14e74a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B3=E4=BD=A9=E5=BA=AD?= Date: Thu, 25 Sep 2025 12:30:25 +0800 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 27 + app/admin/analytics/loading.tsx | 3 + app/admin/analytics/page.tsx | 430 +++ app/admin/questions/page.tsx | 354 +++ app/admin/results/loading.tsx | 3 + app/admin/results/page.tsx | 421 +++ app/admin/users/page.tsx | 452 +++ app/dashboard/page.tsx | 33 + app/globals.css | 126 + app/home/page.tsx | 390 +++ app/layout.tsx | 35 + app/login/page.tsx | 129 + app/page.tsx | 171 ++ app/register/page.tsx | 203 ++ app/results/combined/page.tsx | 295 ++ app/results/creative/page.tsx | 225 ++ app/results/logic/page.tsx | 194 ++ app/results/page.tsx | 376 +++ app/settings/page.tsx | 346 +++ app/tests/combined/page.tsx | 316 +++ app/tests/creative/page.tsx | 177 ++ app/tests/logic/page.tsx | 166 ++ app/tests/page.tsx | 151 + components.json | 21 + components/auth-provider.tsx | 30 + components/protected-route.tsx | 48 + components/test-layout.tsx | 72 + components/theme-provider.tsx | 11 + components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 157 ++ components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 46 + components/ui/breadcrumb.tsx | 109 + components/ui/button.tsx | 59 + components/ui/calendar.tsx | 213 ++ components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 ++ components/ui/chart.tsx | 353 +++ components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 184 ++ components/ui/context-menu.tsx | 252 ++ components/ui/dialog.tsx | 143 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 257 ++ components/ui/form.tsx | 167 ++ components/ui/hover-card.tsx | 44 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 21 + components/ui/label.tsx | 24 + components/ui/menubar.tsx | 276 ++ components/ui/navigation-menu.tsx | 166 ++ components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 48 + components/ui/progress.tsx | 31 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 56 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 185 ++ components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 + components/ui/sidebar.tsx | 726 +++++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 63 + components/ui/sonner.tsx | 25 + components/ui/switch.tsx | 31 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 55 + components/ui/textarea.tsx | 18 + components/ui/toast.tsx | 129 + components/ui/toaster.tsx | 35 + components/ui/toggle-group.tsx | 73 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 61 + components/ui/use-mobile.tsx | 19 + components/ui/use-toast.ts | 191 ++ hooks/use-mobile.ts | 19 + hooks/use-toast.ts | 191 ++ lib/hooks/use-auth.ts | 114 + lib/questions/creative-questions.ts | 114 + lib/questions/logic-questions.ts | 138 + lib/utils.ts | 6 + lib/utils/excel-parser.ts | 180 ++ lib/utils/permissions.ts | 54 + lib/utils/score-calculator.ts | 188 ++ next.config.mjs | 14 + package-lock.json | 3960 +++++++++++++++++++++++++++ package.json | 75 + pnpm-lock.yaml | 5 + postcss.config.mjs | 8 + public/placeholder-logo.png | Bin 0 -> 568 bytes public/placeholder-logo.svg | 1 + public/placeholder-user.jpg | Bin 0 -> 1635 bytes public/placeholder.jpg | Bin 0 -> 1064 bytes public/placeholder.svg | 1 + styles/globals.css | 125 + tsconfig.json | 27 + 100 files changed, 16023 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 app/admin/analytics/loading.tsx create mode 100644 app/admin/analytics/page.tsx create mode 100644 app/admin/questions/page.tsx create mode 100644 app/admin/results/loading.tsx create mode 100644 app/admin/results/page.tsx create mode 100644 app/admin/users/page.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/globals.css create mode 100644 app/home/page.tsx create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/register/page.tsx create mode 100644 app/results/combined/page.tsx create mode 100644 app/results/creative/page.tsx create mode 100644 app/results/logic/page.tsx create mode 100644 app/results/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/tests/combined/page.tsx create mode 100644 app/tests/creative/page.tsx create mode 100644 app/tests/logic/page.tsx create mode 100644 app/tests/page.tsx create mode 100644 components.json create mode 100644 components/auth-provider.tsx create mode 100644 components/protected-route.tsx create mode 100644 components/test-layout.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-mobile.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/use-toast.ts create mode 100644 lib/hooks/use-auth.ts create mode 100644 lib/questions/creative-questions.ts create mode 100644 lib/questions/logic-questions.ts create mode 100644 lib/utils.ts create mode 100644 lib/utils/excel-parser.ts create mode 100644 lib/utils/permissions.ts create mode 100644 lib/utils/score-calculator.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 public/placeholder-logo.png create mode 100644 public/placeholder-logo.svg create mode 100644 public/placeholder-user.jpg create mode 100644 public/placeholder.jpg create mode 100644 public/placeholder.svg create mode 100644 styles/globals.css create mode 100644 tsconfig.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f650315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/app/admin/analytics/loading.tsx b/app/admin/analytics/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/app/admin/analytics/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx new file mode 100644 index 0000000..6bee7b2 --- /dev/null +++ b/app/admin/analytics/page.tsx @@ -0,0 +1,430 @@ +"use client" + +import { useState, useEffect } from "react" +import { ProtectedRoute } from "@/components/protected-route" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Progress } from "@/components/ui/progress" +import { Badge } from "@/components/ui/badge" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Brain, Lightbulb, BarChart3, ArrowLeft, TrendingUp, Users, Award, Target } from "lucide-react" +import Link from "next/link" +import { useAuth, type User } from "@/lib/hooks/use-auth" + +interface DepartmentStats { + department: string + totalUsers: number + participatedUsers: number + participationRate: number + averageLogicScore: number + averageCreativeScore: number + averageCombinedScore: number + overallAverage: number + topPerformer: string | null + testCounts: { + logic: number + creative: number + combined: number + } +} + +interface TestResult { + userId: string + userName: string + userDepartment: string + type: "logic" | "creative" | "combined" + score: number + completedAt: string +} + +export default function AnalyticsPage() { + return ( + + + + ) +} + +function AnalyticsContent() { + const { user } = useAuth() + const [departmentStats, setDepartmentStats] = useState([]) + const [selectedDepartment, setSelectedDepartment] = useState("all") + const [overallStats, setOverallStats] = useState({ + totalUsers: 0, + totalParticipants: 0, + overallParticipationRate: 0, + averageScore: 0, + totalTests: 0, + }) + + const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"] + + useEffect(() => { + loadAnalyticsData() + }, []) + + const loadAnalyticsData = () => { + // Load users + const users: User[] = JSON.parse(localStorage.getItem("hr_users") || "[]") + + // Load all test results + const allResults: TestResult[] = [] + + users.forEach((user: User) => { + // Check for logic test results + const logicKey = `logicTestResults_${user.id}` + const logicResults = localStorage.getItem(logicKey) + if (logicResults) { + const data = JSON.parse(logicResults) + allResults.push({ + userId: user.id, + userName: user.name, + userDepartment: user.department, + type: "logic", + score: data.score, + completedAt: data.completedAt, + }) + } + + // Check for creative test results + const creativeKey = `creativeTestResults_${user.id}` + const creativeResults = localStorage.getItem(creativeKey) + if (creativeResults) { + const data = JSON.parse(creativeResults) + allResults.push({ + userId: user.id, + userName: user.name, + userDepartment: user.department, + type: "creative", + score: data.score, + completedAt: data.completedAt, + }) + } + + // Check for combined test results + const combinedKey = `combinedTestResults_${user.id}` + const combinedResults = localStorage.getItem(combinedKey) + if (combinedResults) { + const data = JSON.parse(combinedResults) + allResults.push({ + userId: user.id, + userName: user.name, + userDepartment: user.department, + type: "combined", + score: data.overallScore, + completedAt: data.completedAt, + }) + } + }) + + // Calculate department statistics + const deptStats: DepartmentStats[] = departments + .map((dept) => { + const deptUsers = users.filter((u) => u.department === dept) + const deptResults = allResults.filter((r) => r.userDepartment === dept) + const participatedUsers = new Set(deptResults.map((r) => r.userId)).size + + // Calculate average scores by test type + const logicResults = deptResults.filter((r) => r.type === "logic") + const creativeResults = deptResults.filter((r) => r.type === "creative") + const combinedResults = deptResults.filter((r) => r.type === "combined") + + const averageLogicScore = + logicResults.length > 0 + ? Math.round(logicResults.reduce((sum, r) => sum + r.score, 0) / logicResults.length) + : 0 + + const averageCreativeScore = + creativeResults.length > 0 + ? Math.round(creativeResults.reduce((sum, r) => sum + r.score, 0) / creativeResults.length) + : 0 + + const averageCombinedScore = + combinedResults.length > 0 + ? Math.round(combinedResults.reduce((sum, r) => sum + r.score, 0) / combinedResults.length) + : 0 + + // Calculate overall average + const allScores = [averageLogicScore, averageCreativeScore, averageCombinedScore].filter((s) => s > 0) + const overallAverage = + allScores.length > 0 ? Math.round(allScores.reduce((sum, s) => sum + s, 0) / allScores.length) : 0 + + // Find top performer + const userScores = new Map() + deptResults.forEach((result) => { + if (!userScores.has(result.userId)) { + userScores.set(result.userId, []) + } + userScores.get(result.userId)!.push(result.score) + }) + + let topPerformer: string | null = null + let topScore = 0 + userScores.forEach((scores, userId) => { + const avgScore = scores.reduce((sum, s) => sum + s, 0) / scores.length + if (avgScore > topScore) { + topScore = avgScore + const user = users.find((u) => u.id === userId) + topPerformer = user ? user.name : null + } + }) + + return { + department: dept, + totalUsers: deptUsers.length, + participatedUsers, + participationRate: deptUsers.length > 0 ? Math.round((participatedUsers / deptUsers.length) * 100) : 0, + averageLogicScore, + averageCreativeScore, + averageCombinedScore, + overallAverage, + topPerformer, + testCounts: { + logic: logicResults.length, + creative: creativeResults.length, + combined: combinedResults.length, + }, + } + }) + .filter((stat) => stat.totalUsers > 0) // Only show departments with users + + setDepartmentStats(deptStats) + + // Calculate overall statistics + const totalUsers = users.length + const totalParticipants = new Set(allResults.map((r) => r.userId)).size + const overallParticipationRate = totalUsers > 0 ? Math.round((totalParticipants / totalUsers) * 100) : 0 + const averageScore = + allResults.length > 0 ? Math.round(allResults.reduce((sum, r) => sum + r.score, 0) / allResults.length) : 0 + + setOverallStats({ + totalUsers, + totalParticipants, + overallParticipationRate, + averageScore, + totalTests: allResults.length, + }) + } + + const getScoreColor = (score: number) => { + if (score >= 90) return "text-green-600" + if (score >= 80) return "text-blue-600" + if (score >= 70) return "text-yellow-600" + if (score >= 60) return "text-orange-600" + return "text-red-600" + } + + const getParticipationColor = (rate: number) => { + if (rate >= 80) return "text-green-600" + if (rate >= 60) return "text-blue-600" + if (rate >= 40) return "text-yellow-600" + return "text-red-600" + } + + const filteredStats = + selectedDepartment === "all" + ? departmentStats + : departmentStats.filter((stat) => stat.department === selectedDepartment) + + return ( +
+ {/* Header */} +
+
+
+ +
+

部門分析

+

查看各部門的測試結果統計和分析

+
+
+
+
+ +
+
+ {/* Overall Statistics */} +
+ + +
+ +
+
{overallStats.totalUsers}
+
總用戶數
+
+
+ + + +
+ +
+
{overallStats.totalParticipants}
+
參與用戶
+
+
+ + + +
+ +
+
{overallStats.overallParticipationRate}%
+
參與率
+
+
+ + + +
+ +
+
{overallStats.averageScore}
+
平均分數
+
+
+ + + +
+ +
+
{overallStats.totalTests}
+
總測試次數
+
+
+
+ + {/* Department Filter */} + + + 部門篩選 + + +
+ +
+
+
+ + {/* Department Statistics */} +
+ {filteredStats.map((stat) => ( + + + + {stat.department} + + {stat.participatedUsers}/{stat.totalUsers} 人參與 + + + + 參與率: + {stat.participationRate}% + + + + {/* Participation Rate */} +
+
+ 參與率 + {stat.participationRate}% +
+ +
+ + {/* Test Scores */} +
+
+
+ +
+
+ {stat.averageLogicScore || "-"} +
+
邏輯思維
+
({stat.testCounts.logic} 次)
+
+ +
+
+ +
+
+ {stat.averageCreativeScore || "-"} +
+
創意能力
+
({stat.testCounts.creative} 次)
+
+ +
+
+ +
+
+ {stat.averageCombinedScore || "-"} +
+
綜合能力
+
({stat.testCounts.combined} 次)
+
+
+ + {/* Overall Average */} +
+
+ 部門平均分數 + + {stat.overallAverage || "-"} + +
+
+ + {/* Top Performer */} + {stat.topPerformer && ( +
+
+ 表現最佳 + + + {stat.topPerformer} + +
+
+ )} +
+
+ ))} +
+ + {filteredStats.length === 0 && ( + + +
+ {selectedDepartment === "all" ? "暫無部門數據" : "該部門暫無數據"} +
+
+
+ )} +
+
+
+ ) +} diff --git a/app/admin/questions/page.tsx b/app/admin/questions/page.tsx new file mode 100644 index 0000000..37ab034 --- /dev/null +++ b/app/admin/questions/page.tsx @@ -0,0 +1,354 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { ProtectedRoute } from "@/components/protected-route" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + ArrowLeft, + Upload, + Download, + Brain, + Lightbulb, + FileSpreadsheet, + CheckCircle, + AlertCircle, + Info, +} from "lucide-react" +import Link from "next/link" +import { logicQuestions } from "@/lib/questions/logic-questions" +import { creativeQuestions } from "@/lib/questions/creative-questions" +import { parseExcelFile, type ImportResult } from "@/lib/utils/excel-parser" + +export default function QuestionsManagementPage() { + return ( + + + + ) +} + +function QuestionsManagementContent() { + const [activeTab, setActiveTab] = useState("logic") + const [isImporting, setIsImporting] = useState(false) + const [importResult, setImportResult] = useState(null) + const [selectedFile, setSelectedFile] = useState(null) + const [importType, setImportType] = useState<"logic" | "creative">("logic") + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + if ( + file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + file.type === "application/vnd.ms-excel" || + file.name.endsWith(".xlsx") || + file.name.endsWith(".xls") + ) { + setSelectedFile(file) + setImportResult(null) + } else { + setImportResult({ + success: false, + message: "請選擇 Excel 檔案 (.xlsx 或 .xls)", + }) + } + } + } + + const handleImport = async () => { + if (!selectedFile) { + setImportResult({ + success: false, + message: "請先選擇要匯入的 Excel 檔案", + }) + return + } + + setIsImporting(true) + + try { + const result = await parseExcelFile(selectedFile, importType) + setImportResult(result) + + if (result.success) { + setSelectedFile(null) + // 重置檔案輸入 + const fileInput = document.getElementById("file-input") as HTMLInputElement + if (fileInput) fileInput.value = "" + } + } catch (error) { + setImportResult({ + success: false, + message: "匯入失敗,請檢查檔案格式是否正確", + errors: [error instanceof Error ? error.message : "未知錯誤"], + }) + } finally { + setIsImporting(false) + } + } + + const downloadTemplate = (type: "logic" | "creative") => { + let csvContent = "" + + if (type === "logic") { + csvContent = [ + ["題目ID", "題目內容", "選項A", "選項B", "選項C", "選項D", "正確答案", "解釋"], + [ + "1", + "範例題目:如果所有A都是B,所有B都是C,那麼?", + "所有A都是C", + "所有C都是A", + "有些A不是C", + "無法確定", + "A", + "根據邏輯推理...", + ], + ["2", "在序列 2, 4, 8, 16, ? 中,下一個數字是?", "24", "32", "30", "28", "B", "每個數字都是前一個數字的2倍"], + ] + .map((row) => row.join(",")) + .join("\n") + } else { + csvContent = [ + ["題目ID", "陳述內容", "類別", "是否反向計分"], + ["1", "我經常能想出創新的解決方案", "innovation", "否"], + ["2", "我更喜歡按照既定規則工作", "flexibility", "是"], + ["3", "我喜歡嘗試新的做事方法", "innovation", "否"], + ] + .map((row) => row.join(",")) + .join("\n") + } + + const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" }) + const link = document.createElement("a") + const url = URL.createObjectURL(blob) + link.setAttribute("href", url) + link.setAttribute("download", `${type === "logic" ? "邏輯思維" : "創意能力"}題目範本.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

題目管理

+

管理測試題目和匯入新題目

+
+
+
+
+ +
+
+ {/* Import Section */} + + + + + Excel 檔案匯入 + + 上傳 Excel 檔案來批量匯入測試題目。支援邏輯思維測試和創意能力測試題目。 + + + {/* File Upload */} +
+
+
+ + +
+
+ + +
+
+ + {selectedFile && ( +
+ + 已選擇:{selectedFile.name} +
+ )} + +
+ +
+
+ + {/* Import Result */} + {importResult && ( + + {importResult.success ? : } + + {importResult.message} + {importResult.errors && ( +
    + {importResult.errors.map((error, index) => ( +
  • + {error} +
  • + ))} +
+ )} +
+
+ )} + + {/* Template Download */} +
+
+ + 下載範本檔案 +
+
+ + +
+
+
+
+ + {/* Current Questions */} + + + 現有題目管理 + 查看和管理系統中的測試題目 + + + + + + + 邏輯思維 ({logicQuestions.length}) + + + + 創意能力 ({creativeQuestions.length}) + + + + +
+

邏輯思維測試題目

+ {logicQuestions.length} 道題目 +
+ + + + + 題目ID + 題目內容 + 選項數量 + 正確答案 + + + + {logicQuestions.slice(0, 10).map((question) => ( + + {question.id} + {question.question} + {question.options.length} + + {question.correctAnswer} + + + ))} + +
+
+ + +
+

創意能力測試題目

+ {creativeQuestions.length} 道題目 +
+ + + + + 題目ID + 陳述內容 + 類別 + 反向計分 + + + + {creativeQuestions.slice(0, 10).map((question) => ( + + {question.id} + {question.statement} + + {question.category} + + + {question.isReverse ? ( + + ) : ( + + )} + + + ))} + +
+
+
+
+
+
+
+
+ ) +} diff --git a/app/admin/results/loading.tsx b/app/admin/results/loading.tsx new file mode 100644 index 0000000..f15322a --- /dev/null +++ b/app/admin/results/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null +} diff --git a/app/admin/results/page.tsx b/app/admin/results/page.tsx new file mode 100644 index 0000000..4f4c829 --- /dev/null +++ b/app/admin/results/page.tsx @@ -0,0 +1,421 @@ +"use client" + +import { useState, useEffect } from "react" +import { ProtectedRoute } from "@/components/protected-route" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Brain, Lightbulb, BarChart3, ArrowLeft, Search, Download, Filter } from "lucide-react" +import Link from "next/link" +import { useAuth, type User } from "@/lib/hooks/use-auth" + +interface TestResult { + userId: string + userName: string + userDepartment: string + type: "logic" | "creative" | "combined" + score: number + completedAt: string + details?: any +} + +export default function AdminResultsPage() { + return ( + + + + ) +} + +function AdminResultsContent() { + const { user } = useAuth() + const [results, setResults] = useState([]) + const [filteredResults, setFilteredResults] = useState([]) + const [users, setUsers] = useState([]) + const [searchTerm, setSearchTerm] = useState("") + const [departmentFilter, setDepartmentFilter] = useState("all") + const [testTypeFilter, setTestTypeFilter] = useState("all") + const [stats, setStats] = useState({ + totalResults: 0, + averageScore: 0, + totalUsers: 0, + completionRate: 0, + }) + + const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"] + + useEffect(() => { + loadData() + }, []) + + useEffect(() => { + filterResults() + }, [results, searchTerm, departmentFilter, testTypeFilter]) + + const loadData = () => { + // Load users + const usersData = JSON.parse(localStorage.getItem("hr_users") || "[]") + setUsers(usersData) + + // Load all test results + const allResults: TestResult[] = [] + + usersData.forEach((user: User) => { + // Check for logic test results + const logicKey = `logicTestResults_${user.id}` + const logicResults = localStorage.getItem(logicKey) + if (logicResults) { + const data = JSON.parse(logicResults) + allResults.push({ + userId: user.id, + userName: user.name, + userDepartment: user.department, + type: "logic", + score: data.score, + completedAt: data.completedAt, + details: data, + }) + } + + // Check for creative test results + const creativeKey = `creativeTestResults_${user.id}` + const creativeResults = localStorage.getItem(creativeKey) + if (creativeResults) { + const data = JSON.parse(creativeResults) + allResults.push({ + userId: user.id, + userName: user.name, + userDepartment: user.department, + type: "creative", + score: data.score, + completedAt: data.completedAt, + details: data, + }) + } + + // Check for combined test results + const combinedKey = `combinedTestResults_${user.id}` + const combinedResults = localStorage.getItem(combinedKey) + if (combinedResults) { + const data = JSON.parse(combinedResults) + allResults.push({ + userId: user.id, + userName: user.name, + userDepartment: user.department, + type: "combined", + score: data.overallScore, + completedAt: data.completedAt, + details: data, + }) + } + }) + + // Sort by completion date (newest first) + allResults.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()) + setResults(allResults) + + // Calculate statistics + const totalResults = allResults.length + const averageScore = + totalResults > 0 ? Math.round(allResults.reduce((sum, r) => sum + r.score, 0) / totalResults) : 0 + const totalUsers = usersData.length + const usersWithResults = new Set(allResults.map((r) => r.userId)).size + const completionRate = totalUsers > 0 ? Math.round((usersWithResults / totalUsers) * 100) : 0 + + setStats({ + totalResults, + averageScore, + totalUsers, + completionRate, + }) + } + + const filterResults = () => { + let filtered = results + + // Filter by search term (user name) + if (searchTerm) { + filtered = filtered.filter((result) => result.userName.toLowerCase().includes(searchTerm.toLowerCase())) + } + + // Filter by department + if (departmentFilter !== "all") { + filtered = filtered.filter((result) => result.userDepartment === departmentFilter) + } + + // Filter by test type + if (testTypeFilter !== "all") { + filtered = filtered.filter((result) => result.type === testTypeFilter) + } + + setFilteredResults(filtered) + } + + const getTestTypeInfo = (type: string) => { + switch (type) { + case "logic": + return { + name: "邏輯思維", + icon: Brain, + color: "bg-primary", + textColor: "text-primary", + } + case "creative": + return { + name: "創意能力", + icon: Lightbulb, + color: "bg-accent", + textColor: "text-accent", + } + case "combined": + return { + name: "綜合能力", + icon: BarChart3, + color: "bg-gradient-to-r from-primary to-accent", + textColor: "text-primary", + } + default: + return { + name: "未知", + icon: BarChart3, + color: "bg-muted", + textColor: "text-muted-foreground", + } + } + } + + const getScoreLevel = (score: number) => { + if (score >= 90) return { level: "優秀", color: "bg-green-500" } + if (score >= 80) return { level: "良好", color: "bg-blue-500" } + if (score >= 70) return { level: "中等", color: "bg-yellow-500" } + if (score >= 60) return { level: "及格", color: "bg-orange-500" } + return { level: "不及格", color: "bg-red-500" } + } + + const exportResults = () => { + const csvContent = [ + ["姓名", "部門", "測試類型", "分數", "等級", "完成時間"], + ...filteredResults.map((result) => [ + result.userName, + result.userDepartment, + getTestTypeInfo(result.type).name, + result.score.toString(), + getScoreLevel(result.score).level, + new Date(result.completedAt).toLocaleString("zh-TW"), + ]), + ] + .map((row) => row.join(",")) + .join("\n") + + const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" }) + const link = document.createElement("a") + const url = URL.createObjectURL(blob) + link.setAttribute("href", url) + link.setAttribute("download", `測試結果_${new Date().toISOString().split("T")[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

所有測試結果

+

查看和分析所有員工的測試結果

+
+
+
+
+ +
+
+ {/* Statistics Overview */} +
+ + +
+ +
+
{stats.totalResults}
+
總測試次數
+
+
+ + + +
+ +
+
{stats.averageScore}
+
平均分數
+
+
+ + + +
+ +
+
{stats.totalUsers}
+
總用戶數
+
+
+ + + +
+ +
+
{stats.completionRate}%
+
參與率
+
+
+
+ + {/* Filters */} + + + + + 篩選條件 + + + +
+
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + {/* Results Table */} + + + 測試結果列表 + + 顯示 {filteredResults.length} 筆結果(共 {results.length} 筆) + + + + + + + 用戶 + 部門 + 測試類型 + 分數 + 等級 + 完成時間 + + + + {filteredResults.map((result, index) => { + const testInfo = getTestTypeInfo(result.type) + const scoreLevel = getScoreLevel(result.score) + const Icon = testInfo.icon + + return ( + + +
{result.userName}
+
+ {result.userDepartment} + +
+
+ +
+ {testInfo.name} +
+
+ +
{result.score}
+
+ + {scoreLevel.level} + + +
{new Date(result.completedAt).toLocaleString("zh-TW")}
+
+
+ ) + })} +
+
+ + {filteredResults.length === 0 && ( +
+
沒有找到符合條件的測試結果
+
+ )} +
+
+
+
+
+ ) +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..163502e --- /dev/null +++ b/app/admin/users/page.tsx @@ -0,0 +1,452 @@ +"use client" + +import { useState, useEffect } from "react" +import { ProtectedRoute } from "@/components/protected-route" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Plus, Edit, Trash2, ArrowLeft } from "lucide-react" +import Link from "next/link" +import { useAuth, type User } from "@/lib/hooks/use-auth" + +export default function UsersManagementPage() { + return ( + + + + ) +} + +function UsersManagementContent() { + const { user: currentUser } = useAuth() + const [users, setUsers] = useState<(User & { password?: string })[]>([]) + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) + const [editingUser, setEditingUser] = useState(null) + const [newUser, setNewUser] = useState({ + name: "", + email: "", + password: "", + department: "", + role: "user" as "user" | "admin", + }) + const [error, setError] = useState("") + + const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"] + + useEffect(() => { + loadUsers() + }, []) + + const loadUsers = () => { + const usersData = JSON.parse(localStorage.getItem("hr_users") || "[]") + setUsers(usersData) + } + + const handleAddUser = () => { + setError("") + + if (!newUser.name || !newUser.email || !newUser.password || !newUser.department) { + setError("請填寫所有必填欄位") + return + } + + if (users.some((u) => u.email === newUser.email)) { + setError("該電子郵件已被使用") + return + } + + const user = { + ...newUser, + id: `user-${Date.now()}`, + createdAt: new Date().toISOString(), + } + + const updatedUsers = [...users, user] + setUsers(updatedUsers) + localStorage.setItem("hr_users", JSON.stringify(updatedUsers)) + + setNewUser({ + name: "", + email: "", + password: "", + department: "", + role: "user", + }) + setIsAddDialogOpen(false) + } + + const handleEditUser = (user: User) => { + setEditingUser(user) + setNewUser({ + name: user.name, + email: user.email, + password: "", + department: user.department, + role: user.role, + }) + } + + const handleUpdateUser = () => { + if (!editingUser) return + + setError("") + + if (!newUser.name || !newUser.email || !newUser.department) { + setError("請填寫所有必填欄位") + return + } + + if (users.some((u) => u.email === newUser.email && u.id !== editingUser.id)) { + setError("該電子郵件已被使用") + return + } + + const updatedUsers = users.map((u) => + u.id === editingUser.id + ? { + ...u, + name: newUser.name, + email: newUser.email, + department: newUser.department, + role: newUser.role, + ...(newUser.password && { password: newUser.password }), + } + : u, + ) + + setUsers(updatedUsers) + localStorage.setItem("hr_users", JSON.stringify(updatedUsers)) + + setEditingUser(null) + setNewUser({ + name: "", + email: "", + password: "", + department: "", + role: "user", + }) + } + + const handleDeleteUser = (userId: string) => { + if (userId === currentUser?.id) { + setError("無法刪除自己的帳戶") + return + } + + const updatedUsers = users.filter((u) => u.id !== userId) + setUsers(updatedUsers) + localStorage.setItem("hr_users", JSON.stringify(updatedUsers)) + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

用戶管理

+

管理系統用戶和權限

+
+
+
+
+ +
+
+ {/* Stats */} +
+ + + 總用戶數 + + +
{users.length}
+
+
+ + + + 管理員 + + +
{users.filter((u) => u.role === "admin").length}
+
+
+ + + + 一般用戶 + + +
{users.filter((u) => u.role === "user").length}
+
+
+
+ + {/* Users Table */} + + +
+
+ 用戶列表 + 管理系統中的所有用戶 +
+ + + + + + + 新增用戶 + 建立新的系統用戶帳戶 + +
+
+ + setNewUser({ ...newUser, name: e.target.value })} + placeholder="請輸入姓名" + /> +
+ +
+ + setNewUser({ ...newUser, email: e.target.value })} + placeholder="請輸入電子郵件" + /> +
+ +
+ + setNewUser({ ...newUser, password: e.target.value })} + placeholder="請輸入密碼" + /> +
+ +
+ + +
+ +
+ + +
+ + {error && ( + + {error} + + )} + +
+ + +
+
+
+
+
+
+ + + + + 姓名 + 電子郵件 + 部門 + 角色 + 建立時間 + 操作 + + + + {users.map((user) => ( + + {user.name} + {user.email} + {user.department} + + + {user.role === "admin" ? "管理員" : "一般用戶"} + + + {new Date(user.createdAt).toLocaleDateString()} + +
+ + {user.id !== currentUser?.id && ( + + )} +
+
+
+ ))} +
+
+
+
+ + {/* Edit User Dialog */} + setEditingUser(null)}> + + + 編輯用戶 + 修改用戶資訊和權限 + +
+
+ + setNewUser({ ...newUser, name: e.target.value })} + placeholder="請輸入姓名" + /> +
+ +
+ + setNewUser({ ...newUser, email: e.target.value })} + placeholder="請輸入電子郵件" + /> +
+ +
+ + setNewUser({ ...newUser, password: e.target.value })} + placeholder="請輸入新密碼" + /> +
+ +
+ + +
+ +
+ + +
+ + {error && ( + + {error} + + )} + +
+ + +
+
+
+
+
+
+
+ ) +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..19cb141 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { useAuth } from "@/lib/hooks/use-auth" + +export default function DashboardPage() { + const { user, isLoading } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!isLoading) { + if (!user) { + router.push("/") + } else { + router.push("/home") + } + } + }, [user, isLoading, router]) + + if (isLoading) { + return ( +
+
+
+

載入中...

+
+
+ ) + } + + return null +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..6c6e53c --- /dev/null +++ b/app/globals.css @@ -0,0 +1,126 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + /* 更新为专业HR系统配色方案 */ + --background: oklch(0.98 0.005 106); + --foreground: oklch(0.15 0.02 258); + --card: oklch(1 0 0); + --card-foreground: oklch(0.15 0.02 258); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.15 0.02 258); + --primary: oklch(0.45 0.15 258); + --primary-foreground: oklch(0.98 0.005 106); + --secondary: oklch(0.92 0.02 106); + --secondary-foreground: oklch(0.15 0.02 258); + --muted: oklch(0.95 0.01 106); + --muted-foreground: oklch(0.55 0.02 258); + --accent: oklch(0.65 0.12 180); + --accent-foreground: oklch(0.98 0.005 106); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.98 0.005 106); + --border: oklch(0.88 0.02 106); + --input: oklch(0.95 0.01 106); + --ring: oklch(0.45 0.15 258); + --chart-1: oklch(0.45 0.15 258); + --chart-2: oklch(0.65 0.12 180); + --chart-3: oklch(0.55 0.18 140); + --chart-4: oklch(0.75 0.08 60); + --chart-5: oklch(0.35 0.12 300); + --radius: 0.75rem; + --sidebar: oklch(0.98 0.005 106); + --sidebar-foreground: oklch(0.15 0.02 258); + --sidebar-primary: oklch(0.45 0.15 258); + --sidebar-primary-foreground: oklch(0.98 0.005 106); + --sidebar-accent: oklch(0.92 0.02 106); + --sidebar-accent-foreground: oklch(0.15 0.02 258); + --sidebar-border: oklch(0.88 0.02 106); + --sidebar-ring: oklch(0.45 0.15 258); +} + +.dark { + --background: oklch(0.08 0.02 258); + --foreground: oklch(0.95 0.01 106); + --card: oklch(0.12 0.02 258); + --card-foreground: oklch(0.95 0.01 106); + --popover: oklch(0.12 0.02 258); + --popover-foreground: oklch(0.95 0.01 106); + --primary: oklch(0.65 0.15 258); + --primary-foreground: oklch(0.08 0.02 258); + --secondary: oklch(0.18 0.02 258); + --secondary-foreground: oklch(0.95 0.01 106); + --muted: oklch(0.15 0.02 258); + --muted-foreground: oklch(0.65 0.02 258); + --accent: oklch(0.55 0.12 180); + --accent-foreground: oklch(0.08 0.02 258); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.95 0.01 106); + --border: oklch(0.22 0.02 258); + --input: oklch(0.18 0.02 258); + --ring: oklch(0.65 0.15 258); + --chart-1: oklch(0.65 0.15 258); + --chart-2: oklch(0.55 0.12 180); + --chart-3: oklch(0.45 0.18 140); + --chart-4: oklch(0.65 0.08 60); + --chart-5: oklch(0.55 0.12 300); + --sidebar: oklch(0.12 0.02 258); + --sidebar-foreground: oklch(0.95 0.01 106); + --sidebar-primary: oklch(0.65 0.15 258); + --sidebar-primary-foreground: oklch(0.08 0.02 258); + --sidebar-accent: oklch(0.18 0.02 258); + --sidebar-accent-foreground: oklch(0.95 0.01 106); + --sidebar-border: oklch(0.22 0.02 258); + --sidebar-ring: oklch(0.65 0.15 258); +} + +@theme inline { + --font-sans: var(--font-inter); + --font-mono: var(--font-jetbrains-mono); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/home/page.tsx b/app/home/page.tsx new file mode 100644 index 0000000..e3ad08b --- /dev/null +++ b/app/home/page.tsx @@ -0,0 +1,390 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Brain, Lightbulb, BarChart3, Users, Settings, Menu, ChevronDown } from "lucide-react" +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet" +import Link from "next/link" +import { useAuth } from "@/lib/hooks/use-auth" +import { useRouter } from "next/navigation" +import { ProtectedRoute } from "@/components/protected-route" + +export default function HomePage() { + const { user, logout } = useAuth() + const router = useRouter() + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const dropdownRef = useRef(null) + + const handleLogout = () => { + logout() + router.push("/") + } + + // 點擊外部關閉下拉選單 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false) + } + } + + if (isDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isDropdownOpen]) + + // 調試信息 + console.log("Current user:", user) + + return ( + +
+ {/* Header */} +
+
+
+
+
+ +
+
+

HR 評估系統

+

員工能力測評平台

+
+
+ + {/* Desktop Navigation */} +
+ {/* Navigation Links */} +
+ + {user?.role === "admin" ? "所有測試結果" : "我的測試結果"} + + {user?.role === "admin" && ( + + 部門分析 + + )} +
+ + {/* 自定義下拉選單 */} +
+ + + {isDropdownOpen && ( +
+
+ setIsDropdownOpen(false)} + > + + 帳戶設定 + + {user?.role === "admin" && ( + <> +
+ setIsDropdownOpen(false)} + > + + 用戶管理 + + setIsDropdownOpen(false)} + > + + 題目管理 + + + )} +
+ +
+
+ )} +
+
+ +
+ + + + + + + 選單 + +
+
+

{user?.name}

+

{user?.department}

+
+ + {/* Navigation Links */} + + {user?.role === "admin" && ( + + )} + + + {user?.role === "admin" && ( + <> + + + + )} + +
+
+
+
+
+
+
+ + {/* Hero Section */} +
+
+
+

+ 歡迎回來,{user?.name} +

+

+ 透過科學的邏輯思維測試和創意能力評估,全面了解您的綜合素質 +

+
+
+
+ + {/* Test Cards / Admin Info Cards */} +
+
+ {user?.role === "admin" ? ( + // 管理者看到的介紹卡片 +
+ {/* 邏輯思維測試介紹 */} + + +
+ +
+ 邏輯思維測試 + 評估邏輯推理、分析判斷和問題解決能力 +
+ +
+
+ 題目數量 + 10題 +
+
+ 題目類型 + 單選題 +
+
+ 預計時間 + 15-20分鐘 +
+
+
+
+ + {/* 創意能力測試介紹 */} + + +
+ +
+ 創意能力測試 + 評估創新思維、想像力和創造性解決問題的能力 +
+ +
+
+ 題目數量 + 20題 +
+
+ 題目類型 + 5級量表 +
+
+ 預計時間 + 25-30分鐘 +
+
+
+
+ + {/* 綜合測試介紹 */} + + +
+ +
+ 綜合測試 + 完整的邏輯思維 + 創意能力雙重評估,獲得全面的能力報告 +
+ +
+
+ 總題目數 + 30題 +
+
+ 預計時間 + 45分鐘 +
+
+
+
+
+ ) : ( + // 一般用戶看到的測試功能 +
+ {/* Logic Test */} + + +
+ +
+ 邏輯思維測試 + 評估邏輯推理能力 +
+ + + +
+ + {/* Creative Test */} + + +
+ +
+ 創意能力測試 + 評估創新思維能力 +
+ + + +
+ + {/* Combined Test */} + + +
+ +
+ 綜合測試 + 完整能力評估 +
+ + + +
+
+ )} +
+
+ + {/* Footer */} +
+
+
+ {/* 左側內容 */} +
+
+ +
+
+ HR 評估系統 +

專業的員工能力測評解決方案,助力企業人才發展

+
+
+ + {/* 右側內容 */} +
+ © 2025 HR 評估系統. All rights reserved. +
+
+
+
+
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..6b5295c --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,35 @@ +import type React from "react" +import type { Metadata } from "next" +import { Inter, JetBrains_Mono } from "next/font/google" +import "./globals.css" +import { AuthProvider } from "@/components/auth-provider" + +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin"], +}) + +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", + subsets: ["latin"], +}) + +export const metadata: Metadata = { + title: "HR 評估系統", + description: "專業的員工能力測評平台", + generator: 'v0.app' +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..0045202 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,129 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Users, Eye, EyeOff } from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useAuth } from "@/lib/hooks/use-auth" + +export default function LoginPage() { + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + const router = useRouter() + const { login } = useAuth() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setIsLoading(true) + + try { + const success = await login(email, password) + if (success) { + router.push("/dashboard") + } else { + setError("帳號或密碼錯誤") + } + } catch (err) { + setError("登入失敗,請稍後再試") + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+
+ +
+

HR 評估系統

+

請登入您的帳戶

+
+ + + + 登入 + 輸入您的帳號和密碼以存取系統 + + +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + /> + +
+
+ + {error && ( + + {error} + + )} + + +
+ +
+

+ 還沒有帳戶?{" "} + + 立即註冊 + +

+
+ +
+

測試帳戶:

+
+

管理者:admin@company.com / admin123

+

員工:user@company.com / user123

+
+
+
+
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..f74999c --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,171 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { useAuth } from "@/lib/hooks/use-auth" +import { Button } from "@/components/ui/button" +import { Users, Brain, Lightbulb, BarChart3, Shield, Clock } from "lucide-react" +import Link from "next/link" + +export default function HomePage() { + const { user, isLoading } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!isLoading && user) { + router.push("/home") + } + }, [user, isLoading, router]) + + if (isLoading) { + return ( +
+
+
+

載入中...

+
+
+ ) + } + + if (user) { + return null + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+ HR 評估系統 +
+
+ + 登入 + + +
+
+
+ + {/* Hero Section */} +
+
+
+
+ +
+

HR 評估系統

+

+ 專業的員工能力測評平台,透過科學的邏輯思維測試和創意能力評估,全面了解員工的綜合素質 +

+
+
+
+ + {/* Features Section */} +
+
+
+

系統特色

+

+ 提供專業的測試類型和完整的分析報告,幫助企業全面評估員工能力 +

+
+ +
+
+
+ +
+

邏輯思維測試

+

+ 評估邏輯推理、分析判斷和問題解決能力,幫助識別具備優秀思維能力的人才 +

+
+ +
+
+ +
+

創意能力測試

+

+ 評估創新思維、想像力和創造性解決問題的能力,發掘具有創新潛力的員工 +

+
+ +
+
+ +
+

詳細分析報告

+

+ 提供完整的能力分析報告和發展建議,協助制定個人化的培訓計畫 +

+
+ +
+
+ +
+

安全可靠

+

+ 採用先進的資料加密技術,確保測試結果和個人資料的安全性 +

+
+ +
+
+ +
+

高效便捷

+

+ 線上測試系統,隨時隨地進行評估,大幅提升HR工作效率 +

+
+ +
+
+ +
+

多角色管理

+

+ 支援管理者和員工不同角色,提供個人化的使用體驗和權限管理 +

+
+
+
+
+ + + {/* Footer */} +
+
+
+ {/* 左側內容 */} +
+
+ +
+
+ HR 評估系統 +

專業的員工能力測評解決方案,助力企業人才發展

+
+
+ + {/* 右側內容 */} +
+ © 2025 HR 評估系統. All rights reserved. +
+
+
+
+
+ ) +} diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..ee2b331 --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,203 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Users, Eye, EyeOff } from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useAuth } from "@/lib/hooks/use-auth" + +export default function RegisterPage() { + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + confirmPassword: "", + department: "", + role: "user" as "user" | "admin", + }) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + const router = useRouter() + const { register } = useAuth() + + const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"] + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + if (formData.password !== formData.confirmPassword) { + setError("密碼確認不一致") + return + } + + if (formData.password.length < 6) { + setError("密碼長度至少需要6個字元") + return + } + + setIsLoading(true) + + try { + const success = await register({ + name: formData.name, + email: formData.email, + password: formData.password, + department: formData.department, + role: formData.role, + }) + + if (success) { + router.push("/dashboard") + } else { + setError("註冊失敗,該電子郵件可能已被使用") + } + } catch (err) { + setError("註冊失敗,請稍後再試") + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+
+ +
+

HR 評估系統

+

建立您的帳戶

+
+ + + + 註冊 + 填寫以下資訊以建立您的帳戶 + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + required + /> +
+ +
+ + +
+ +
+ +
+ setFormData({ ...formData, password: e.target.value })} + required + /> + +
+
+ +
+ +
+ setFormData({ ...formData, confirmPassword: e.target.value })} + required + /> + +
+
+ + {error && ( + + {error} + + )} + + +
+ +
+

+ 已有帳戶?{" "} + + 立即登入 + +

+
+
+
+
+
+ ) +} diff --git a/app/results/combined/page.tsx b/app/results/combined/page.tsx new file mode 100644 index 0000000..8959eac --- /dev/null +++ b/app/results/combined/page.tsx @@ -0,0 +1,295 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { Brain, Lightbulb, BarChart3, Home, RotateCcw, TrendingUp, Target, Award } from "lucide-react" +import Link from "next/link" +import { getRecommendations } from "@/lib/utils/score-calculator" + +interface CombinedTestResults { + type: string + logicScore: number + creativityScore: number + overallScore: number + level: string + description: string + breakdown: { + logic: number + creativity: number + balance: number + } + logicAnswers: Record + creativeAnswers: Record + logicCorrect: number + creativityTotal: number + creativityMaxScore: number + completedAt: string +} + +export default function CombinedResultsPage() { + const [results, setResults] = useState(null) + + useEffect(() => { + const savedResults = localStorage.getItem("combinedTestResults") + if (savedResults) { + setResults(JSON.parse(savedResults)) + } + }, []) + + if (!results) { + return ( +
+ + +

未找到测试结果

+ +
+
+
+ ) + } + + const recommendations = getRecommendations(results.logicScore, results.creativityScore) + + const getScoreColor = (score: number) => { + if (score >= 90) return "text-green-600" + if (score >= 80) return "text-blue-600" + if (score >= 70) return "text-yellow-600" + if (score >= 60) return "text-orange-600" + return "text-red-600" + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

综合能力测试结果

+

+ 完成时间:{new Date(results.completedAt).toLocaleString("zh-CN")} +

+
+
+
+
+ +
+
+ {/* Overall Score */} + + +
+ {results.overallScore} +
+ 综合评估完成! +
+ + {results.level} + +
+

{results.description}

+
+ + +
+
+
+ {results.logicScore} +
+
逻辑思维
+
+
+
+ {results.creativityScore} +
+
创意能力
+
+
+
+ {results.breakdown.balance} +
+
能力平衡
+
+
+
+
+ + {/* Detailed Breakdown */} +
+ {/* Logic Results */} + + + + + 逻辑思维测试 + + + +
+
+ 得分 + + {results.logicScore} + +
+ +
+
+
{results.logicCorrect}
+
答对题数
+
+
+
10
+
总题数
+
+
+
+
+
+ + {/* Creative Results */} + + + + + 创意能力测试 + + + +
+
+ 得分 + + {results.creativityScore} + +
+ +
+
+
{results.creativityTotal}
+
总得分
+
+
+
{results.creativityMaxScore}
+
满分
+
+
+
+
+
+
+ + {/* Ability Analysis */} + + + + + 能力分析 + + + +
+
+ +

逻辑思维

+
+ {results.logicScore}分 +
+ +

+ {results.logicScore >= 80 ? "表现优秀" : results.logicScore >= 60 ? "表现良好" : "需要提升"} +

+
+ +
+ +

创意能力

+
+ {results.creativityScore}分 +
+ +

+ {results.creativityScore >= 80 + ? "表现优秀" + : results.creativityScore >= 60 + ? "表现良好" + : "需要提升"} +

+
+ +
+ +

能力平衡

+
+ {results.breakdown.balance}分 +
+ +

+ {results.breakdown.balance >= 80 + ? "非常均衡" + : results.breakdown.balance >= 60 + ? "相对均衡" + : "发展不均"} +

+
+
+
+
+ + {/* Recommendations */} + {recommendations.length > 0 && ( + + + + + 发展建议 + + + +
+ {recommendations.map((recommendation, index) => ( +
+
+ {index + 1} +
+

{recommendation}

+
+ ))} +
+
+
+ )} + + {/* Actions */} +
+ + + +
+
+
+
+ ) +} diff --git a/app/results/creative/page.tsx b/app/results/creative/page.tsx new file mode 100644 index 0000000..36bb4d9 --- /dev/null +++ b/app/results/creative/page.tsx @@ -0,0 +1,225 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { Lightbulb, Home, RotateCcw, TrendingUp } from "lucide-react" +import Link from "next/link" +import { creativeQuestions } from "@/lib/questions/creative-questions" + +interface CreativeTestResults { + type: string + score: number + totalScore: number + maxScore: number + answers: Record + completedAt: string +} + +export default function CreativeResultsPage() { + const [results, setResults] = useState(null) + + useEffect(() => { + const savedResults = localStorage.getItem("creativeTestResults") + if (savedResults) { + setResults(JSON.parse(savedResults)) + } + }, []) + + if (!results) { + return ( +
+ + +

未找到测试结果

+ +
+
+
+ ) + } + + const getCreativityLevel = (score: number) => { + if (score >= 85) return { level: "极具创意", color: "bg-purple-500", description: "拥有卓越的创新思维和想象力" } + if (score >= 75) return { level: "很有创意", color: "bg-blue-500", description: "具备较强的创造性思维能力" } + if (score >= 65) return { level: "有一定创意", color: "bg-green-500", description: "具有一定的创新潜力" } + if (score >= 50) return { level: "创意一般", color: "bg-yellow-500", description: "创造性思维有待提升" } + return { level: "缺乏创意", color: "bg-red-500", description: "需要培养创新思维能力" } + } + + const creativityLevel = getCreativityLevel(results.score) + + // Calculate category scores + const categoryScores = { + innovation: { total: 0, count: 0, name: "创新能力" }, + imagination: { total: 0, count: 0, name: "想象力" }, + flexibility: { total: 0, count: 0, name: "灵活性" }, + originality: { total: 0, count: 0, name: "原创性" }, + } + + creativeQuestions.forEach((question, index) => { + const answer = results.answers[index] || 1 + const score = question.isReverse ? 6 - answer : answer + categoryScores[question.category].total += score + categoryScores[question.category].count += 1 + }) + + const categoryResults = Object.entries(categoryScores).map(([key, data]) => ({ + category: key, + name: data.name, + score: data.count > 0 ? Math.round((data.total / (data.count * 5)) * 100) : 0, + rawScore: data.total, + maxRawScore: data.count * 5, + })) + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

创意能力测试结果

+

+ 完成时间:{new Date(results.completedAt).toLocaleString("zh-CN")} +

+
+
+
+
+ +
+
+ {/* Score Overview */} + + +
+ {results.score} +
+ 创意测试完成! +
+ + {creativityLevel.level} + +
+

{creativityLevel.description}

+
+ +
+
+
{results.totalScore}
+
总得分
+
+
+
{results.maxScore}
+
满分
+
+
+
+ {Math.round((results.totalScore / results.maxScore) * 100)}% +
+
得分率
+
+
+ +
+
+ + {/* Category Analysis */} + + + + + 能力维度分析 + + + +
+ {categoryResults.map((category) => ( +
+
+

{category.name}

+ {category.score}分 +
+ +

+ {category.rawScore} / {category.maxRawScore} 分 +

+
+ ))} +
+
+
+ + {/* Detailed Feedback */} + + + 详细反馈 + + +
+
+

创意能力评估

+

+ 基于您的测试结果,您在创意思维方面表现为"{creativityLevel.level}"水平。 + {results.score >= 75 && + "您具备出色的创新思维能力,善于从不同角度思考问题,能够产生独特的想法和解决方案。"} + {results.score >= 50 && + results.score < 75 && + "您具有一定的创造性思维潜力,建议多参与创新活动,培养发散性思维。"} + {results.score < 50 && "建议您多接触创新思维训练,培养好奇心和探索精神,提升创造性解决问题的能力。"} +

+
+ +
+ {categoryResults.map((category) => ( +
+

{category.name}

+
+ + {category.score}% +
+

+ {category.score >= 80 && "表现优秀,继续保持"} + {category.score >= 60 && category.score < 80 && "表现良好,有提升空间"} + {category.score < 60 && "需要重点提升"} +

+
+ ))} +
+
+
+
+ + {/* Actions */} +
+ + + +
+
+
+
+ ) +} diff --git a/app/results/logic/page.tsx b/app/results/logic/page.tsx new file mode 100644 index 0000000..d336118 --- /dev/null +++ b/app/results/logic/page.tsx @@ -0,0 +1,194 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { CheckCircle, XCircle, Brain, Home, RotateCcw } from "lucide-react" +import Link from "next/link" +import { logicQuestions } from "@/lib/questions/logic-questions" + +interface LogicTestResults { + type: string + score: number + correctAnswers: number + totalQuestions: number + answers: Record + completedAt: string +} + +export default function LogicResultsPage() { + const [results, setResults] = useState(null) + + useEffect(() => { + const savedResults = localStorage.getItem("logicTestResults") + if (savedResults) { + setResults(JSON.parse(savedResults)) + } + }, []) + + if (!results) { + return ( +
+ + +

未找到测试结果

+ +
+
+
+ ) + } + + const getScoreLevel = (score: number) => { + if (score >= 90) return { level: "优秀", color: "bg-green-500", description: "逻辑思维能力出色" } + if (score >= 80) return { level: "良好", color: "bg-blue-500", description: "逻辑思维能力较强" } + if (score >= 70) return { level: "中等", color: "bg-yellow-500", description: "逻辑思维能力一般" } + if (score >= 60) return { level: "及格", color: "bg-orange-500", description: "逻辑思维能力需要提升" } + return { level: "不及格", color: "bg-red-500", description: "逻辑思维能力有待加强" } + } + + const scoreLevel = getScoreLevel(results.score) + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

逻辑思维测试结果

+

+ 完成时间:{new Date(results.completedAt).toLocaleString("zh-CN")} +

+
+
+
+
+ +
+
+ {/* Score Overview */} + + +
+ {results.score} +
+ 测试完成! +
+ + {scoreLevel.level} + +
+

{scoreLevel.description}

+
+ +
+
+
{results.correctAnswers}
+
答对题数
+
+
+
{results.totalQuestions}
+
总题数
+
+
+
+ {Math.round((results.correctAnswers / results.totalQuestions) * 100)}% +
+
正确率
+
+
+ +
+
+ + {/* Detailed Results */} + + + 详细结果 + + +
+ {logicQuestions.map((question, index) => { + const userAnswer = results.answers[index] + const isCorrect = userAnswer === question.correctAnswer + const correctOption = question.options.find((opt) => opt.value === question.correctAnswer) + const userOption = question.options.find((opt) => opt.value === userAnswer) + + return ( +
+
+
+ {isCorrect ? ( + + ) : ( + + )} +
+
+

+ 第{index + 1}题:{question.question} +

+
+
+ 你的答案: + + {userOption?.text || "未作答"} + +
+ {!isCorrect && ( +
+ 正确答案: + + {correctOption?.text} + +
+ )} + {question.explanation && !isCorrect && ( +
+ 解析: + {question.explanation} +
+ )} +
+
+
+
+ ) + })} +
+
+
+ + {/* Actions */} +
+ + + +
+
+
+
+ ) +} diff --git a/app/results/page.tsx b/app/results/page.tsx new file mode 100644 index 0000000..c1be2c9 --- /dev/null +++ b/app/results/page.tsx @@ -0,0 +1,376 @@ +"use client" + +import { useEffect, useState } from "react" +import { ProtectedRoute } from "@/components/protected-route" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { Brain, Lightbulb, BarChart3, Calendar, TrendingUp, Users, Eye, ArrowLeft } from "lucide-react" +import Link from "next/link" +import { useAuth } from "@/lib/hooks/use-auth" + +interface TestResult { + type: "logic" | "creative" | "combined" + score: number + completedAt: string + details?: any +} + +export default function ResultsPage() { + return ( + + + + ) +} + +function ResultsContent() { + const { user } = useAuth() + const [results, setResults] = useState([]) + const [stats, setStats] = useState({ + totalTests: 0, + averageScore: 0, + bestScore: 0, + lastTestDate: null as string | null, + }) + + useEffect(() => { + // Load all test results from localStorage + const logicResults = localStorage.getItem("logicTestResults") + const creativeResults = localStorage.getItem("creativeTestResults") + const combinedResults = localStorage.getItem("combinedTestResults") + + const allResults: TestResult[] = [] + + if (logicResults) { + const data = JSON.parse(logicResults) + allResults.push({ + type: "logic", + score: data.score, + completedAt: data.completedAt, + details: data, + }) + } + + if (creativeResults) { + const data = JSON.parse(creativeResults) + allResults.push({ + type: "creative", + score: data.score, + completedAt: data.completedAt, + details: data, + }) + } + + if (combinedResults) { + const data = JSON.parse(combinedResults) + allResults.push({ + type: "combined", + score: data.overallScore, + completedAt: data.completedAt, + details: data, + }) + } + + // Sort by completion date (newest first) + allResults.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime()) + + setResults(allResults) + + // Calculate statistics + if (allResults.length > 0) { + const totalScore = allResults.reduce((sum, result) => sum + result.score, 0) + const averageScore = Math.round(totalScore / allResults.length) + const bestScore = Math.max(...allResults.map((r) => r.score)) + const lastTestDate = allResults[0].completedAt + + setStats({ + totalTests: allResults.length, + averageScore, + bestScore, + lastTestDate, + }) + } + }, []) + + const getTestTypeInfo = (type: string) => { + switch (type) { + case "logic": + return { + name: "邏輯思維測試", + icon: Brain, + color: "bg-primary", + textColor: "text-primary", + link: "/results/logic", + } + case "creative": + return { + name: "創意能力測試", + icon: Lightbulb, + color: "bg-accent", + textColor: "text-accent", + link: "/results/creative", + } + case "combined": + return { + name: "綜合能力測試", + icon: BarChart3, + color: "bg-gradient-to-r from-primary to-accent", + textColor: "text-primary", + link: "/results/combined", + } + default: + return { + name: "未知測試", + icon: BarChart3, + color: "bg-muted", + textColor: "text-muted-foreground", + link: "#", + } + } + } + + const getScoreLevel = (score: number) => { + if (score >= 90) return { level: "優秀", color: "bg-green-500" } + if (score >= 80) return { level: "良好", color: "bg-blue-500" } + if (score >= 70) return { level: "中等", color: "bg-yellow-500" } + if (score >= 60) return { level: "及格", color: "bg-orange-500" } + return { level: "不及格", color: "bg-red-500" } + } + + if (results.length === 0) { + return ( +
+ {/* Header */} +
+
+
+ +
+

我的測試結果

+

查看您的測試歷史和成績分析

+
+
+
+
+ +
+
+
+ +
+

暫無測試記錄

+

您還沒有完成任何測試,開始您的第一次評估吧!

+
+ + + +
+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ +
+

我的測試結果

+

查看您的測試歷史和成績分析

+
+
+
+
+ +
+
+ {/* User Info */} + {user && ( + + +
+
+ +
+
+

{user.name}

+

+ {user.department} • {user.role === "admin" ? "管理員" : "一般用戶"} +

+
+
+
+
+ )} + + {/* Statistics Overview */} +
+ + +
+ +
+
{stats.totalTests}
+
完成測試
+
+
+ + + +
+ +
+
{stats.averageScore}
+
平均分數
+
+
+ + + +
+ +
+
{stats.bestScore}
+
最高分數
+
+
+ + + +
+ +
+
+ {stats.lastTestDate ? new Date(stats.lastTestDate).toLocaleDateString("zh-TW") : "無"} +
+
最近測試
+
+
+
+ + {/* Test History */} + + + 測試歷史 + + +
+ {results.map((result, index) => { + const testInfo = getTestTypeInfo(result.type) + const scoreLevel = getScoreLevel(result.score) + const Icon = testInfo.icon + + return ( +
+
+
+ +
+
+

{testInfo.name}

+

+ 完成時間:{new Date(result.completedAt).toLocaleString("zh-TW")} +

+
+
+ +
+
+
{result.score}
+ {scoreLevel.level} +
+ +
+
+ ) + })} +
+
+
+ + {/* Performance Chart */} + + + 成績趨勢 + + +
+ {results.map((result, index) => { + const testInfo = getTestTypeInfo(result.type) + return ( +
+
+ {testInfo.name} + {result.score}分 +
+ +
+ ) + })} +
+
+
+ + {/* Quick Actions */} + + + 繼續測試 + + +
+ + + +
+
+
+
+
+
+ ) +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..fbe267c --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,346 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { ProtectedRoute } from "@/components/protected-route" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Separator } from "@/components/ui/separator" +import { User, Lock, ArrowLeft, Save } from "lucide-react" +import Link from "next/link" +import { useAuth } from "@/lib/hooks/use-auth" + +export default function SettingsPage() { + return ( + + + + ) +} + +function SettingsContent() { + const { user, logout } = useAuth() + const router = useRouter() + const [isLoading, setIsLoading] = useState(false) + const [message, setMessage] = useState("") + const [error, setError] = useState("") + + // Profile form state + const [profileData, setProfileData] = useState({ + name: "", + email: "", + department: "", + }) + + // Password form state + const [passwordData, setPasswordData] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + + const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"] + + useEffect(() => { + if (user) { + setProfileData({ + name: user.name, + email: user.email, + department: user.department, + }) + } + }, [user]) + + const handleProfileUpdate = async () => { + setError("") + setMessage("") + setIsLoading(true) + + try { + // Get all users + const users = JSON.parse(localStorage.getItem("hr_users") || "[]") + + // Check if email is already taken by another user + const emailExists = users.some((u: any) => u.email === profileData.email && u.id !== user?.id) + if (emailExists) { + setError("該電子郵件已被其他用戶使用") + return + } + + // Update user data + const updatedUsers = users.map((u: any) => + u.id === user?.id + ? { ...u, name: profileData.name, email: profileData.email, department: profileData.department } + : u, + ) + + localStorage.setItem("hr_users", JSON.stringify(updatedUsers)) + + // Update current user session + const updatedUser = { + ...user!, + name: profileData.name, + email: profileData.email, + department: profileData.department, + } + localStorage.setItem("hr_current_user", JSON.stringify(updatedUser)) + + setMessage("個人資料已成功更新") + + // Refresh page to update user context + setTimeout(() => { + window.location.reload() + }, 1500) + } catch (err) { + setError("更新失敗,請稍後再試") + } finally { + setIsLoading(false) + } + } + + const handlePasswordChange = async () => { + setError("") + setMessage("") + + if (passwordData.newPassword !== passwordData.confirmPassword) { + setError("新密碼確認不一致") + return + } + + if (passwordData.newPassword.length < 6) { + setError("新密碼長度至少需要6個字元") + return + } + + setIsLoading(true) + + try { + // Get all users + const users = JSON.parse(localStorage.getItem("hr_users") || "[]") + + // Find current user and verify current password + const currentUser = users.find((u: any) => u.id === user?.id) + if (!currentUser || currentUser.password !== passwordData.currentPassword) { + setError("目前密碼不正確") + return + } + + // Update password + const updatedUsers = users.map((u: any) => (u.id === user?.id ? { ...u, password: passwordData.newPassword } : u)) + + localStorage.setItem("hr_users", JSON.stringify(updatedUsers)) + + setPasswordData({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + + setMessage("密碼已成功更新") + } catch (err) { + setError("密碼更新失敗,請稍後再試") + } finally { + setIsLoading(false) + } + } + + if (!user) return null + + return ( +
+ {/* Header */} +
+
+
+ +
+

帳戶設定

+

管理您的個人資料和帳戶設定

+
+
+
+
+ +
+
+ {/* Profile Settings */} + + + + + 個人資料 + + 更新您的個人資訊 + + +
+ + setProfileData({ ...profileData, name: e.target.value })} + placeholder="請輸入您的姓名" + /> +
+ +
+ + setProfileData({ ...profileData, email: e.target.value })} + placeholder="請輸入電子郵件" + /> +
+ +
+ + +
+ +
+ +
+ {user.role === "admin" ? "管理員" : "一般用戶"} +

角色由管理員設定,無法自行修改

+
+
+ + +
+
+ + + + {/* Password Settings */} + + + + + 密碼設定 + + 更改您的登入密碼 + + +
+ + setPasswordData({ ...passwordData, currentPassword: e.target.value })} + placeholder="請輸入目前密碼" + /> +
+ +
+ + setPasswordData({ ...passwordData, newPassword: e.target.value })} + placeholder="請輸入新密碼(至少6個字元)" + /> +
+ +
+ + setPasswordData({ ...passwordData, confirmPassword: e.target.value })} + placeholder="請再次輸入新密碼" + /> +
+ + +
+
+ + {/* Account Info */} + + + 帳戶資訊 + 您的帳戶詳細資訊 + + +
+
+ 用戶ID: + {user.id} +
+
+ 建立時間: + {new Date(user.createdAt).toLocaleDateString()} +
+
+ + + +
+
+

登出帳戶

+

結束目前的登入狀態

+
+ +
+
+
+ + {/* Messages */} + {message && ( + + {message} + + )} + + {error && ( + + {error} + + )} +
+
+
+ ) +} diff --git a/app/tests/combined/page.tsx b/app/tests/combined/page.tsx new file mode 100644 index 0000000..025779d --- /dev/null +++ b/app/tests/combined/page.tsx @@ -0,0 +1,316 @@ +"use client" + +import { useState, useEffect } from "react" +import { TestLayout } from "@/components/test-layout" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Label } from "@/components/ui/label" +import { Progress } from "@/components/ui/progress" +import { useRouter } from "next/navigation" +import { logicQuestions } from "@/lib/questions/logic-questions" +import { creativeQuestions } from "@/lib/questions/creative-questions" +import { calculateCombinedScore } from "@/lib/utils/score-calculator" + +type TestPhase = "logic" | "creative" | "completed" + +export default function CombinedTestPage() { + const router = useRouter() + const [phase, setPhase] = useState("logic") + const [currentQuestion, setCurrentQuestion] = useState(0) + const [logicAnswers, setLogicAnswers] = useState>({}) + const [creativeAnswers, setCreativeAnswers] = useState>({}) + const [timeRemaining, setTimeRemaining] = useState(45 * 60) // 45 minutes total + + // Timer effect + useEffect(() => { + const timer = setInterval(() => { + setTimeRemaining((prev) => { + if (prev <= 1) { + handleSubmit() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, []) + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` + } + + const getCurrentQuestions = () => { + return phase === "logic" ? logicQuestions : creativeQuestions + } + + const getTotalQuestions = () => { + return logicQuestions.length + creativeQuestions.length + } + + const getOverallProgress = () => { + const logicCompleted = phase === "logic" ? currentQuestion : logicQuestions.length + const creativeCompleted = phase === "creative" ? currentQuestion : 0 + return logicCompleted + creativeCompleted + } + + const handleAnswerChange = (value: string) => { + if (phase === "logic") { + setLogicAnswers((prev) => ({ + ...prev, + [currentQuestion]: value, + })) + } else { + setCreativeAnswers((prev) => ({ + ...prev, + [currentQuestion]: Number.parseInt(value), + })) + } + } + + const handleNext = () => { + const currentQuestions = getCurrentQuestions() + + if (currentQuestion < currentQuestions.length - 1) { + setCurrentQuestion((prev) => prev + 1) + } else if (phase === "logic") { + // Switch to creative phase + setPhase("creative") + setCurrentQuestion(0) + } else { + // Complete the test + handleSubmit() + } + } + + const handlePrevious = () => { + if (currentQuestion > 0) { + setCurrentQuestion((prev) => prev - 1) + } else if (phase === "creative") { + // Go back to logic phase + setPhase("logic") + setCurrentQuestion(logicQuestions.length - 1) + } + } + + const handleSubmit = () => { + // Calculate logic score + let logicCorrect = 0 + logicQuestions.forEach((question, index) => { + if (logicAnswers[index] === question.correctAnswer) { + logicCorrect++ + } + }) + const logicScore = Math.round((logicCorrect / logicQuestions.length) * 100) + + // Calculate creativity score + let creativityTotal = 0 + creativeQuestions.forEach((question, index) => { + const answer = creativeAnswers[index] || 1 + creativityTotal += question.isReverse ? 6 - answer : answer + }) + const creativityMaxScore = creativeQuestions.length * 5 + const creativityScore = Math.round((creativityTotal / creativityMaxScore) * 100) + + // Calculate combined score + const combinedResult = calculateCombinedScore(logicScore, creativityScore) + + // Store results + const results = { + type: "combined", + logicScore, + creativityScore, + overallScore: combinedResult.overallScore, + level: combinedResult.level, + description: combinedResult.description, + breakdown: combinedResult.breakdown, + logicAnswers, + creativeAnswers, + logicCorrect, + creativityTotal, + creativityMaxScore, + completedAt: new Date().toISOString(), + } + + localStorage.setItem("combinedTestResults", JSON.stringify(results)) + router.push("/results/combined") + } + + const currentQuestions = getCurrentQuestions() + const currentQ = currentQuestions[currentQuestion] + const isLastQuestion = phase === "creative" && currentQuestion === creativeQuestions.length - 1 + const hasAnswer = + phase === "logic" ? logicAnswers[currentQuestion] !== undefined : creativeAnswers[currentQuestion] !== undefined + + const getPhaseTitle = () => { + if (phase === "logic") return "第一部分:邏輯思維測試" + return "第二部分:創意能力測試" + } + + const getQuestionNumber = () => { + if (phase === "logic") return currentQuestion + 1 + return logicQuestions.length + currentQuestion + 1 + } + + return ( + router.push("/")} + > +
+ {/* Phase Indicator */} +
+
+

{getPhaseTitle()}

+
+ {phase === "logic" + ? `${currentQuestion + 1}/${logicQuestions.length}` + : `${currentQuestion + 1}/${creativeQuestions.length}`} +
+
+ +
+ + + + + {phase === "logic" ? currentQ.question : currentQ.statement} + + {phase === "creative" && ( +

請根據這個描述與你的實際情況的符合程度進行選擇

+ )} +
+ + + {phase === "logic" + ? // Logic question options + currentQ.options?.map((option: any, index: number) => ( +
+ + +
+ )) + : // Creative question options + [ + { value: "5", label: "我最符合", color: "text-green-600" }, + { value: "4", label: "比較符合", color: "text-green-500" }, + { value: "3", label: "一般", color: "text-yellow-500" }, + { value: "2", label: "不太符合", color: "text-orange-500" }, + { value: "1", label: "與我不符", color: "text-red-500" }, + ].map((option) => ( +
+ + +
{option.value}
+
+ ))} +
+
+
+ + {/* Navigation */} +
+ + +
+ {/* Logic questions indicators */} + {logicQuestions.map((_, index) => ( + + ))} + + {/* Separator */} +
+ + {/* Creative questions indicators */} + {creativeQuestions.map((_, index) => ( + + ))} +
+ + {isLastQuestion ? ( + + ) : ( + + )} +
+ + {/* Progress Summary */} +
+ 總進度:已完成 {getOverallProgress()} / {getTotalQuestions()} 題 +
+ 當前階段:{phase === "logic" ? "邏輯思維測試" : "創意能力測試"} ( + {Object.keys(phase === "logic" ? logicAnswers : creativeAnswers).length} / {currentQuestions.length} 題) +
+
+
+ ) +} diff --git a/app/tests/creative/page.tsx b/app/tests/creative/page.tsx new file mode 100644 index 0000000..4788641 --- /dev/null +++ b/app/tests/creative/page.tsx @@ -0,0 +1,177 @@ +"use client" + +import { useState, useEffect } from "react" +import { TestLayout } from "@/components/test-layout" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Label } from "@/components/ui/label" +import { useRouter } from "next/navigation" +import { creativeQuestions } from "@/lib/questions/creative-questions" + +export default function CreativeTestPage() { + const router = useRouter() + const [currentQuestion, setCurrentQuestion] = useState(0) + const [answers, setAnswers] = useState>({}) + const [timeRemaining, setTimeRemaining] = useState(30 * 60) // 30 minutes in seconds + + // Timer effect + useEffect(() => { + const timer = setInterval(() => { + setTimeRemaining((prev) => { + if (prev <= 1) { + handleSubmit() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, []) + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` + } + + const handleAnswerChange = (value: string) => { + setAnswers((prev) => ({ + ...prev, + [currentQuestion]: Number.parseInt(value), + })) + } + + const handleNext = () => { + if (currentQuestion < creativeQuestions.length - 1) { + setCurrentQuestion((prev) => prev + 1) + } + } + + const handlePrevious = () => { + if (currentQuestion > 0) { + setCurrentQuestion((prev) => prev - 1) + } + } + + const handleSubmit = () => { + // Calculate score based on creativity scoring + let totalScore = 0 + creativeQuestions.forEach((question, index) => { + const answer = answers[index] || 1 + // For creativity, higher scores indicate more creative thinking + totalScore += question.isReverse ? 6 - answer : answer + }) + + const maxScore = creativeQuestions.length * 5 + const score = Math.round((totalScore / maxScore) * 100) + + // Store results in localStorage + const results = { + type: "creative", + score, + totalScore, + maxScore, + answers, + completedAt: new Date().toISOString(), + } + + localStorage.setItem("creativeTestResults", JSON.stringify(results)) + router.push("/results/creative") + } + + const currentQ = creativeQuestions[currentQuestion] + const isLastQuestion = currentQuestion === creativeQuestions.length - 1 + const hasAnswer = answers[currentQuestion] !== undefined + + const scaleOptions = [ + { value: "5", label: "我最符合", color: "text-green-600" }, + { value: "4", label: "比较符合", color: "text-green-500" }, + { value: "3", label: "一般", color: "text-yellow-500" }, + { value: "2", label: "不太符合", color: "text-orange-500" }, + { value: "1", label: "与我不符", color: "text-red-500" }, + ] + + return ( + router.push("/")} + > +
+ + + {currentQ.statement} +

請根據這個描述與你的實際情況的符合程度進行選擇

+
+ + + {scaleOptions.map((option) => ( +
+ + +
{option.value}
+
+ ))} +
+
+
+ + {/* Navigation */} +
+ + +
+ {creativeQuestions.map((_, index) => ( + + ))} +
+ + {isLastQuestion ? ( + + ) : ( + + )} +
+ + {/* Progress Summary */} +
+ 已完成 {Object.keys(answers).length} / {creativeQuestions.length} 題 +
+
+
+ ) +} diff --git a/app/tests/logic/page.tsx b/app/tests/logic/page.tsx new file mode 100644 index 0000000..747f013 --- /dev/null +++ b/app/tests/logic/page.tsx @@ -0,0 +1,166 @@ +"use client" + +import { useState, useEffect } from "react" +import { TestLayout } from "@/components/test-layout" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Label } from "@/components/ui/label" +import { useRouter } from "next/navigation" +import { logicQuestions } from "@/lib/questions/logic-questions" + +export default function LogicTestPage() { + const router = useRouter() + const [currentQuestion, setCurrentQuestion] = useState(0) + const [answers, setAnswers] = useState>({}) + const [timeRemaining, setTimeRemaining] = useState(20 * 60) // 20 minutes in seconds + + // Timer effect + useEffect(() => { + const timer = setInterval(() => { + setTimeRemaining((prev) => { + if (prev <= 1) { + handleSubmit() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, []) + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` + } + + const handleAnswerChange = (value: string) => { + setAnswers((prev) => ({ + ...prev, + [currentQuestion]: value, + })) + } + + const handleNext = () => { + if (currentQuestion < logicQuestions.length - 1) { + setCurrentQuestion((prev) => prev + 1) + } + } + + const handlePrevious = () => { + if (currentQuestion > 0) { + setCurrentQuestion((prev) => prev - 1) + } + } + + const handleSubmit = () => { + // Calculate score + let correctAnswers = 0 + logicQuestions.forEach((question, index) => { + if (answers[index] === question.correctAnswer) { + correctAnswers++ + } + }) + + const score = Math.round((correctAnswers / logicQuestions.length) * 100) + + // Store results in localStorage + const results = { + type: "logic", + score, + correctAnswers, + totalQuestions: logicQuestions.length, + answers, + completedAt: new Date().toISOString(), + } + + localStorage.setItem("logicTestResults", JSON.stringify(results)) + router.push("/results/logic") + } + + const currentQ = logicQuestions[currentQuestion] + const isLastQuestion = currentQuestion === logicQuestions.length - 1 + const hasAnswer = answers[currentQuestion] !== undefined + + return ( + router.push("/")} + > +
+ + + {currentQ.question} + + + + {currentQ.options.map((option, index) => ( +
+ + +
+ ))} +
+
+
+ + {/* Navigation */} +
+
+ + + {isLastQuestion ? ( + + ) : ( + + )} +
+ +
+ {logicQuestions.map((_, index) => ( + + ))} +
+
+ + {/* Progress Summary */} +
+ 已完成 {Object.keys(answers).length} / {logicQuestions.length} 題 +
+
+
+ ) +} diff --git a/app/tests/page.tsx b/app/tests/page.tsx new file mode 100644 index 0000000..78cd8a1 --- /dev/null +++ b/app/tests/page.tsx @@ -0,0 +1,151 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Brain, Lightbulb, BarChart3, ArrowLeft } from "lucide-react" +import Link from "next/link" + +export default function TestsPage() { + return ( +
+ {/* Header */} +
+
+
+ +
+

測試中心

+

選擇您要進行的測試類型

+
+
+
+
+ +
+
+
+

選擇測試類型

+

+ 我們提供三種不同的測試模式,您可以根據需要選擇單項測試或綜合評估 +

+
+ +
+ {/* Logic Test */} + + +
+ +
+ 邏輯思維測試 + 評估邏輯推理、分析判斷和問題解決能力 +
+ +
+
+ 題目數量 + 10 題 +
+
+ 題目類型 + 單選題 +
+
+ 預計時間 + 15-20 分鐘 +
+
+ +
+
+ + {/* Creative Test */} + + +
+ +
+ 創意能力測試 + 評估創新思維、想像力和創造性解決問題的能力 +
+ +
+
+ 題目數量 + 20 題 +
+
+ 題目類型 + 5級量表 +
+
+ 預計時間 + 25-30 分鐘 +
+
+ +
+
+ + {/* Combined Test */} + + +
+ +
+ 綜合能力測試 + 完整的邏輯思維 + 創意能力雙重評估 +
+ +
+
+ 題目數量 + 30 題 +
+
+ 測試內容 + 邏輯 + 創意 +
+
+ 預計時間 + 40-45 分鐘 +
+
+ +
+
+
+ + {/* Additional Info */} +
+ + +

測試說明

+
+

• 所有測試都有時間限制,請合理安排答題時間

+

• 邏輯題和創意題不會混合出現,會分別進行

+

• 綜合測試將先進行邏輯題,再進行創意題

+

• 測試結果會自動保存,您可以隨時查看歷史成績

+

• 建議在安靜的環境中完成測試,以獲得最佳結果

+
+
+
+
+
+
+
+ ) +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/auth-provider.tsx b/components/auth-provider.tsx new file mode 100644 index 0000000..ef9e591 --- /dev/null +++ b/components/auth-provider.tsx @@ -0,0 +1,30 @@ +"use client" + +import type React from "react" + +import { createContext, useContext } from "react" +import { useAuth, type User } from "@/lib/hooks/use-auth" + +interface AuthContextType { + user: User | null + login: (email: string, password: string) => Promise + register: (userData: Omit & { password: string }) => Promise + logout: () => void + isLoading: boolean +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const auth = useAuth() + + return {children} +} + +export function useAuthContext() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error("useAuthContext must be used within an AuthProvider") + } + return context +} diff --git a/components/protected-route.tsx b/components/protected-route.tsx new file mode 100644 index 0000000..fb0abcb --- /dev/null +++ b/components/protected-route.tsx @@ -0,0 +1,48 @@ +"use client" + +import type React from "react" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { useAuth } from "@/lib/hooks/use-auth" + +interface ProtectedRouteProps { + children: React.ReactNode + adminOnly?: boolean +} + +export function ProtectedRoute({ children, adminOnly = false }: ProtectedRouteProps) { + const { user, isLoading } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!isLoading) { + if (!user) { + router.push("/login") + return + } + + if (adminOnly && user.role !== "admin") { + router.push("/dashboard") + return + } + } + }, [user, isLoading, adminOnly, router]) + + if (isLoading) { + return ( +
+
+
+

載入中...

+
+
+ ) + } + + if (!user || (adminOnly && user.role !== "admin")) { + return null + } + + return <>{children} +} diff --git a/components/test-layout.tsx b/components/test-layout.tsx new file mode 100644 index 0000000..7b8dda4 --- /dev/null +++ b/components/test-layout.tsx @@ -0,0 +1,72 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Progress } from "@/components/ui/progress" +import { ArrowLeft, Clock } from "lucide-react" +import Link from "next/link" +import type { ReactNode } from "react" + +interface TestLayoutProps { + children: ReactNode + title: string + currentQuestion: number + totalQuestions: number + timeRemaining?: string + onBack?: () => void +} + +export function TestLayout({ + children, + title, + currentQuestion, + totalQuestions, + timeRemaining, + onBack, +}: TestLayoutProps) { + const progress = (currentQuestion / totalQuestions) * 100 + + return ( +
+ {/* Header */} +
+
+
+
+ +
+

{title}

+

+ 第 {currentQuestion} 題 / 共 {totalQuestions} 題 +

+
+
+ {timeRemaining && ( +
+ + {timeRemaining} +
+ )} +
+
+ +
+
+
+ + {/* Main Content */} +
{children}
+
+ ) +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..e538a33 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDownIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..9704452 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +'use client' + +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..e6751ab --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..40bb120 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio' + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..aa98465 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +'use client' + +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..fc4126b --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span' + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..1750ff2 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { ChevronRight, MoreHorizontal } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return