引言:为什么需要一个基于小组评分的Web应用?

在现代软件开发教育和企业培训中,小组项目是常见的实践形式。然而,传统的小组评分方式往往面临两大痛点:团队协作效率低下评分公平性难以保证。一个成员可能贡献了80%的工作,却和只贡献20%的成员获得相同的分数;或者在协作过程中,沟通不畅导致项目延期。

开发一个专门的“基于小组评分的Web应用”可以完美解决这些问题。这个应用不仅能规范项目流程,还能通过数据记录和算法实现公平评分。本文将从零开始,详细指导你完成从需求分析到部署的全流程,并重点解决团队协作与评分公平性的关键问题。

1. 需求分析:明确我们要解决的核心问题

1.1 核心用户痛点

  • 协作不透明:不知道队友在做什么,进度难以追踪。
  • 评分主观性:组长或老师凭感觉打分,缺乏数据支撑。
  • 责任分散:出现“搭便车”现象,努力的成员感到不公。

1.2 功能需求清单(MoSCoW法则)

  • Must have (必须有)
    • 用户系统(注册、登录、角色区分:学生/组长/老师)。
    • 项目创建与小组管理(组长创建项目,邀请成员)。
    • 任务看板(创建任务、分配、更新状态)。
    • 工时/贡献度日志(成员记录每日工作)。
    • 评分系统(基于贡献度的自动计算 + 主观调整)。
  • Should have (应该有)
    • 实时通知(WebSocket或轮询)。
    • 文件上传与共享。
    • 代码仓库集成(GitHub API)。
  • Could have (可以有)
    • 甘特图视图。
    • 每日站会打卡功能。

1.3 非功能性需求

  • 安全性:密码加密,权限控制(RBAC)。
  • 性能:支持50人同时在线。
  • 易用性:界面简洁,操作步骤少。

2. 技术选型与架构设计

为了快速开发且保证可扩展性,我们选择以下技术栈:

  • 前端:Vue 3 (Composition API) + Element Plus UI 组件库。
  • 后端:Node.js + Express 框架。
  • 数据库:MongoDB (灵活的Schema适合项目管理)。
  • 认证:JWT (JSON Web Tokens)。
  • 部署:Docker + Nginx。

2.1 数据库设计 (ER图概念)

我们需要设计几个核心集合(Collection):

  1. Users (用户)

    • _id: ObjectId
    • username: String
    • password: String (Hashed)
    • role: String (Enum: ‘student’, ‘组长’, ‘老师’)
  2. Projects (项目)

    • _id: ObjectId
    • name: String
    • owner: UserId (组长)
    • members: UserId
    • status: String (进行中/已完成)
  3. Tasks (任务)

    • _id: ObjectId
    • projectId: ObjectId
    • assignee: UserId (负责人)
    • title: String
    • status: String (待办/进行中/已完成)
    • estimatedHours: Number
  4. Logs (贡献日志)

    • _id: ObjectId
    • userId: ObjectId
    • taskId: ObjectId
    • hoursSpent: Number (花费小时数)
    • description: String (工作描述)
    • date: Date
  5. Scores (评分)

    • _id: ObjectId
    • projectId: ObjectId
    • userId: ObjectId
    • baseScore: Number (基于工时的计算分)
    • adjustment: Number (组长/老师调整分)
    • finalScore: Number (总分)

3. 后端开发实战 (Node.js + Express)

我们将逐步构建后端API。为了演示,这里使用伪代码和核心逻辑片段。

3.1 项目初始化

mkdir group-scoring-app
cd group-scoring-app
npm init -y
npm install express mongoose jsonwebtoken bcryptjs cors dotenv
npm install --save-dev nodemon

3.2 核心中间件:身份验证 (Middleware)

这是解决“权限控制”的关键,确保只有登录用户才能操作。

// middleware/auth.js
const jwt = require('jsonwebtoken');

const auth = (req, res, next) => {
    const token = req.header('x-auth-token');
    if (!token) {
        return res.status(401).json({ msg: '没有Token,权限被拒绝' });
    }

    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        req.user = decoded.user; // 将用户信息附加到req对象
        next();
    } catch (err) {
        res.status(401).json({ msg: 'Token无效' });
    }
};

module.exports = auth;

3.3 核心功能:记录贡献日志与计算分数

这是解决“评分公平性”的核心逻辑。我们通过记录日志来量化贡献。

// routes/logs.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const Log = require('../models/Log');
const Task = require('../models/Task');

