diff --git a/DATABASE_MIGRATION_GUIDE.md b/DATABASE_MIGRATION_GUIDE.md new file mode 100644 index 0000000..1009bdd --- /dev/null +++ b/DATABASE_MIGRATION_GUIDE.md @@ -0,0 +1,95 @@ +# 資料庫遷移指南 - 添加 is_timeout 欄位 + +## 概述 +本次遷移為現有的測試結果表添加 `is_timeout` 欄位,用於標記測試是否因為時間到而強制提交。 + +## 受影響的表 +1. `test_results` - 邏輯測試和創意測試結果表 +2. `combined_test_results` - 綜合測試結果表 + +## 遷移內容 +為每個表添加以下欄位: +```sql +is_timeout BOOLEAN DEFAULT FALSE +``` + +## 執行方法 + +### 方法一:直接執行 SQL 腳本(推薦) +1. 打開 MySQL 客戶端(如 phpMyAdmin、MySQL Workbench 或命令行) +2. 連接到 `hr_assessment` 資料庫 +3. 執行以下 SQL 命令: + +```sql +USE hr_assessment; + +-- 添加 test_results 表的 is_timeout 欄位 +ALTER TABLE test_results +ADD COLUMN is_timeout BOOLEAN DEFAULT FALSE; + +-- 添加 combined_test_results 表的 is_timeout 欄位 +ALTER TABLE combined_test_results +ADD COLUMN is_timeout BOOLEAN DEFAULT FALSE; +``` + +### 方法二:使用 Node.js 腳本 +1. 確保已安裝 mysql2 依賴: + ```bash + npm install mysql2 + ``` + +2. 執行遷移腳本: + ```bash + node scripts/add-timeout-columns.js + ``` + +### 方法三:應用程式自動遷移 +當應用程式重新啟動時,會自動執行遷移(如果欄位不存在)。 + +## 驗證遷移 +執行以下查詢來驗證欄位是否成功添加: + +```sql +-- 檢查 test_results 表結構 +DESCRIBE test_results; + +-- 檢查 combined_test_results 表結構 +DESCRIBE combined_test_results; + +-- 確認欄位存在 +SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'hr_assessment' +AND TABLE_NAME IN ('test_results', 'combined_test_results') +AND COLUMN_NAME = 'is_timeout'; +``` + +## 預期結果 +- `test_results` 表應該包含 `is_timeout` 欄位 +- `combined_test_results` 表應該包含 `is_timeout` 欄位 +- 所有現有記錄的 `is_timeout` 值應該為 `FALSE` + +## 回滾方法 +如果需要移除這些欄位(不建議): + +```sql +USE hr_assessment; + +-- 移除 test_results 表的 is_timeout 欄位 +ALTER TABLE test_results DROP COLUMN is_timeout; + +-- 移除 combined_test_results 表的 is_timeout 欄位 +ALTER TABLE combined_test_results DROP COLUMN is_timeout; +``` + +## 注意事項 +1. 此遷移是向後兼容的,不會影響現有功能 +2. 所有現有記錄的 `is_timeout` 值將設為 `FALSE` +3. 新的測試結果將根據實際情況設置 `is_timeout` 值 +4. 建議在生產環境執行前先在測試環境驗證 + +## 完成後的功能 +- 綜合測試時間從 45 分鐘改為 30 分鐘 +- 剩餘 5 分鐘時會彈出提醒 +- 時間到後自動提交並標記為時間到 +- 管理員可以在結果頁面看到時間到標記 diff --git a/app/admin/results/detail/[testResultId]/page.tsx b/app/admin/results/detail/[testResultId]/page.tsx index d3adce6..49b6f77 100644 --- a/app/admin/results/detail/[testResultId]/page.tsx +++ b/app/admin/results/detail/[testResultId]/page.tsx @@ -553,6 +553,7 @@ function AdminResultDetailContent() { balanceScore={result.details.abilityBalance || 0} level={getScoreLevel(result.score, result.type).level} description={getScoreLevel(result.score, result.type).description} + isTimeout={result.isTimeout} logicBreakdown={result.details.breakdown} creativityBreakdown={result.details.breakdown} // 個別測試結果的詳細資料 diff --git a/app/api/admin/test-results/detail/route.ts b/app/api/admin/test-results/detail/route.ts index 8b2aaa8..34e1bf6 100644 --- a/app/api/admin/test-results/detail/route.ts +++ b/app/api/admin/test-results/detail/route.ts @@ -123,7 +123,8 @@ export async function GET(request: NextRequest) { userId: testResult.user_id, type: testResult.test_type, score: testResult.score, - completedAt: testResult.completed_at + completedAt: testResult.completed_at, + isTimeout: testResult.is_timeout || false } // 獲取詳細答案 diff --git a/app/api/test-results/creative/route.ts b/app/api/test-results/creative/route.ts index c3ad21e..df10cc0 100644 --- a/app/api/test-results/creative/route.ts +++ b/app/api/test-results/creative/route.ts @@ -10,7 +10,8 @@ export async function POST(request: NextRequest) { const { userId, answers, - completedAt + completedAt, + isTimeout = false } = body // 驗證必要欄位 @@ -81,7 +82,8 @@ export async function POST(request: NextRequest) { score: scorePercentage, total_questions: questions.length, correct_answers: totalScore, - completed_at: mysqlCompletedAt + completed_at: mysqlCompletedAt, + is_timeout: isTimeout }) console.log('測試結果建立結果:', testResult) diff --git a/app/results/combined/page.tsx b/app/results/combined/page.tsx index ba15dab..cba3c44 100644 --- a/app/results/combined/page.tsx +++ b/app/results/combined/page.tsx @@ -136,16 +136,6 @@ export default function CombinedResultsPage() { 返回首頁 - - diff --git a/app/results/creative/page.tsx b/app/results/creative/page.tsx index b5392bc..7e4b3a7 100644 --- a/app/results/creative/page.tsx +++ b/app/results/creative/page.tsx @@ -114,16 +114,6 @@ export default function CreativeResultsPage() { 返回首頁 - - diff --git a/app/results/logic/page.tsx b/app/results/logic/page.tsx index d156645..49fa50a 100644 --- a/app/results/logic/page.tsx +++ b/app/results/logic/page.tsx @@ -147,16 +147,6 @@ export default function LogicResultsPage() { 返回首頁 - - diff --git a/app/tests/combined/page.tsx b/app/tests/combined/page.tsx index ad10cd0..0dc7b71 100644 --- a/app/tests/combined/page.tsx +++ b/app/tests/combined/page.tsx @@ -10,6 +10,7 @@ import { Progress } from "@/components/ui/progress" import { useRouter } from "next/navigation" import { calculateCombinedScore } from "@/lib/utils/score-calculator" import { useAuth } from "@/lib/hooks/use-auth" +import { TimeWarningModal } from "@/components/time-warning-modal" interface LogicQuestion { id: number @@ -47,11 +48,22 @@ export default function CombinedTestPage() { const [creativeQuestions, setCreativeQuestions] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isSubmitting, setIsSubmitting] = useState(false) + const [hasTimedOut, setHasTimedOut] = useState(false) // 防止重複提交 + const [timeoutSubmitted, setTimeoutSubmitted] = useState(false) // 確保只提交一次 + + // 彈窗狀態 + const [showWarningModal, setShowWarningModal] = useState(false) + const [showTimeoutModal, setShowTimeoutModal] = useState(false) + const [showSuccessModal, setShowSuccessModal] = useState(false) + const [modalMessage, setModalMessage] = useState('') // Load questions from database useEffect(() => { const loadQuestions = async () => { try { + // 清除之前的提交標記 + localStorage.removeItem('combinedTestTimeoutSubmitted') + // Load logic questions const logicResponse = await fetch('/api/logic-questions') const logicData = await logicResponse.json() @@ -87,12 +99,25 @@ export default function CombinedTestPage() { // 檢查是否剩餘5分鐘(300秒)且尚未顯示警告 if (newTime <= 300 && !hasShownWarning) { setHasShownWarning(true) - alert('⚠️ 注意:距離測試結束還有5分鐘,請盡快完成剩餘題目!') + setModalMessage('距離測試結束還有5分鐘,請盡快完成剩餘題目!') + setShowWarningModal(true) } // 時間到,強制提交 - if (newTime <= 0) { + if (newTime <= 0 && !hasTimedOut && !timeoutSubmitted) { + // 檢查 localStorage 是否已經提交過 + const alreadySubmitted = localStorage.getItem('combinedTestTimeoutSubmitted') + if (alreadySubmitted) { + console.log('⏰ 已經提交過時間到結果,跳過重複提交') + setHasTimedOut(true) + setTimeoutSubmitted(true) + return 0 + } + console.log('⏰ 時間到!強制提交測驗...') + localStorage.setItem('combinedTestTimeoutSubmitted', 'true') + setHasTimedOut(true) // 防止重複提交 + setTimeoutSubmitted(true) // 確保只提交一次 handleTimeoutSubmit() return 0 } @@ -102,7 +127,7 @@ export default function CombinedTestPage() { }, 1000) return () => clearInterval(timer) - }, [logicQuestions, creativeQuestions, hasShownWarning]) + }, [logicQuestions, creativeQuestions, hasShownWarning, hasTimedOut, timeoutSubmitted]) const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60) @@ -164,17 +189,33 @@ export default function CombinedTestPage() { } const handleTimeoutSubmit = async () => { + // 防止重複提交 - 多重檢查 + if (isSubmitting || hasTimedOut || timeoutSubmitted) { + console.log('⏰ 已經在處理時間到提交,跳過重複請求') + return + } + + // 再次檢查 localStorage + const alreadySubmitted = localStorage.getItem('combinedTestTimeoutSubmitted') + if (alreadySubmitted) { + console.log('⏰ localStorage 顯示已經提交過,跳過重複請求') + return + } + console.log('⏰ 時間到!強制提交綜合測試...') console.log('用戶狀態:', user) if (!user) { console.log('❌ 用戶未登入') - alert('⏰ 時間到!但用戶未登入,無法提交結果。') + setModalMessage('時間到!但用戶未登入,無法提交結果。') + setShowTimeoutModal(true) return } console.log('✅ 用戶已登入,用戶ID:', user.id) setIsSubmitting(true) + setHasTimedOut(true) + setTimeoutSubmitted(true) try { // Calculate logic score @@ -267,12 +308,18 @@ export default function CombinedTestPage() { } // 顯示時間到提示 - alert('⏰ 測試時間已到!系統已自動提交您的答案。') - router.push("/results/combined") + setModalMessage('測試時間已到!系統已自動提交您的答案。') + setShowTimeoutModal(true) + + // 延遲跳轉,讓用戶看到提示 + setTimeout(() => { + router.push("/results/combined") + }, 3000) } catch (error) { console.error('❌ 強制提交測驗失敗:', error) - alert('⏰ 時間到!但提交失敗,請聯繫管理員。') + setModalMessage('時間到!但提交失敗,請聯繫管理員。') + setShowTimeoutModal(true) } finally { setIsSubmitting(false) } @@ -608,6 +655,37 @@ export default function CombinedTestPage() { {Object.keys(phase === "logic" ? logicAnswers : creativeAnswers).length} / {currentQuestions.length} 題) + + {/* 時間警告彈窗 */} + setShowWarningModal(false)} + type="warning" + title="⚠️ 時間提醒" + message={modalMessage} + showCountdown={false} + /> + + {/* 時間到彈窗 */} + setShowTimeoutModal(false)} + type="timeout" + title="⏰ 時間到" + message={modalMessage} + showCountdown={true} + countdownSeconds={3} + /> + + {/* 成功提交彈窗 */} + setShowSuccessModal(false)} + type="success" + title="✅ 提交成功" + message={modalMessage} + showCountdown={false} + /> ) } diff --git a/components/combined-analysis.tsx b/components/combined-analysis.tsx index fea67ba..ea16231 100644 --- a/components/combined-analysis.tsx +++ b/components/combined-analysis.tsx @@ -12,6 +12,7 @@ interface CombinedAnalysisProps { balanceScore: number level: string description: string + isTimeout?: boolean logicBreakdown?: any creativityBreakdown?: any // 個別測試結果的詳細資料 @@ -34,6 +35,7 @@ export function CombinedAnalysis({ balanceScore, level, description, + isTimeout = false, logicBreakdown, creativityBreakdown, // 個別測試結果的詳細資料 @@ -120,11 +122,18 @@ export function CombinedAnalysis({ > {overallScore} - 綜合評估完成! + + 綜合測試{isTimeout ? '(時間到)' : ''}完成! +
{level} + {isTimeout && ( + + 時間到強制提交 + + )}

