dramaling-vocab-learning/frontend/components/review/TestStatusIndicator.tsx

323 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { memo } from 'react'
import { TestItem } from '@/store/useTestQueueStore'
/**
* 測驗狀態指示器
* 根據PRD US-009要求實現視覺化狀態顯示
* ✅ 已答對(綠色)- 已從當日清單移除
* ❌ 已答錯(紅色)- 移到隊列最後
* ⏭️ 已跳過(黃色)- 移到隊列最後
* ⚪ 未完成(灰色)- 優先處理
*/
export interface TestStatusIndicatorProps {
test: TestItem
showDetails?: boolean
size?: 'sm' | 'md' | 'lg'
className?: string
}
export const TestStatusIndicator: React.FC<TestStatusIndicatorProps> = memo(({
test,
showDetails = false,
size = 'md',
className = ''
}) => {
// 獲取狀態配置
const getStatusConfig = () => {
if (test.isCompleted) {
return {
icon: '✅',
label: '已答對',
bgColor: 'bg-green-100',
textColor: 'text-green-800',
borderColor: 'border-green-300',
description: '已從當日清單移除'
}
}
if (test.isIncorrect) {
return {
icon: '❌',
label: '已答錯',
bgColor: 'bg-red-100',
textColor: 'text-red-800',
borderColor: 'border-red-300',
description: '移到隊列最後重複練習'
}
}
if (test.isSkipped) {
return {
icon: '⏭️',
label: '已跳過',
bgColor: 'bg-yellow-100',
textColor: 'text-yellow-800',
borderColor: 'border-yellow-300',
description: '移到隊列最後稍後處理'
}
}
if (test.isCurrent) {
return {
icon: '▶️',
label: '進行中',
bgColor: 'bg-blue-100',
textColor: 'text-blue-800',
borderColor: 'border-blue-300',
description: '當前正在進行的測驗'
}
}
return {
icon: '⚪',
label: '未完成',
bgColor: 'bg-gray-100',
textColor: 'text-gray-800',
borderColor: 'border-gray-300',
description: '優先處理的測驗'
}
}
// 獲取尺寸樣式
const getSizeClasses = () => {
switch (size) {
case 'sm':
return {
container: 'px-2 py-1 text-xs',
icon: 'text-sm',
text: 'text-xs'
}
case 'lg':
return {
container: 'px-4 py-3 text-base',
icon: 'text-lg',
text: 'text-base'
}
default: // md
return {
container: 'px-3 py-2 text-sm',
icon: 'text-base',
text: 'text-sm'
}
}
}
const statusConfig = getStatusConfig()
const sizeClasses = getSizeClasses()
return (
<div
className={`
inline-flex items-center gap-2 rounded-lg border
${statusConfig.bgColor}
${statusConfig.textColor}
${statusConfig.borderColor}
${sizeClasses.container}
${className}
`}
title={showDetails ? statusConfig.description : statusConfig.label}
>
{/* 狀態圖示 */}
<span className={sizeClasses.icon}>
{statusConfig.icon}
</span>
{/* 狀態文字 */}
<span className={`font-medium ${sizeClasses.text}`}>
{statusConfig.label}
</span>
{/* 優先級顯示(開發模式) */}
{process.env.NODE_ENV === 'development' && (
<span className="text-xs opacity-60">
P{test.priority}
</span>
)}
{/* 詳細資訊 */}
{showDetails && (
<div className="ml-2 text-xs opacity-80">
({test.testName})
</div>
)}
</div>
)
})
TestStatusIndicator.displayName = 'TestStatusIndicator'
/**
* 測驗狀態統計元件
*/
interface TestStatsProps {
stats: {
total: number
completed: number
skipped: number
incorrect: number
remaining: number
}
className?: string
}
export const TestStats: React.FC<TestStatsProps> = memo(({
stats,
className = ''
}) => {
const items = [
{ key: 'completed', label: '已完成', count: stats.completed, color: 'text-green-600', icon: '✅' },
{ key: 'incorrect', label: '需重做', count: stats.incorrect, color: 'text-red-600', icon: '❌' },
{ key: 'skipped', label: '已跳過', count: stats.skipped, color: 'text-yellow-600', icon: '⏭️' },
{ key: 'remaining', label: '剩餘', count: stats.remaining, color: 'text-gray-600', icon: '⚪' }
]
return (
<div className={`flex flex-wrap gap-4 ${className}`}>
{items.map(({ key, label, count, color, icon }) => (
<div key={key} className="flex items-center gap-1 text-sm">
<span>{icon}</span>
<span className={`font-medium ${color}`}>{count}</span>
<span className="text-gray-600">{label}</span>
</div>
))}
</div>
)
})
TestStats.displayName = 'TestStats'
/**
* 測驗狀態進度條
*/
interface TestProgressBarProps {
stats: {
total: number
completed: number
skipped: number
incorrect: number
remaining: number
}
className?: string
}
export const TestProgressBar: React.FC<TestProgressBarProps> = memo(({
stats,
className = ''
}) => {
const { total, completed, skipped, incorrect, remaining } = stats
if (total === 0) return null
const completedPercent = (completed / total) * 100
const incorrectPercent = (incorrect / total) * 100
const skippedPercent = (skipped / total) * 100
return (
<div className={`space-y-2 ${className}`}>
{/* 進度條 */}
<div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full flex">
{/* 已完成 */}
{completed > 0 && (
<div
className="bg-green-500 transition-all duration-300"
style={{ width: `${completedPercent}%` }}
title={`已完成: ${completed}/${total}`}
/>
)}
{/* 答錯 */}
{incorrect > 0 && (
<div
className="bg-red-500 transition-all duration-300"
style={{ width: `${incorrectPercent}%` }}
title={`需重做: ${incorrect}/${total}`}
/>
)}
{/* 跳過 */}
{skipped > 0 && (
<div
className="bg-yellow-500 transition-all duration-300"
style={{ width: `${skippedPercent}%` }}
title={`已跳過: ${skipped}/${total}`}
/>
)}
</div>
</div>
{/* 數據統計 */}
<div className="flex justify-between text-sm text-gray-600">
<span>: {completed}/{total}</span>
<span>{((completed / total) * 100).toFixed(0)}%</span>
</div>
</div>
)
})
TestProgressBar.displayName = 'TestProgressBar'
/**
* 測驗狀態列表
*/
interface TestStatusListProps {
tests: TestItem[]
groupByCard?: boolean
className?: string
}
export const TestStatusList: React.FC<TestStatusListProps> = memo(({
tests,
groupByCard = true,
className = ''
}) => {
if (groupByCard) {
// 按詞卡分組
const groupedTests = tests.reduce((acc, test) => {
if (!acc[test.cardId]) {
acc[test.cardId] = {
word: test.word,
tests: []
}
}
acc[test.cardId].tests.push(test)
return acc
}, {} as Record<string, { word: string; tests: TestItem[] }>)
return (
<div className={`space-y-4 ${className}`}>
{Object.entries(groupedTests).map(([cardId, { word, tests }]) => (
<div key={cardId} className="border rounded-lg p-4">
<h4 className="font-semibold text-gray-900 mb-3">{word}</h4>
<div className="flex flex-wrap gap-2">
{tests.map(test => (
<TestStatusIndicator
key={test.id}
test={test}
showDetails
size="sm"
/>
))}
</div>
</div>
))}
</div>
)
}
// 平面列表
return (
<div className={`space-y-2 ${className}`}>
{tests.map(test => (
<div key={test.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<span className="font-medium text-gray-900">{test.word}</span>
<span className="text-sm text-gray-600">{test.testName}</span>
</div>
<TestStatusIndicator test={test} size="sm" />
</div>
))}
</div>
)
})
TestStatusList.displayName = 'TestStatusList'