// @route   POST api/logs
// @desc    记录工作日志
router.post('/', auth, async (req, res) => {
    const { taskId, hoursSpent, description } = req.body;

    try {
        // 1. 检查任务是否存在且属于当前用户
        const task = await Task.findById(taskId);
        if (!task) return res.status(404).json({ msg: '任务未找到' });
        
        // 简单的权限检查:只能记录自己的工时
        if (task.assignee.toString() !== req.user.id) {
            return res.status(403).json({ msg: '只能记录分配给自己的任务工时' });
        }

        // 2. 创建日志
        const newLog = new Log({
            userId: req.user.id,
            taskId,
            hoursSpent,
            description,
            date: new Date()
        });

        await newLog.save();

        // 3. 更新任务状态(可选逻辑)
        // 如果工时达到预估,自动标记完成
        const logs = await Log.find({ taskId });
        const totalHours = logs.reduce((acc, log) => acc + log.hoursSpent, 0);
        
        if (totalHours >= task.estimatedHours) {
            task.status = '已完成';
            await task.save();
        }

        res.json(newLog);
    } catch (err) {
        console.error(err.message);
        res.status(500).send('服务器错误');
    }
});

module.exports = router;

3.4 核心算法:公平评分计算引擎

这是本应用的灵魂。我们采用 “基础分(工时占比) + 调整分(主观评价)” 的混合模式。

算法逻辑:

  1. 获取项目所有成员的总工时。
  2. 计算个人工时占比:个人总工时 / 项目总工时
  3. 基础分 = 占比 * 基准分(例如100分)。
  4. 最终分 = 基础分 + 调整分(组长可微调,范围 -10 到 +10,防止极端偏差)。
// routes/scores.js
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const Log = require('../models/Log');
const Project = require('../models/Project');
const Score = require('../models/Score');

// @route   GET api/scores/calculate/:projectId
// @desc    计算项目成员分数 (仅组长或老师可调用)
router.get('/calculate/:projectId', auth, async (req, res) => {
    try {
        const project = await Project.findById(req.params.projectId);
        
        // 权限检查:只有组长或老师能计算分数
        if (project.owner.toString() !== req.user.id && req.user.role !== '老师') {
            return res.status(403).json({ msg: '无权计算分数' });
        }

        const members = project.members; // 数组 [userId1, userId2]
        let projectTotalHours = 0;
        let memberHours = [];

        // 第一步:统计每个成员的总工时
        for (const memberId of members) {
            // 查找该成员在本项目所有任务的日志
            // 注意:这里简化了查询,实际需关联Tasks和Logs
            const logs = await Log.find({ userId: memberId }) 
                .populate('taskId', 'projectId') // 关联任务查项目ID
                .lean(); // 转为普通JS对象
            
            // 筛选出属于当前项目的日志
            const projectLogs = logs.filter(l => l.taskId.projectId.toString() === project._id.toString());
            
            const totalHours = projectLogs.reduce((acc, log) => acc + log.hoursSpent, 0);
            projectTotalHours += totalHours;
            
            memberHours.push({ userId: memberId, hours: totalHours });
        }

        // 第二步:计算基础分
        const results = [];
        for (const member of memberHours) {
            let baseScore = 0;
            if (projectTotalHours > 0) {
                // 基础分 = (个人工时 / 总工时) * 100
                baseScore = (member.hours / projectTotalHours) * 100;
            }
            
            // 查找是否已有评分记录(获取调整分)
            let existingScore = await Score.findOne({ projectId: project._id, userId: member.userId });
            let adjustment = existingScore ? existingScore.adjustment : 0;
            
            // 计算最终分
            let finalScore = baseScore + adjustment;

            // 保存或更新到数据库
            const scoreRecord = await Score.findOneAndUpdate(
                { projectId: project._id, userId: member.userId },
                { 
                    baseScore: parseFloat(baseScore.toFixed(2)), 
                    adjustment, 
                    finalScore: parseFloat(finalScore.toFixed(2)) 
                },
                { new: true, upsert: true } // 不存在则创建
            );

            results.push(scoreRecord);
        }

        res.json(results);

    } catch (err) {
        console.error(err.message);
        res.status(500).send('服务器错误');
    }
});

// @route   POST api/scores/adjust/:projectId
// @desc    组长手动调整分数
router.post('/adjust/:projectId', auth, async (req, res) => {
    const { targetUserId, adjustment } = req.body;
    try {
        const project = await Project.findById(req.params.projectId);
        if (project.owner.toString() !== req.user.id) {
            return res.status(403).json({ msg: '只有组长能调整分数' });
        }

        // 限制调整范围,防止恶意打分
        if (adjustment < -10 || adjustment > 10) {
            return res.status(400).json({ msg: '调整分必须在 -10 到 10 之间' });
        }

        const score = await Score.findOne({ projectId: project._id, userId: targetUserId });
        if (!score) return res.status(404).json({ msg: '请先计算基础分' });

        score.adjustment = adjustment;
        score.finalScore = parseFloat((score.baseScore + adjustment).toFixed(2));
        await score.save();

        res.json(score);
    } catch (err) {
        res.status(500).send('服务器错误');
    }
});

