作品提交与评分系统:构建公平透明的学生创作评估平台

HTMLPAGE 团队
20 分钟阅读

系统讲解在线教育平台中作品提交与评分系统的设计与实现,包括多媒体上传、评分维度设计、评审流程、成绩统计等核心功能。

#作品评分 #在线教育 #评审系统 #学生作品 #成绩管理

作品提交与评分系统:构建公平透明的学生创作评估平台

与传统考试不同,作品类作业需要学生提交文档、图片、视频甚至代码。评分也不再是简单的对错,而是多维度的综合评价。本文将讲解如何设计一个专业的作品提交与评分系统。

系统需求分析

核心场景

用户角色主要操作
学生上传作品、查看反馈、申诉成绩
教师设置评分标准、评分、发布成绩
助教协助批改、汇总成绩
管理员监控异常、导出报表

功能模块

作品提交与评分系统
├── 任务发布模块
│   ├── 创建作品任务
│   ├── 设置提交要求
│   └── 配置评分规则
├── 作品提交模块
│   ├── 多媒体上传
│   ├── 在线编辑
│   └── 版本管理
├── 评分模块
│   ├── 评分维度设置
│   ├── 评分操作
│   └── 评语反馈
├── 成绩管理模块
│   ├── 成绩统计
│   ├── 排名分析
│   └── 成绩导出
└── 申诉模块
    ├── 学生申诉
    └── 教师复核

数据模型设计

核心实体

// 作品任务
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 }
})

总结

作品提交与评分系统的核心要点:

模块关键点
作品提交多格式支持、大文件上传、版本管理
评分规则多维度、权重分配、等级描述
评分操作便捷打分、实时计算、评语反馈
成绩管理统计分析、可视化、导出功能
公平性迟交策略、申诉机制、匿名评审

一个好的评分系统不仅是打分工具,更是教学反馈的桥梁。让学生知道"为什么得这个分",比分数本身更重要。

延伸阅读