修正時間到上船數據問題

This commit is contained in:
2025-10-12 01:52:25 +08:00
parent cf40e937a1
commit 6f7ed92a8a
14 changed files with 528 additions and 41 deletions

View File

@@ -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 分鐘時會彈出提醒
- 時間到後自動提交並標記為時間到
- 管理員可以在結果頁面看到時間到標記

View File

@@ -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}
// 個別測試結果的詳細資料

View File

@@ -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
}
// 獲取詳細答案

View File

@@ -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)

View File

@@ -136,16 +136,6 @@ export default function CombinedResultsPage() {
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full">
<Link href="/tests/logic">
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full">
<Link href="/tests/creative">
</Link>
</Button>
</div>
</CardContent>
</Card>

View File

@@ -114,16 +114,6 @@ export default function CreativeResultsPage() {
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full">
<Link href="/tests/logic">
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full">
<Link href="/tests/combined">
</Link>
</Button>
</div>
</CardContent>
</Card>

View File

@@ -147,16 +147,6 @@ export default function LogicResultsPage() {
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full">
<Link href="/tests/creative">
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="w-full">
<Link href="/tests/combined">
</Link>
</Button>
</div>
</CardContent>
</Card>

View File

@@ -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<CreativeQuestion[]>([])
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('測試時間已到!系統已自動提交您的答案。')
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} )
</div>
</div>
{/* 時間警告彈窗 */}
<TimeWarningModal
isOpen={showWarningModal}
onClose={() => setShowWarningModal(false)}
type="warning"
title="⚠️ 時間提醒"
message={modalMessage}
showCountdown={false}
/>
{/* 時間到彈窗 */}
<TimeWarningModal
isOpen={showTimeoutModal}
onClose={() => setShowTimeoutModal(false)}
type="timeout"
title="⏰ 時間到"
message={modalMessage}
showCountdown={true}
countdownSeconds={3}
/>
{/* 成功提交彈窗 */}
<TimeWarningModal
isOpen={showSuccessModal}
onClose={() => setShowSuccessModal(false)}
type="success"
title="✅ 提交成功"
message={modalMessage}
showCountdown={false}
/>
</TestLayout>
)
}

View File

@@ -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({
>
<span className="text-3xl font-bold text-white">{overallScore}</span>
</div>
<CardTitle className="text-3xl mb-2"></CardTitle>
<CardTitle className="text-3xl mb-2">
{isTimeout ? '(時間到)' : ''}
</CardTitle>
<div className="flex items-center justify-center gap-2 mb-4">
<Badge variant="secondary" className="text-lg px-4 py-1 bg-gradient-to-r from-blue-500 to-teal-500 text-white">
{level}
</Badge>
{isTimeout && (
<Badge variant="destructive" className="text-lg px-4 py-1">
</Badge>
)}
</div>
<p className="text-lg text-muted-foreground mb-3">{description}</p>
</CardHeader>

View File

@@ -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 <AlertTriangle className="w-8 h-8 text-yellow-500" />
case 'timeout':
return <Clock className="w-8 h-8 text-red-500" />
case 'success':
return <CheckCircle className="w-8 h-8 text-green-500" />
default:
return <AlertTriangle className="w-8 h-8 text-yellow-500" />
}
}
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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className={`sm:max-w-md ${getBgColor()}`}>
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="p-3 rounded-full bg-white shadow-lg">
{getIcon()}
</div>
</div>
<DialogTitle className="text-center text-xl font-bold">
{title}
</DialogTitle>
</DialogHeader>
<div className="text-center space-y-4">
<p className="text-gray-700 leading-relaxed">
{message}
</p>
{showCountdown && remainingSeconds > 0 && (
<div className="flex items-center justify-center space-x-2">
<div className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center font-bold">
{remainingSeconds}
</div>
<span className="text-sm text-gray-600"></span>
</div>
)}
<div className="flex justify-center space-x-3 pt-4">
<Button
onClick={onClose}
className={`px-6 ${
type === 'warning'
? 'bg-yellow-500 hover:bg-yellow-600 text-white'
: type === 'timeout'
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-green-500 hover:bg-green-600 text-white'
}`}
>
{type === 'warning' ? '我知道了' : type === 'timeout' ? '確認' : '好的'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -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;

View File

@@ -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<boolean> {
@@ -40,6 +41,9 @@ export async function initializeDatabase(): Promise<boolean> {
// 建立綜合測試結果表
await createCombinedTestResultsTable()
// 執行資料庫遷移(添加 is_timeout 欄位)
await addTimeoutColumns()
console.log('✅ 資料庫初始化完成')
return true
} catch (error) {

View File

@@ -0,0 +1,71 @@
import { executeQuery } from '../connection'
// 添加 is_timeout 欄位到現有資料庫表
export async function addTimeoutColumns(): Promise<void> {
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)
}

View File

@@ -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();