module.exports = router;

4. 前端开发实战 (Vue 3)

前端主要负责数据展示和交互。这里我们重点展示“任务看板”和“评分仪表盘”的实现思路。

4.1 任务看板组件 (Kanban Board)

使用 Vue 3 的 reactive 状态管理,实现拖拽或点击更新状态。

<!-- components/TaskBoard.vue -->
<template>
  <div class="board">
    <div v-for="status in ['待办', '进行中', '已完成']" :key="status" class="column">
      <h3>{{ status }}</h3>
      <div 
        v-for="task in getTasksByStatus(status)" 
        :key="task._id" 
        class="task-card"
        @click="openTaskModal(task)"
      >
        <div class="title">{{ task.title }}</div>
        <div class="assignee">负责人: {{ task.assigneeName }}</div>
        <div class="hours">预估: {{ task.estimatedHours }}h</div>
        <!-- 只有负责人能看到记录按钮 -->
        <button v-if="isMyTask(task)" @click.stop="logWork(task)">记录工时</button>
      </div>
    </div>
  </div>

  <!-- 记录工时弹窗 -->
  <el-dialog v-model="dialogVisible" title="记录工时">
    <el-form :model="logForm">
      <el-form-item label="花费小时">
        <el-input-number v-model="logForm.hours" :min="0.5" :step="0.5" />
      </el-form-item>
      <el-form-item label="工作描述">
        <el-input v-model="logForm.desc" type="textarea" />
      </el-form-item>
    </el-form>
    <template #footer>
      <el-button @click="dialogVisible = false">取消</el-button>
      <el-button type="primary" @click="submitLog">提交</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue';
import axios from 'axios';

const tasks = ref([]);
const dialogVisible = ref(false);
const logForm = reactive({ taskId: null, hours: 1, desc: '' });
const currentUser = JSON.parse(localStorage.getItem('user')); // 假设存储了用户信息

// 模拟获取数据
const fetchTasks = async () => {
  // const res = await axios.get('/api/tasks');
  // tasks.value = res.data;
  // 这里为了演示,写死数据
  tasks.value = [
    { _id: 1, title: '设计数据库', status: '已完成', assignee: 'u1', assigneeName: '张三', estimatedHours: 4 },
    { _id: 2, title: '开发API', status: '进行中', assignee: 'u2', assigneeName: '李四', estimatedHours: 8 },
  ];
};

const getTasksByStatus = (status) => tasks.value.filter(t => t.status === status);

const isMyTask = (task) => task.assignee === currentUser.id;

const logWork = (task) => {
  logForm.taskId = task._id;
  logForm.hours = 1;
  logForm.desc = '';
  dialogVisible.value = true;
};

const submitLog = async () => {
  try {
    await axios.post('/api/logs', logForm);
    alert('记录成功!');
    dialogVisible.value = false;
  } catch (err) {
    alert('记录失败');
  }
};

onMounted(fetchTasks);
</script>

