新增評分項目設定、資料庫整合

This commit is contained in:
2025-09-22 00:33:12 +08:00
parent 8de09129be
commit 9d4c586ad3
20 changed files with 2321 additions and 79 deletions

View File

@@ -0,0 +1,166 @@
import { NextRequest, NextResponse } from 'next/server';
import { CriteriaTemplateService, CriteriaItemService } from '@/lib/services/database';
// GET - 獲取特定評分標準模板
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const templateId = parseInt(params.id);
if (isNaN(templateId)) {
return NextResponse.json(
{ success: false, error: '無效的模板 ID' },
{ status: 400 }
);
}
const template = await CriteriaTemplateService.findWithItems(templateId);
if (!template) {
return NextResponse.json(
{ success: false, error: '找不到指定的評分標準模板' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: template
});
} catch (error) {
console.error('獲取評分標準模板失敗:', error);
return NextResponse.json(
{ success: false, error: '獲取評分標準模板失敗' },
{ status: 500 }
);
}
}
// PUT - 更新評分標準模板
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const templateId = parseInt(params.id);
if (isNaN(templateId)) {
return NextResponse.json(
{ success: false, error: '無效的模板 ID' },
{ status: 400 }
);
}
const body = await request.json();
const { name, description, items } = body;
if (!name || !items || !Array.isArray(items)) {
return NextResponse.json(
{ success: false, error: '請提供模板名稱和評分項目' },
{ status: 400 }
);
}
// 驗證權重總和
const totalWeight = items.reduce((sum: number, item: any) => sum + (item.weight || 0), 0);
if (Math.abs(totalWeight - 100) > 0.01) {
return NextResponse.json(
{ success: false, error: '權重總和必須等於 100%' },
{ status: 400 }
);
}
// 驗證所有項目都有名稱
if (items.some((item: any) => !item.name?.trim())) {
return NextResponse.json(
{ success: false, error: '所有評分項目都必須有名稱' },
{ status: 400 }
);
}
// 使用事務更新模板和項目
const { transaction } = await import('@/lib/database');
await transaction(async (connection) => {
// 更新模板
await connection.execute(
'UPDATE criteria_templates SET name = ?, description = ?, total_weight = ?, updated_at = NOW() WHERE id = ?',
[name, description || '', totalWeight, templateId]
);
// 刪除舊的評分項目
await connection.execute(
'DELETE FROM criteria_items WHERE template_id = ?',
[templateId]
);
// 創建新的評分項目
for (let i = 0; i < items.length; i++) {
const item = items[i];
await connection.execute(
'INSERT INTO criteria_items (template_id, name, description, weight, max_score, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
[
templateId,
item.name,
item.description || '',
item.weight,
item.maxScore || 10,
i + 1
]
);
}
});
return NextResponse.json({
success: true,
message: '評分標準模板更新成功'
});
} catch (error) {
console.error('更新評分標準模板失敗:', error);
return NextResponse.json(
{ success: false, error: '更新評分標準模板失敗' },
{ status: 500 }
);
}
}
// DELETE - 刪除評分標準模板
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const templateId = parseInt(params.id);
if (isNaN(templateId)) {
return NextResponse.json(
{ success: false, error: '無效的模板 ID' },
{ status: 400 }
);
}
// 檢查是否為預設模板
const template = await CriteriaTemplateService.findById(templateId);
if (template?.is_default) {
return NextResponse.json(
{ success: false, error: '無法刪除預設模板' },
{ status: 400 }
);
}
await CriteriaTemplateService.delete(templateId);
return NextResponse.json({
success: true,
message: '評分標準模板刪除成功'
});
} catch (error) {
console.error('刪除評分標準模板失敗:', error);
return NextResponse.json(
{ success: false, error: '刪除評分標準模板失敗' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { CriteriaTemplateService } from '@/lib/services/database';
// GET - 獲取預設評分標準模板
export async function GET() {
try {
const template = await CriteriaTemplateService.findDefault();
if (!template) {
return NextResponse.json(
{ success: false, error: '找不到預設評分標準模板' },
{ status: 404 }
);
}
const templateWithItems = await CriteriaTemplateService.findWithItems(template.id);
return NextResponse.json({
success: true,
data: templateWithItems
});
} catch (error) {
console.error('獲取預設評分標準模板失敗:', error);
return NextResponse.json(
{ success: false, error: '獲取預設評分標準模板失敗' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server';
import { CriteriaTemplateService, CriteriaItemService } from '@/lib/services/database';
// GET - 獲取所有評分標準模板
export async function GET() {
try {
// 這裡暫時使用 user_id = 1實際應用中應該從 session 獲取
const templates = await CriteriaTemplateService.findByUserId(1);
// 獲取每個模板的評分項目
const templatesWithItems = await Promise.all(
templates.map(async (template) => {
const items = await CriteriaItemService.findByTemplateId(template.id);
return { ...template, items };
})
);
return NextResponse.json({
success: true,
data: templatesWithItems
});
} catch (error) {
console.error('獲取評分標準模板失敗:', error);
return NextResponse.json(
{ success: false, error: '獲取評分標準模板失敗' },
{ status: 500 }
);
}
}
// POST - 創建或更新評分標準模板(單一模板模式)
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, description, items } = body;
if (!name || !items || !Array.isArray(items)) {
return NextResponse.json(
{ success: false, error: '請提供模板名稱和評分項目' },
{ status: 400 }
);
}
// 驗證權重總和
const totalWeight = items.reduce((sum: number, item: any) => sum + (Number(item.weight) || 0), 0);
if (Math.abs(totalWeight - 100) > 0.01) {
return NextResponse.json(
{ success: false, error: '權重總和必須等於 100%' },
{ status: 400 }
);
}
// 驗證所有項目都有名稱
if (items.some((item: any) => !item.name?.trim())) {
return NextResponse.json(
{ success: false, error: '所有評分項目都必須有名稱' },
{ status: 400 }
);
}
// 使用事務創建或更新模板
const { transaction } = await import('@/lib/database');
const result = await transaction(async (connection) => {
// 檢查是否已存在模板
const [existingTemplates] = await connection.execute(
'SELECT id FROM criteria_templates WHERE user_id = ? LIMIT 1',
[1]
);
let templateId;
if (existingTemplates.length > 0) {
// 更新現有模板
templateId = existingTemplates[0].id;
await connection.execute(
'UPDATE criteria_templates SET name = ?, description = ?, total_weight = ?, updated_at = NOW() WHERE id = ?',
[name, description || '', totalWeight, templateId]
);
// 刪除舊的評分項目
await connection.execute(
'DELETE FROM criteria_items WHERE template_id = ?',
[templateId]
);
} else {
// 創建新模板
const [templateResult] = await connection.execute(
'INSERT INTO criteria_templates (user_id, name, description, is_default, is_public, total_weight) VALUES (?, ?, ?, ?, ?, ?)',
[1, name, description || '', 1, 1, totalWeight]
);
templateId = (templateResult as any).insertId;
}
// 創建評分項目
for (let i = 0; i < items.length; i++) {
const item = items[i];
await connection.execute(
'INSERT INTO criteria_items (template_id, name, description, weight, max_score, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
[
templateId,
item.name,
item.description || '',
Number(item.weight) || 0,
Number(item.maxScore) || 10,
i + 1
]
);
}
return templateId;
});
return NextResponse.json({
success: true,
data: { id: result },
message: '評分標準模板儲存成功'
});
} catch (error) {
console.error('儲存評分標準模板失敗:', error);
return NextResponse.json(
{ success: false, error: '儲存評分標準模板失敗' },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
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"
@@ -9,60 +9,93 @@ 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 } from "lucide-react"
import { Plus, Trash2, Save, RotateCcw, FileText, Loader2 } from "lucide-react"
import { useToast } from "@/hooks/use-toast"
interface CriteriaItem {
id: string
id: number | string
name: string
description: string
weight: number
maxScore: number
}
const defaultCriteria: CriteriaItem[] = [
{
id: "1",
name: "內容品質",
description: "內容的準確性、完整性和專業度",
weight: 25,
maxScore: 10,
},
{
id: "2",
name: "視覺設計",
description: "版面設計、色彩搭配和視覺效果",
weight: 20,
maxScore: 10,
},
{
id: "3",
name: "邏輯結構",
description: "內容組織的邏輯性和條理性",
weight: 20,
maxScore: 10,
},
{
id: "4",
name: "創新性",
description: "創意思維和獨特觀點的展現",
weight: 15,
maxScore: 10,
},
{
id: "5",
name: "實用性",
description: "內容的實際應用價值和可操作性",
weight: 20,
maxScore: 10,
},
]
interface CriteriaTemplate {
id: number
name: string
description: string
total_weight: number
items: CriteriaItem[]
}
export default function CriteriaPage() {
const [criteria, setCriteria] = useState<CriteriaItem[]>(defaultCriteria)
const [templateName, setTemplateName] = useState("預設評分標準")
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(),
@@ -83,12 +116,12 @@ export default function CriteriaPage() {
}
const updateWeight = (id: string, weight: number[]) => {
updateCriteria(id, "weight", weight[0])
updateCriteria(id, "weight", Number(weight[0]) || 0)
}
const totalWeight = criteria.reduce((sum, item) => sum + item.weight, 0)
const totalWeight = criteria.reduce((sum, item) => sum + (Number(item.weight) || 0), 0)
const saveCriteria = () => {
const saveCriteria = async () => {
if (totalWeight !== 100) {
toast({
title: "權重設定錯誤",
@@ -107,20 +140,98 @@ export default function CriteriaPage() {
return
}
// 這裡會連接到後端 API 儲存評分標準
toast({
title: "儲存成功",
description: "評分標準已成功儲存",
})
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 = () => {
setCriteria(defaultCriteria)
setTemplateName("預設評分標準")
toast({
title: "已重置",
description: "評分標準已重置為預設值",
})
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 (
@@ -140,16 +251,29 @@ export default function CriteriaPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent>
<Input
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="輸入評分標準名稱"
className="max-w-md"
/>
<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>
@@ -158,7 +282,9 @@ export default function CriteriaPage() {
<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"}>{totalWeight}%</Badge>
<Badge variant={totalWeight === 100 ? "default" : "destructive"}>
{Number(totalWeight).toFixed(1)}%
</Badge>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
@@ -190,7 +316,7 @@ export default function CriteriaPage() {
<CardContent className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div>
<Label htmlFor={`name-${item.id}`}></Label>
<Label htmlFor={`name-${item.id}`} className="mb-2 block"></Label>
<Input
id={`name-${item.id}`}
value={item.name}
@@ -199,7 +325,7 @@ export default function CriteriaPage() {
/>
</div>
<div>
<Label htmlFor={`maxScore-${item.id}`}>滿</Label>
<Label htmlFor={`maxScore-${item.id}`} className="mb-2 block">滿</Label>
<Input
id={`maxScore-${item.id}`}
type="number"
@@ -212,7 +338,7 @@ export default function CriteriaPage() {
</div>
<div>
<Label htmlFor={`description-${item.id}`}></Label>
<Label htmlFor={`description-${item.id}`} className="mb-2 block"></Label>
<Textarea
id={`description-${item.id}`}
value={item.description}
@@ -225,10 +351,10 @@ export default function CriteriaPage() {
<div>
<div className="flex items-center justify-between mb-2">
<Label></Label>
<Badge variant="outline">{item.weight}%</Badge>
<Badge variant="outline">{Number(item.weight).toFixed(1)}%</Badge>
</div>
<Slider
value={[item.weight]}
value={[Number(item.weight) || 0]}
onValueChange={(value) => updateWeight(item.id, value)}
max={100}
min={0}
@@ -253,11 +379,20 @@ export default function CriteriaPage() {
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4">
<Button onClick={saveCriteria} className="flex-1">
<Save className="h-4 w-4 mr-2" />
<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">
<Button onClick={resetToDefault} variant="outline" disabled={isLoading || isSaving}>
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
@@ -278,7 +413,7 @@ export default function CriteriaPage() {
{item.description && <div className="text-sm text-muted-foreground mt-1">{item.description}</div>}
</div>
<div className="text-right">
<div className="font-medium">: {item.weight}%</div>
<div className="font-medium">: {Number(item.weight).toFixed(1)}%</div>
<div className="text-sm text-muted-foreground">滿: {item.maxScore}</div>
</div>
</div>