{description}

diff --git a/components/time-warning-modal.tsx b/components/time-warning-modal.tsx new file mode 100644 index 0000000..cb44f58 --- /dev/null +++ b/components/time-warning-modal.tsx @@ -0,0 +1,124 @@ +"use client" + +import { useEffect, useState } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { AlertTriangle, Clock, CheckCircle } from "lucide-react" + +interface TimeWarningModalProps { + isOpen: boolean + onClose: () => void + type: 'warning' | 'timeout' | 'success' + title: string + message: string + showCountdown?: boolean + countdownSeconds?: number +} + +export function TimeWarningModal({ + isOpen, + onClose, + type, + title, + message, + showCountdown = false, + countdownSeconds = 0 +}: TimeWarningModalProps) { + const [remainingSeconds, setRemainingSeconds] = useState(countdownSeconds) + + useEffect(() => { + if (isOpen && showCountdown && remainingSeconds > 0) { + const timer = setInterval(() => { + setRemainingSeconds(prev => { + if (prev <= 1) { + clearInterval(timer) + onClose() + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + } + }, [isOpen, showCountdown, remainingSeconds, onClose]) + + useEffect(() => { + if (isOpen) { + setRemainingSeconds(countdownSeconds) + } + }, [isOpen, countdownSeconds]) + + const getIcon = () => { + switch (type) { + case 'warning': + return + case 'timeout': + return + case 'success': + return + default: + return + } + } + + const getBgColor = () => { + switch (type) { + case 'warning': + return 'bg-yellow-50 border-yellow-200' + case 'timeout': + return 'bg-red-50 border-red-200' + case 'success': + return 'bg-green-50 border-green-200' + default: + return 'bg-yellow-50 border-yellow-200' + } + } + + return ( + + + +
+
+ {getIcon()} +
+
+ + {title} + +
+ +
+

+ {message} +

+ + {showCountdown && remainingSeconds > 0 && ( +
+
+ {remainingSeconds} +
+ 秒後自動關閉 +
+ )} + +
+ +
+
+
+
+ ) +} diff --git a/database-migrations/add-timeout-columns.sql b/database-migrations/add-timeout-columns.sql new file mode 100644 index 0000000..1a8df0d --- /dev/null +++ b/database-migrations/add-timeout-columns.sql @@ -0,0 +1,46 @@ +-- 添加 is_timeout 欄位到現有資料庫表 +-- 執行時間: 2024-01-XX +-- 描述: 為測試結果表添加時間到標記欄位 + +USE hr_assessment; + +-- 檢查並添加 test_results 表的 is_timeout 欄位 +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'test_results' + AND COLUMN_NAME = 'is_timeout' +); + +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE test_results ADD COLUMN is_timeout BOOLEAN DEFAULT FALSE', + 'SELECT "test_results 表的 is_timeout 欄位已存在,跳過" as message' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 檢查並添加 combined_test_results 表的 is_timeout 欄位 +SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'combined_test_results' + AND COLUMN_NAME = 'is_timeout' +); + +SET @sql = IF(@column_exists = 0, + 'ALTER TABLE combined_test_results ADD COLUMN is_timeout BOOLEAN DEFAULT FALSE', + 'SELECT "combined_test_results 表的 is_timeout 欄位已存在,跳過" as message' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 驗證欄位是否添加成功 +DESCRIBE test_results; +DESCRIBE combined_test_results; + +-- 顯示成功訊息 +SELECT 'is_timeout 欄位添加完成!' AS message; diff --git a/lib/database/init.ts b/lib/database/init.ts index 2d4b53c..147a7f8 100644 --- a/lib/database/init.ts +++ b/lib/database/init.ts @@ -6,6 +6,7 @@ import { createTestResultsTable } from './models/test_result' import { createLogicTestAnswersTable } from './models/logic_test_answer' import { createCreativeTestAnswersTable } from './models/creative_test_answer' import { createCombinedTestResultsTable } from './models/combined_test_result' +import { addTimeoutColumns } from './migrations/add-timeout-columns' // 初始化資料庫 export async function initializeDatabase(): Promise { @@ -40,6 +41,9 @@ export async function initializeDatabase(): Promise { // 建立綜合測試結果表 await createCombinedTestResultsTable() + // 執行資料庫遷移(添加 is_timeout 欄位) + await addTimeoutColumns() + console.log('✅ 資料庫初始化完成') return true } catch (error) { diff --git a/lib/database/migrations/add-timeout-columns.ts b/lib/database/migrations/add-timeout-columns.ts new file mode 100644 index 0000000..c1bfeab --- /dev/null +++ b/lib/database/migrations/add-timeout-columns.ts @@ -0,0 +1,71 @@ +import { executeQuery } from '../connection' + +// 添加 is_timeout 欄位到現有資料庫表 +export async function addTimeoutColumns(): Promise { + try { + console.log('🔄 開始添加 is_timeout 欄位...') + + // 檢查並添加 test_results 表的 is_timeout 欄位 + try { + // 先檢查欄位是否已存在 + const checkColumnQuery = ` + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'test_results' + AND COLUMN_NAME = 'is_timeout' + ` + const result = await executeQuery(checkColumnQuery) + const columnExists = result[0]?.count > 0 + + if (!columnExists) { + await executeQuery(` + ALTER TABLE test_results + ADD COLUMN is_timeout BOOLEAN DEFAULT FALSE + `) + console.log('✅ test_results 表已添加 is_timeout 欄位') + } else { + console.log('ℹ️ test_results 表的 is_timeout 欄位已存在,跳過') + } + } catch (error: any) { + console.error('❌ 檢查/添加 test_results 表 is_timeout 欄位失敗:', error.message) + } + + // 檢查並添加 combined_test_results 表的 is_timeout 欄位 + try { + // 先檢查欄位是否已存在 + const checkColumnQuery = ` + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'combined_test_results' + AND COLUMN_NAME = 'is_timeout' + ` + const result = await executeQuery(checkColumnQuery) + const columnExists = result[0]?.count > 0 + + if (!columnExists) { + await executeQuery(` + ALTER TABLE combined_test_results + ADD COLUMN is_timeout BOOLEAN DEFAULT FALSE + `) + console.log('✅ combined_test_results 表已添加 is_timeout 欄位') + } else { + console.log('ℹ️ combined_test_results 表的 is_timeout 欄位已存在,跳過') + } + } catch (error: any) { + console.error('❌ 檢查/添加 combined_test_results 表 is_timeout 欄位失敗:', error.message) + } + + console.log('✅ is_timeout 欄位添加完成') + } catch (error) { + console.error('❌ 添加 is_timeout 欄位失敗:', error) + throw error + } +} + +// 執行遷移 +if (typeof window === 'undefined') { + // 只在伺服器端執行 + addTimeoutColumns().catch(console.error) +} diff --git a/scripts/add-timeout-columns.js b/scripts/add-timeout-columns.js new file mode 100644 index 0000000..b216162 --- /dev/null +++ b/scripts/add-timeout-columns.js @@ -0,0 +1,86 @@ +const mysql = require('mysql2/promise'); + +// 資料庫配置 +const dbConfig = { + host: 'localhost', + user: 'root', + password: '123456', + database: 'hr_assessment', + port: 3306 +}; + +async function addTimeoutColumns() { + let connection; + + try { + console.log('🔄 連接到資料庫...'); + connection = await mysql.createConnection(dbConfig); + + console.log('✅ 資料庫連接成功'); + console.log('🔄 開始添加 is_timeout 欄位...'); + + // 檢查並添加 test_results 表的 is_timeout 欄位 + try { + // 先檢查欄位是否已存在 + const [checkResult] = await connection.execute(` + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'test_results' + AND COLUMN_NAME = 'is_timeout' + `); + const columnExists = checkResult[0].count > 0; + + if (!columnExists) { + await connection.execute(` + ALTER TABLE test_results + ADD COLUMN is_timeout BOOLEAN DEFAULT FALSE + `); + console.log('✅ test_results 表已添加 is_timeout 欄位'); + } else { + console.log('ℹ️ test_results 表的 is_timeout 欄位已存在,跳過'); + } + } catch (error) { + console.error('❌ 檢查/添加 test_results 表 is_timeout 欄位失敗:', error.message); + } + + // 檢查並添加 combined_test_results 表的 is_timeout 欄位 + try { + // 先檢查欄位是否已存在 + const [checkResult] = await connection.execute(` + SELECT COUNT(*) as count + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'combined_test_results' + AND COLUMN_NAME = 'is_timeout' + `); + const columnExists = checkResult[0].count > 0; + + if (!columnExists) { + await connection.execute(` + ALTER TABLE combined_test_results + ADD COLUMN is_timeout BOOLEAN DEFAULT FALSE + `); + console.log('✅ combined_test_results 表已添加 is_timeout 欄位'); + } else { + console.log('ℹ️ combined_test_results 表的 is_timeout 欄位已存在,跳過'); + } + } catch (error) { + console.error('❌ 檢查/添加 combined_test_results 表 is_timeout 欄位失敗:', error.message); + } + + console.log('✅ is_timeout 欄位添加完成'); + + } catch (error) { + console.error('❌ 執行失敗:', error); + process.exit(1); + } finally { + if (connection) { + await connection.end(); + console.log('📝 資料庫連接已關閉'); + } + } +} + +// 執行遷移 +addTimeoutColumns();