<style scoped>
.board { display: flex; gap: 20px; }
.column { background: #f4f5f7; padding: 10px; width: 300px; border-radius: 8px; }
.task-card { background: white; padding: 10px; margin-bottom: 10px; border-radius: 4px; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
</style>

4.2 评分仪表盘 (Score Dashboard)

展示公平性数据,让组员看到分数是如何计算的,增加透明度。

<!-- components/ScoreDashboard.vue -->
<template>
  <div class="dashboard">
    <h2>项目评分看板 (透明度模式)</h2>
    <el-table :data="scoreData" style="width: 100%">
      <el-table-column prop="username" label="成员" />
      <el-table-column prop="hours" label="累计工时" />
      <el-table-column prop="baseScore" label="基础分 (工时占比)" />
      <el-table-column label="调整分 (组长评价)">
        <template #default="scope">
          <!-- 只有组长能编辑 -->
          <span v-if="!isLeader">{{ scope.row.adjustment }}</span>
          <el-input-number 
            v-else 
            v-model="scope.row.adjustment" 
            :min="-10" :max="10" 
            size="small"
            @change="updateAdjustment(scope.row)"
          />
        </template>
      </el-table-column>
      <el-table-column prop="finalScore" label="最终得分" />
    </el-table>
    
    <div v-if="isLeader" class="tip">
      💡 提示:调整分用于奖励积极贡献或惩罚消极怠工,范围 -10 ~ +10。
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';

const scoreData = ref([]);
const isLeader = ref(false); // 实际应从Vuex或Pinia获取

const fetchData = async () => {
  // 1. 获取计算后的分数
  // const res = await axios.get('/api/scores/calculate/projectId');
  // scoreData.value = res.data;
  
  // 模拟数据
  scoreData.value = [
    { username: '张三', hours: 12, baseScore: 60, adjustment: 0, finalScore: 60 },
    { username: '李四', hours: 8, baseScore: 40, adjustment: 0, finalScore: 40 },
  ];
};

const updateAdjustment = async (row) => {
  try {
    await axios.post('/api/scores/adjust/projectId', {
      targetUserId: row.userId,
      adjustment: row.adjustment
    });
    // 重新计算最终分
    row.finalScore = row.baseScore + row.adjustment;
  } catch (err) {
    alert('调整失败,可能超出范围或无权限');
  }
};

onMounted(fetchData);
</script>

5. 解决团队协作与公平性的关键策略

开发工具只是手段,真正的核心在于如何使用它来规范流程。

5.1 解决团队协作:引入“每日日志”强制机制

在应用中,我们设计了 Logs 模块。强制要求成员每天下班前提交日志。

  • 策略:日志不仅是给组长看的,更是给自己看的。通过可视化图表(如ECharts),展示“代码提交频率”、“工时分布”。
  • 代码实现:在前端首页增加一个“今日工作”快捷入口,降低记录门槛。

5.2 解决评分公平性:混合算法与申诉机制

单纯的工时计算是不公平的(有人摸鱼刷工时),单纯的主观打分也是不公平的。

  • 混合算法:如后端代码所示,Final = (工时占比 * 80%) + (主观调整 * 20%)。这里我们将主观调整的权重限制在20分以内,防止组长一言堂。
  • 申诉机制:在前端增加“申诉”按钮。如果组员觉得分数不公,可以向老师提交证据(日志截图)。这在应用中可以设计为一个简单的消息流。

6. 测试与质量保证

6.1 单元测试 (Jest)

我们需要测试评分算法是否正确。

// tests/score.test.js
const calculateScore = (userHours, totalHours) => {
    if (totalHours === 0) return 0;
    return (userHours / totalHours) * 100;
};

test('计算基础分: 10小时/20小时 应该是 50分', () => {
    expect(calculateScore(10, 20)).toBe(50);
});

test('计算基础分: 0小时/0小时 应该是 0分 (防除零)', () => {
    expect(calculateScore(0, 0)).toBe(0);
});

6.2 集成测试

使用 Postman 或 Supertest 测试 API 流程:

  1. 创建用户 -> 登录 -> 获取Token。
  2. 创建项目 -> 添加成员。
  3. 创建任务 -> 记录日志。
  4. 计算分数 -> 验证分数是否符合预期。

7. 部署全流程 (Docker化)

为了保证环境一致性,我们使用 Docker。

7.1 编写后端 Dockerfile

# Dockerfile.backend
FROM node:16-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 5000

CMD ["npm", "start"]

7.2 编写前端 Dockerfile (构建Nginx)

# Dockerfile.frontend
FROM node:16-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM nginx:stable-alpine
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

7.3 Docker Compose 编排

这是最简单的部署方式,一键启动所有服务。

# docker-compose.yml
version: '3.8'
services:
  backend:
    build: ./backend
    ports:
      - "5000:5000"
    environment:
      - MONGO_URI=mongodb://mongo:27017/group_app
      - JWT_SECRET=mysecretkey
    depends_on:
      - mongo

  frontend:
    build: ./frontend
    ports:
      - "80:80"
    depends_on:
      - backend

  mongo:
    image: mongo:latest
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db

volumes:
  mongo_data:

部署命令:

docker-compose up -d --build

此时,访问 http://localhost 即可看到前端页面,后端API运行在 http://localhost:5000

8. 总结

通过开发这个“基于小组评分的Web应用”,我们不仅完成了一个全栈项目,更重要的是建立了一套可量化的团队协作与评价体系

核心价值回顾:

  1. 流程规范化:从需求分析到部署,每一步都有据可依。
  2. 数据透明化:工时日志让贡献可见,算法让分数有理有据。
  3. 协作高效化:任务看板和通知机制减少了沟通成本。

这套系统可以作为毕业设计、课程作业或企业内部管理工具的蓝本。希望这篇指南能为你提供完整的实战思路!