Files
ai-scoring-application/app/criteria/page.tsx

429 lines
14 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.

"use client"
import { useState, useEffect } from "react"
import { Sidebar } from "@/components/sidebar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Slider } from "@/components/ui/slider"
import { Badge } from "@/components/ui/badge"
import { Plus, Trash2, Save, RotateCcw, FileText, Loader2 } from "lucide-react"
import { useToast } from "@/hooks/use-toast"
interface CriteriaItem {
id: number | string
name: string
description: string
weight: number
maxScore: number
}
interface CriteriaTemplate {
id: number
name: string
description: string
total_weight: number
items: CriteriaItem[]
}
export default function CriteriaPage() {
const [criteria, setCriteria] = useState<CriteriaItem[]>([])
const [templateName, setTemplateName] = useState("")
const [templateDescription, setTemplateDescription] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [currentTemplate, setCurrentTemplate] = useState<CriteriaTemplate | null>(null)
const { toast } = useToast()
// 載入評分標準
useEffect(() => {
loadTemplate()
}, [])
const loadTemplate = async () => {
try {
setIsLoading(true)
const response = await fetch('/api/criteria-templates')
const result = await response.json()
if (result.success && result.data && result.data.length > 0) {
// 載入第一個模板(單一模板模式)
const template = result.data[0]
setCurrentTemplate(template)
setTemplateName(template.name)
setTemplateDescription(template.description || '')
setCriteria(template.items || [])
} else {
// 如果沒有模板,載入預設模板
await loadDefaultTemplate()
}
} catch (error) {
console.error('載入評分標準失敗:', error)
// 如果載入失敗,嘗試載入預設模板
await loadDefaultTemplate()
} finally {
setIsLoading(false)
}
}
const loadDefaultTemplate = async () => {
try {
const response = await fetch('/api/criteria-templates/default')
const result = await response.json()
if (result.success && result.data) {
const template = result.data
setCurrentTemplate(template)
setTemplateName(template.name)
setTemplateDescription(template.description || '')
setCriteria(template.items || [])
} else {
// 如果連預設模板都沒有,使用空模板
setCurrentTemplate(null)
setTemplateName('')
setTemplateDescription('')
setCriteria([])
}
} catch (error) {
console.error('載入預設評分標準失敗:', error)
// 使用空模板
setCurrentTemplate(null)
setTemplateName('')
setTemplateDescription('')
setCriteria([])
}
}
const addCriteria = () => {
const newCriteria: CriteriaItem = {
id: Date.now().toString(),
name: "",
description: "",
weight: 10,
maxScore: 10,
}
setCriteria([...criteria, newCriteria])
}
const removeCriteria = (id: string) => {
setCriteria(criteria.filter((item) => item.id !== id))
}
const updateCriteria = (id: string, field: keyof CriteriaItem, value: string | number) => {
setCriteria(criteria.map((item) => (item.id === id ? { ...item, [field]: value } : item)))
}
const updateWeight = (id: string, weight: number[]) => {
updateCriteria(id, "weight", Number(weight[0]) || 0)
}
const totalWeight = criteria.reduce((sum, item) => sum + (Number(item.weight) || 0), 0)
const saveCriteria = async () => {
if (totalWeight !== 100) {
toast({
title: "權重設定錯誤",
description: "所有評分項目的權重總和必須等於 100%",
variant: "destructive",
})
return
}
if (criteria.some((item) => !item.name.trim())) {
toast({
title: "設定不完整",
description: "請填寫所有評分項目的名稱",
variant: "destructive",
})
return
}
if (!templateName.trim()) {
toast({
title: "設定不完整",
description: "請填寫模板名稱",
variant: "destructive",
})
return
}
try {
setIsSaving(true)
const templateData = {
name: templateName.trim(),
description: templateDescription.trim(),
items: criteria.map(item => ({
name: item.name.trim(),
description: item.description.trim(),
weight: item.weight,
maxScore: item.maxScore
}))
}
// 單一模板模式,總是使用 POST 進行覆蓋
const url = '/api/criteria-templates'
const method = 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(templateData),
})
const result = await response.json()
if (result.success) {
toast({
title: "儲存成功",
description: "評分標準已成功儲存",
})
// 重新載入資料
await loadTemplate()
} else {
throw new Error(result.error || '儲存失敗')
}
} catch (error) {
console.error('儲存評分標準失敗:', error)
toast({
title: "儲存失敗",
description: error instanceof Error ? error.message : "無法儲存評分標準",
variant: "destructive",
})
} finally {
setIsSaving(false)
}
}
const resetToDefault = async () => {
try {
await loadDefaultTemplate()
toast({
title: "已重置",
description: "評分標準已重置為預設值",
})
} catch (error) {
toast({
title: "重置失敗",
description: "無法重置評分標準",
variant: "destructive",
})
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-background">
<Sidebar />
<main className="md:ml-64 p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-muted-foreground">...</p>
</div>
</div>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-background">
<Sidebar />
<main className="md:ml-64 p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8 pt-8 md:pt-0">
<h1 className="text-3xl font-bold text-foreground mb-2 font-[var(--font-playfair)]"></h1>
<p className="text-muted-foreground"></p>
</div>
{/* Template Name */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="template-name" className="mb-2 block"> *</Label>
<Input
id="template-name"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="輸入評分標準名稱"
/>
</div>
<div>
<Label htmlFor="template-description" className="mb-2 block"></Label>
<Textarea
id="template-description"
value={templateDescription}
onChange={(e) => setTemplateDescription(e.target.value)}
placeholder="輸入模板描述(選填)"
rows={2}
/>
</div>
</CardContent>
</Card>
{/* Weight Summary */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium"></span>
<Badge variant={totalWeight === 100 ? "default" : "destructive"}>
{Number(totalWeight).toFixed(1)}%
</Badge>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${totalWeight === 100 ? "bg-primary" : "bg-destructive"}`}
style={{ width: `${Math.min(totalWeight, 100)}%` }}
/>
</div>
{totalWeight !== 100 && <p className="text-sm text-destructive mt-2"> 100%</p>}
</CardContent>
</Card>
{/* Criteria List */}
<div className="space-y-4 mb-6">
{criteria.map((item, index) => (
<Card key={item.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg"> {index + 1}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => removeCriteria(item.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor={`name-${item.id}`} className="mb-2 block"></Label>
<Input
id={`name-${item.id}`}
value={item.name}
onChange={(e) => updateCriteria(item.id, "name", e.target.value)}
placeholder="例如:內容品質"
/>
</div>
<div>
<Label htmlFor={`maxScore-${item.id}`} className="mb-2 block">滿</Label>
<Input
id={`maxScore-${item.id}`}
type="number"
value={item.maxScore}
onChange={(e) => updateCriteria(item.id, "maxScore", Number.parseInt(e.target.value) || 10)}
min="1"
max="100"
/>
</div>
</div>
<div>
<Label htmlFor={`description-${item.id}`} className="mb-2 block"></Label>
<Textarea
id={`description-${item.id}`}
value={item.description}
onChange={(e) => updateCriteria(item.id, "description", e.target.value)}
placeholder="描述此項目的評分標準和要求"
rows={2}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label></Label>
<Badge variant="outline">{Number(item.weight).toFixed(1)}%</Badge>
</div>
<Slider
value={[Number(item.weight) || 0]}
onValueChange={(value) => updateWeight(item.id, value)}
max={100}
min={0}
step={5}
className="w-full"
/>
</div>
</CardContent>
</Card>
))}
</div>
{/* Add Criteria Button */}
<Card className="mb-6">
<CardContent className="pt-6">
<Button onClick={addCriteria} variant="outline" className="w-full bg-transparent">
<Plus className="h-4 w-4 mr-2" />
</Button>
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4">
<Button onClick={saveCriteria} disabled={isSaving || isLoading} className="flex-1">
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
</>
)}
</Button>
<Button onClick={resetToDefault} variant="outline" disabled={isLoading || isSaving}>
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
</div>
{/* Preview */}
<Card className="mt-8">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{criteria.map((item, index) => (
<div key={item.id} className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex-1">
<div className="font-medium">{item.name || `評分項目 ${index + 1}`}</div>
{item.description && <div className="text-sm text-muted-foreground mt-1">{item.description}</div>}
</div>
<div className="text-right">
<div className="font-medium">: {Number(item.weight).toFixed(1)}%</div>
<div className="text-sm text-muted-foreground">滿: {item.maxScore}</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</main>
</div>
)
}