引言:为什么需要一个基于小组评分的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):
Users (用户)
_id: ObjectIdusername: Stringpassword: String (Hashed)role: String (Enum: ‘student’, ‘组长’, ‘老师’)
Projects (项目)
_id: ObjectIdname: Stringowner: UserId (组长)members: UserIdstatus: String (进行中/已完成)
Tasks (任务)
_id: ObjectIdprojectId: ObjectIdassignee: UserId (负责人)title: Stringstatus: String (待办/进行中/已完成)estimatedHours: Number
Logs (贡献日志)
_id: ObjectIduserId: ObjectIdtaskId: ObjectIdhoursSpent: Number (花费小时数)description: String (工作描述)date: Date
Scores (评分)
_id: ObjectIdprojectId: ObjectIduserId: ObjectIdbaseScore: 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 核心算法:公平评分计算引擎
这是本应用的灵魂。我们采用 “基础分(工时占比) + 调整分(主观评价)” 的混合模式。
算法逻辑:
- 获取项目所有成员的总工时。
- 计算个人工时占比:
个人总工时 / 项目总工时。 - 基础分 = 占比 * 基准分(例如100分)。
- 最终分 = 基础分 + 调整分(组长可微调,范围 -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 流程:
- 创建用户 -> 登录 -> 获取Token。
- 创建项目 -> 添加成员。
- 创建任务 -> 记录日志。
- 计算分数 -> 验证分数是否符合预期。
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应用”,我们不仅完成了一个全栈项目,更重要的是建立了一套可量化的团队协作与评价体系。
核心价值回顾:
- 流程规范化:从需求分析到部署,每一步都有据可依。
- 数据透明化:工时日志让贡献可见,算法让分数有理有据。
- 协作高效化:任务看板和通知机制减少了沟通成本。
这套系统可以作为毕业设计、课程作业或企业内部管理工具的蓝本。希望这篇指南能为你提供完整的实战思路!
