作品提交与评分系统:构建公平透明的学生创作评估平台
与传统考试不同,作品类作业需要学生提交文档、图片、视频甚至代码。评分也不再是简单的对错,而是多维度的综合评价。本文将讲解如何设计一个专业的作品提交与评分系统。
系统需求分析
核心场景
| 用户角色 | 主要操作 |
|---|---|
| 学生 | 上传作品、查看反馈、申诉成绩 |
| 教师 | 设置评分标准、评分、发布成绩 |
| 助教 | 协助批改、汇总成绩 |
| 管理员 | 监控异常、导出报表 |
功能模块
作品提交与评分系统
├── 任务发布模块
│ ├── 创建作品任务
│ ├── 设置提交要求
│ └── 配置评分规则
├── 作品提交模块
│ ├── 多媒体上传
│ ├── 在线编辑
│ └── 版本管理
├── 评分模块
│ ├── 评分维度设置
│ ├── 评分操作
│ └── 评语反馈
├── 成绩管理模块
│ ├── 成绩统计
│ ├── 排名分析
│ └── 成绩导出
└── 申诉模块
├── 学生申诉
└── 教师复核
数据模型设计
核心实体
// 作品任务
interface PortfolioTask {
id: string
courseId: string
title: string
description: string
requirements: string // 提交要求(Markdown)
allowedFileTypes: string[] // 允许的文件类型
maxFileSize: number // 最大文件大小 MB
maxFiles: number // 最多上传文件数
deadline: Date // 截止时间
lateSubmissionPolicy: {
allowed: boolean
penaltyPercentPerDay: number // 每天扣分百分比
}
rubric: RubricDimension[] // 评分规则
status: 'draft' | 'published' | 'closed'
createdBy: string
createdAt: Date
}
// 评分维度
interface RubricDimension {
id: string
name: string // 如"创意性"
description: string
maxScore: number // 该维度满分
weight: number // 权重百分比
levels: ScoreLevel[] // 评分等级描述
}
interface ScoreLevel {
score: number
label: string // 如"优秀"
description: string // 达到此等级的标准
}
// 学生提交
interface Submission {
id: string
taskId: string
studentId: string
files: SubmissionFile[]
textContent?: string // 在线编辑的文本
submittedAt: Date
isLate: boolean
version: number // 版本号
status: 'submitted' | 'graded' | 'returned'
}
interface SubmissionFile {
id: string
filename: string
url: string
size: number
type: string
uploadedAt: Date
}
// 评分记录
interface Grade {
id: string
submissionId: string
gradedBy: string
scores: DimensionScore[]
totalScore: number // 加权总分
finalScore: number // 扣除迟交后的最终分
feedback: string // 综合评语
gradedAt: Date
status: 'draft' | 'published'
}
interface DimensionScore {
dimensionId: string
score: number
comment?: string // 该维度的具体反馈
}
数据库表结构
-- 作品任务表
CREATE TABLE portfolio_tasks (
id UUID PRIMARY KEY,
course_id UUID REFERENCES courses(id),
title VARCHAR(255) NOT NULL,
description TEXT,
requirements TEXT,
allowed_file_types JSONB DEFAULT '[]',
max_file_size INT DEFAULT 50,
max_files INT DEFAULT 5,
deadline TIMESTAMP NOT NULL,
late_policy JSONB,
rubric JSONB NOT NULL,
status VARCHAR(20) DEFAULT 'draft',
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW()
);
-- 学生提交表
CREATE TABLE submissions (
id UUID PRIMARY KEY,
task_id UUID REFERENCES portfolio_tasks(id),
student_id UUID REFERENCES users(id),
text_content TEXT,
submitted_at TIMESTAMP DEFAULT NOW(),
is_late BOOLEAN DEFAULT FALSE,
version INT DEFAULT 1,
status VARCHAR(20) DEFAULT 'submitted',
UNIQUE(task_id, student_id, version)
);
-- 提交文件表
CREATE TABLE submission_files (
id UUID PRIMARY KEY,
submission_id UUID REFERENCES submissions(id),
filename VARCHAR(255) NOT NULL,
url TEXT NOT NULL,
size BIGINT,
file_type VARCHAR(50),
uploaded_at TIMESTAMP DEFAULT NOW()
);
-- 评分表
CREATE TABLE grades (
id UUID PRIMARY KEY,
submission_id UUID REFERENCES submissions(id),
graded_by UUID REFERENCES users(id),
scores JSONB NOT NULL,
total_score DECIMAL(5,2),
final_score DECIMAL(5,2),
feedback TEXT,
graded_at TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'draft'
);
-- 索引
CREATE INDEX idx_submissions_task ON submissions(task_id);
CREATE INDEX idx_submissions_student ON submissions(student_id);
CREATE INDEX idx_grades_submission ON grades(submission_id);
作品提交功能
文件上传组件
<template>
<div class="portfolio-uploader">
<div
class="upload-zone"
:class="{ dragging: isDragging }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="handleDrop"
@click="$refs.fileInput.click()"
>
<input
ref="fileInput"
type="file"
:accept="acceptedTypes"
multiple
hidden
@change="handleFileSelect"
>
<div class="upload-prompt">
<UploadIcon class="icon" />
<p>拖拽文件到此处,或点击上传</p>
<span class="hint">
支持 {{ allowedTypesText }},单个文件最大 {{ maxFileSize }}MB
</span>
</div>
</div>
<!-- 文件列表 -->
<div class="file-list" v-if="files.length">
<div
v-for="file in files"
:key="file.id"
class="file-item"
>
<FileIcon :type="file.type" />
<div class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatSize(file.size) }}</span>
</div>
<div class="file-status">
<ProgressBar
v-if="file.uploading"
:value="file.progress"
/>
<CheckIcon v-else-if="file.uploaded" class="success" />
<button @click="removeFile(file.id)" class="remove-btn">
<XIcon />
</button>
</div>
</div>
</div>
<!-- 提交进度 -->
<div class="submission-actions">
<span class="file-count">
已上传 {{ uploadedCount }}/{{ maxFiles }} 个文件
</span>
<button
class="submit-btn"
:disabled="!canSubmit"
@click="submitPortfolio"
>
{{ submitting ? '提交中...' : '提交作品' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { usePortfolioUpload } from '@/composables/usePortfolioUpload'
const props = defineProps({
taskId: String,
allowedTypes: Array,
maxFileSize: Number,
maxFiles: Number
})
const {
files,
uploadFile,
removeFile,
submitPortfolio,
submitting
} = usePortfolioUpload(props.taskId)
const isDragging = ref(false)
const uploadedCount = computed(() =>
files.value.filter(f => f.uploaded).length
)
const canSubmit = computed(() =>
uploadedCount.value > 0 && !submitting.value
)
function handleDrop(e) {
isDragging.value = false
const droppedFiles = Array.from(e.dataTransfer.files)
droppedFiles.forEach(file => validateAndUpload(file))
}
function handleFileSelect(e) {
const selectedFiles = Array.from(e.target.files)
selectedFiles.forEach(file => validateAndUpload(file))
}
function validateAndUpload(file) {
// 检查文件类型
if (!props.allowedTypes.includes(file.type)) {
toast.error(`不支持的文件类型: ${file.type}`)
return
}
// 检查文件大小
if (file.size > props.maxFileSize * 1024 * 1024) {
toast.error(`文件超过大小限制: ${file.name}`)
return
}
// 检查数量限制
if (files.value.length >= props.maxFiles) {
toast.error(`最多上传 ${props.maxFiles} 个文件`)
return
}
uploadFile(file)
}
</script>
提交版本管理
// composables/useSubmissionVersions.ts
export function useSubmissionVersions(taskId: string) {
const versions = ref<Submission[]>([])
const currentVersion = ref<Submission | null>(null)
async function loadVersions() {
const response = await $fetch(`/api/tasks/${taskId}/my-submissions`)
versions.value = response.submissions
currentVersion.value = versions.value[0] || null
}
async function createNewVersion() {
// 基于当前版本创建新版本
const response = await $fetch(`/api/tasks/${taskId}/submissions`, {
method: 'POST',
body: {
basedOnVersion: currentVersion.value?.version
}
})
await loadVersions()
return response.submission
}
function compareVersions(v1: number, v2: number) {
// 返回两个版本的差异
return $fetch(`/api/tasks/${taskId}/submissions/compare`, {
params: { v1, v2 }
})
}
return {
versions,
currentVersion,
loadVersions,
createNewVersion,
compareVersions
}
}
评分系统设计
评分规则配置
<template>
<div class="rubric-editor">
<h3>评分维度设置</h3>
<div
v-for="(dim, index) in dimensions"
:key="dim.id"
class="dimension-card"
>
<div class="dimension-header">
<input
v-model="dim.name"
placeholder="维度名称(如:创意性)"
class="dimension-name"
>
<div class="dimension-weight">
<input
type="number"
v-model.number="dim.weight"
min="0"
max="100"
>
<span>%</span>
</div>
<button @click="removeDimension(index)" class="remove-btn">
<TrashIcon />
</button>
</div>
<textarea
v-model="dim.description"
placeholder="该维度的评分说明"
class="dimension-desc"
/>
<div class="score-levels">
<div
v-for="level in dim.levels"
:key="level.score"
class="level-item"
>
<span class="level-score">{{ level.score }}分</span>
<input
v-model="level.label"
placeholder="等级名称"
>
<input
v-model="level.description"
placeholder="达到此等级的标准"
>
</div>
</div>
</div>
<button @click="addDimension" class="add-dimension-btn">
<PlusIcon /> 添加评分维度
</button>
<div class="weight-summary" :class="{ error: totalWeight !== 100 }">
权重合计: {{ totalWeight }}%
<span v-if="totalWeight !== 100">(需等于 100%)</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const dimensions = ref([
{
id: '1',
name: '内容质量',
description: '作品内容的完整性、准确性和深度',
weight: 40,
maxScore: 100,
levels: [
{ score: 90, label: '优秀', description: '内容全面深入,有独到见解' },
{ score: 75, label: '良好', description: '内容较完整,表达清晰' },
{ score: 60, label: '合格', description: '基本完成要求' },
{ score: 40, label: '待改进', description: '内容不完整或有明显错误' },
]
}
])
const totalWeight = computed(() =>
dimensions.value.reduce((sum, d) => sum + d.weight, 0)
)
function addDimension() {
dimensions.value.push({
id: Date.now().toString(),
name: '',
description: '',
weight: 0,
maxScore: 100,
levels: [
{ score: 90, label: '优秀', description: '' },
{ score: 75, label: '良好', description: '' },
{ score: 60, label: '合格', description: '' },
{ score: 40, label: '待改进', description: '' },
]
})
}
function removeDimension(index) {
dimensions.value.splice(index, 1)
}
</script>
评分操作界面
<template>
<div class="grading-panel">
<!-- 学生作品预览 -->
<div class="submission-preview">
<div class="student-info">
<Avatar :src="submission.student.avatar" />
<span>{{ submission.student.name }}</span>
<Badge v-if="submission.isLate" type="warning">迟交</Badge>
</div>
<div class="file-previews">
<FilePreview
v-for="file in submission.files"
:key="file.id"
:file="file"
/>
</div>
</div>
<!-- 评分表单 -->
<div class="grading-form">
<div
v-for="dim in rubric"
:key="dim.id"
class="score-dimension"
>
<div class="dimension-header">
<span class="dim-name">{{ dim.name }}</span>
<span class="dim-weight">权重 {{ dim.weight }}%</span>
</div>
<div class="score-selector">
<button
v-for="level in dim.levels"
:key="level.score"
:class="{ selected: scores[dim.id] === level.score }"
@click="setScore(dim.id, level.score)"
>
<span class="level-score">{{ level.score }}</span>
<span class="level-label">{{ level.label }}</span>
</button>
</div>
<textarea
v-model="comments[dim.id]"
:placeholder="`${dim.name} 评语(可选)`"
class="dimension-comment"
/>
</div>
<!-- 总分预览 -->
<div class="total-score">
<span>加权总分:</span>
<strong>{{ calculatedTotal }}</strong>
<span v-if="submission.isLate" class="penalty">
(扣除迟交 {{ latePenalty }}% 后: {{ finalScore }})
</span>
</div>
<!-- 综合评语 -->
<div class="overall-feedback">
<label>综合评语</label>
<textarea
v-model="overallFeedback"
placeholder="对学生作品的整体评价和改进建议..."
rows="4"
/>
</div>
<!-- 操作按钮 -->
<div class="grading-actions">
<button @click="saveDraft" class="btn-secondary">
保存草稿
</button>
<button @click="publishGrade" class="btn-primary">
发布成绩
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
submission: Object,
rubric: Array
})
const scores = ref({})
const comments = ref({})
const overallFeedback = ref('')
const calculatedTotal = computed(() => {
let total = 0
for (const dim of props.rubric) {
const score = scores.value[dim.id] || 0
total += (score * dim.weight) / 100
}
return total.toFixed(1)
})
const latePenalty = computed(() => {
if (!props.submission.isLate) return 0
// 计算迟交天数和扣分
const daysLate = Math.ceil(
(new Date(props.submission.submittedAt) - new Date(props.submission.deadline))
/ (1000 * 60 * 60 * 24)
)
return Math.min(daysLate * 5, 30) // 每天扣5%,最多扣30%
})
const finalScore = computed(() => {
return (calculatedTotal.value * (1 - latePenalty.value / 100)).toFixed(1)
})
function setScore(dimId, score) {
scores.value[dimId] = score
}
async function saveDraft() {
await $fetch(`/api/grades`, {
method: 'POST',
body: {
submissionId: props.submission.id,
scores: formatScores(),
feedback: overallFeedback.value,
status: 'draft'
}
})
toast.success('草稿已保存')
}
async function publishGrade() {
await $fetch(`/api/grades`, {
method: 'POST',
body: {
submissionId: props.submission.id,
scores: formatScores(),
totalScore: parseFloat(calculatedTotal.value),
finalScore: parseFloat(finalScore.value),
feedback: overallFeedback.value,
status: 'published'
}
})
toast.success('成绩已发布')
emit('graded')
}
</script>
成绩统计与分析
成绩分布图
<template>
<div class="grade-statistics">
<div class="stat-cards">
<StatCard title="平均分" :value="stats.average" />
<StatCard title="最高分" :value="stats.max" />
<StatCard title="最低分" :value="stats.min" />
<StatCard title="及格率" :value="`${stats.passRate}%`" />
</div>
<div class="distribution-chart">
<h4>成绩分布</h4>
<BarChart :data="distributionData" />
</div>
<div class="dimension-analysis">
<h4>各维度平均得分</h4>
<RadarChart :data="dimensionData" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
grades: Array,
rubric: Array
})
const stats = computed(() => {
const scores = props.grades.map(g => g.finalScore)
return {
average: (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1),
max: Math.max(...scores),
min: Math.min(...scores),
passRate: ((scores.filter(s => s >= 60).length / scores.length) * 100).toFixed(1)
}
})
const distributionData = computed(() => {
const ranges = ['0-59', '60-69', '70-79', '80-89', '90-100']
const counts = [0, 0, 0, 0, 0]
for (const grade of props.grades) {
const score = grade.finalScore
if (score < 60) counts[0]++
else if (score < 70) counts[1]++
else if (score < 80) counts[2]++
else if (score < 90) counts[3]++
else counts[4]++
}
return {
labels: ranges,
datasets: [{
label: '人数',
data: counts,
backgroundColor: ['#EF4444', '#F59E0B', '#3B82F6', '#22C55E', '#10B981']
}]
}
})
</script>
API 设计
核心接口
// server/api/tasks/[taskId]/submissions/index.post.ts
export default defineEventHandler(async (event) => {
const taskId = getRouterParam(event, 'taskId')
const userId = event.context.user.id
const body = await readBody(event)
// 检查截止时间
const task = await db.portfolioTasks.findUnique({ where: { id: taskId } })
const isLate = new Date() > new Date(task.deadline)
if (isLate && !task.lateSubmissionPolicy.allowed) {
throw createError({ statusCode: 400, message: '已过截止时间,不允许提交' })
}
// 创建提交记录
const submission = await db.submissions.create({
data: {
taskId,
studentId: userId,
textContent: body.textContent,
isLate,
version: await getNextVersion(taskId, userId)
}
})
return { submission }
})
// server/api/grades/index.post.ts
export default defineEventHandler(async (event) => {
const userId = event.context.user.id
const body = await readBody(event)
// 验证教师权限
await requireTeacherRole(event)
// 创建或更新评分
const grade = await db.grades.upsert({
where: {
submissionId_gradedBy: {
submissionId: body.submissionId,
gradedBy: userId
}
},
create: {
submissionId: body.submissionId,
gradedBy: userId,
scores: body.scores,
totalScore: body.totalScore,
finalScore: body.finalScore,
feedback: body.feedback,
status: body.status
},
update: {
scores: body.scores,
totalScore: body.totalScore,
finalScore: body.finalScore,
feedback: body.feedback,
status: body.status,
gradedAt: new Date()
}
})
// 如果发布成绩,发送通知
if (body.status === 'published') {
await sendGradeNotification(grade)
}
return { grade }
})
总结
作品提交与评分系统的核心要点:
| 模块 | 关键点 |
|---|---|
| 作品提交 | 多格式支持、大文件上传、版本管理 |
| 评分规则 | 多维度、权重分配、等级描述 |
| 评分操作 | 便捷打分、实时计算、评语反馈 |
| 成绩管理 | 统计分析、可视化、导出功能 |
| 公平性 | 迟交策略、申诉机制、匿名评审 |
一个好的评分系统不仅是打分工具,更是教学反馈的桥梁。让学生知道"为什么得这个分",比分数本身更重要。


