323 lines
8.6 KiB
TypeScript
323 lines
8.6 KiB
TypeScript
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' |