HOJ二次开发实战:从修改登录动画到新增签到功能,手把手教你定制自己的OJ
在技术社区和编程教育领域,在线判题系统(OJ)扮演着至关重要的角色。HOJ作为一款开源的在线判题系统,因其模块化设计和良好的扩展性,成为许多开发者和教育机构的首选。本文将聚焦HOJ的二次开发实战,带你从零开始实现几个典型的功能定制案例。
1. 前端定制:修改全局加载动画
HOJ默认的加载动画虽然简洁,但缺乏个性。我们可以通过修改前端代码来实现自定义动画效果。首先需要定位到动画相关的代码位置:
# 在HOJ前端项目中,加载动画通常位于以下路径 src/components/Common/Loading.vue修改这个Vue组件时,可以考虑以下几种动画方案:
- CSS动画:使用
@keyframes实现平滑过渡效果 - Lottie动画:导入JSON格式的复杂矢量动画
- SVG动画:创建轻量级的矢量图形动画
推荐方案:使用CSS动画,因为它性能最优且兼容性最好。下面是一个波纹扩散效果的实现代码:
<template> <div class="loading-container"> <div class="ripple"> <div></div> <div></div> </div> <p>{{ message }}</p> </div> </template> <style scoped> .ripple { position: relative; width: 64px; height: 64px; margin: 0 auto; } .ripple div { position: absolute; border: 4px solid #1890ff; opacity: 1; border-radius: 50%; animation: ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite; } .ripple div:nth-child(2) { animation-delay: -0.5s; } @keyframes ripple { 0% { top: 28px; left: 28px; width: 0; height: 0; opacity: 1; } 100% { top: -1px; left: -1px; width: 58px; height: 58px; opacity: 0; } } </style>提示:修改完成后,记得运行
npm run build重新构建前端资源,并更新Docker镜像。
2. 功能扩展:实现签到求签系统
签到功能可以增加用户粘性和平台趣味性。我们需要同时修改前端和后端代码来实现这个功能。
2.1 数据库设计
首先在HOJ的数据库中创建签到记录表:
CREATE TABLE `user_check_in` ( `id` bigint NOT NULL AUTO_INCREMENT, `uid` varchar(32) NOT NULL COMMENT '用户ID', `check_in_date` date NOT NULL COMMENT '签到日期', `fortune` varchar(255) DEFAULT NULL COMMENT '求签结果', `continuous_days` int DEFAULT '1' COMMENT '连续签到天数', PRIMARY KEY (`id`), UNIQUE KEY `uid_date` (`uid`,`check_in_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;2.2 后端API开发
在Spring Boot后端项目中,创建签到控制器:
@RestController @RequestMapping("/api/checkin") public class CheckInController { @Autowired private UserCheckInService userCheckInService; @PostMapping("/do") public CommonResult<Void> doCheckIn(@RequestParam(value = "fortune", required = false) String fortune) { // 获取当前用户ID String uid = UserSessionUtil.getUserInfo().getUid(); return userCheckInService.doCheckIn(uid, fortune); } @GetMapping("/status") public CommonResult<CheckInStatusVO> getCheckInStatus() { String uid = UserSessionUtil.getUserInfo().getUid(); return userCheckInService.getCheckInStatus(uid); } }2.3 前端界面实现
在前端项目中创建签到组件:
<template> <div class="check-in-card"> <h3>每日签到</h3> <div v-if="status.checked" class="checked"> <p>已连续签到 {{ status.continuousDays }} 天</p> <p v-if="status.fortune">今日运势:{{ status.fortune }}</p> </div> <div v-else class="unchecked"> <button @click="showFortune = true">立即签到</button> <div v-if="showFortune" class="fortune-selector"> <p>求个今日运势吧!</p> <button v-for="item in fortunes" :key="item" @click="doCheckIn(item)"> {{ item }} </button> </div> </div> </div> </template> <script> const FORTUNES = ['大吉', '中吉', '小吉', '末吉', '凶']; export default { data() { return { status: { checked: false, continuousDays: 0 }, showFortune: false, fortunes: FORTUNES }; }, methods: { async doCheckIn(fortune) { try { await this.$api.checkIn.do(fortune); this.status = await this.$api.checkIn.status(); this.showFortune = false; } catch (error) { this.$error(error); } } } }; </script>3. 实用功能:FPS格式答案导入
对于编程教学场景,能够批量导入题目和答案可以极大提高效率。HOJ支持FPS格式的题目导入,但默认不支持答案导入,我们可以扩展这个功能。
3.1 修改FPS解析器
在后端项目中找到FPS解析器类(通常名为FpsProblemParser),添加答案解析逻辑:
public class FpsProblemParser { // ... 原有代码 ... private void parseAnswer(Problem problem, Element item) { Element answerElement = item.element("answer"); if (answerElement != null) { String answer = answerElement.getText(); problem.setAnswer(answer); } } public Problem parse(Element item, boolean isUpload) { Problem problem = new Problem(); // ... 原有解析逻辑 ... parseAnswer(problem, item); return problem; } }3.2 更新题目服务
修改题目服务,在导入题目时保存答案:
@Service public class ProblemServiceImpl implements ProblemService { // ... 原有代码 ... @Override @Transactional(rollbackFor = Exception.class) public void addProblem(Problem problem) { // ... 原有保存逻辑 ... if (StringUtils.isNotBlank(problem.getAnswer())) { ProblemAnswer answer = new ProblemAnswer(); answer.setPid(problem.getId()); answer.setAnswer(problem.getAnswer()); problemAnswerMapper.insert(answer); } } }3.3 前端适配
在前端导入界面添加答案导入提示:
<template> <div class="import-fps"> <h3>FPS格式导入</h3> <p>支持导入题目描述、测试用例和<strong>标准答案</strong></p> <input type="file" @change="handleFileChange" accept=".xml" /> <button @click="submit">开始导入</button> </div> </template>4. 安全增强:登录日志功能
记录用户登录行为是保障系统安全的重要手段。我们可以为HOJ添加详细的登录日志功能。
4.1 创建登录日志表
CREATE TABLE `user_login_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `uid` varchar(32) NOT NULL COMMENT '用户ID', `login_time` datetime NOT NULL COMMENT '登录时间', `ip` varchar(45) NOT NULL COMMENT '登录IP', `user_agent` varchar(512) DEFAULT NULL COMMENT '用户代理', `location` varchar(100) DEFAULT NULL COMMENT '地理位置', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-成功 0-失败', PRIMARY KEY (`id`), KEY `idx_uid` (`uid`), KEY `idx_time` (`login_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;4.2 实现日志记录拦截器
创建Spring拦截器记录登录行为:
public class LoginLogInterceptor implements HandlerInterceptor { @Autowired private UserLoginLogService loginLogService; @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { if (request.getRequestURI().equals("/api/login") && response.getStatus() == 200) { String username = request.getParameter("username"); UserLoginLog log = new UserLoginLog(); log.setUid(getUidByUsername(username)); log.setLoginTime(new Date()); log.setIp(IpUtils.getIpAddr(request)); log.setUserAgent(request.getHeader("User-Agent")); log.setLocation(IpUtils.getCityInfo(log.getIp())); log.setStatus(1); loginLogService.save(log); } } }4.3 添加登录日志查询接口
@RestController @RequestMapping("/api/admin") public class AdminLoginLogController { @Autowired private UserLoginLogService loginLogService; @GetMapping("/login-logs") public CommonResult<IPage<UserLoginLog>> getLoginLogs( @RequestParam(value = "username", required = false) String username, @RequestParam(value = "ip", required = false) String ip, @RequestParam(value = "status", required = false) Integer status, @RequestParam(value = "startTime", required = false) String startTime, @RequestParam(value = "endTime", required = false) String endTime, @RequestParam(value = "current", defaultValue = "1") int current, @RequestParam(value = "size", defaultValue = "10") int size) { QueryWrapper<UserLoginLog> wrapper = new QueryWrapper<>(); // 构建查询条件... return CommonResult.success(loginLogService.page(new Page<>(current, size), wrapper)); } }5. 教学辅助:班级同步题单功能
对于教育用途的OJ系统,能够按班级同步题单是非常实用的功能。
5.1 数据库设计
CREATE TABLE `class_problem_list` ( `id` bigint NOT NULL AUTO_INCREMENT, `class_id` bigint NOT NULL COMMENT '班级ID', `name` varchar(100) NOT NULL COMMENT '题单名称', `description` text COMMENT '题单描述', `creator` varchar(32) NOT NULL COMMENT '创建者', `create_time` datetime NOT NULL COMMENT '创建时间', `update_time` datetime NOT NULL COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_class_id` (`class_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `class_problem_list_item` ( `id` bigint NOT NULL AUTO_INCREMENT, `list_id` bigint NOT NULL COMMENT '题单ID', `pid` bigint NOT NULL COMMENT '题目ID', `sort` int DEFAULT '0' COMMENT '排序', PRIMARY KEY (`id`), UNIQUE KEY `uk_list_problem` (`list_id`,`pid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;5.2 后端实现
创建班级题单服务:
@Service public class ClassProblemListServiceImpl implements ClassProblemListService { @Autowired private ClassProblemListMapper listMapper; @Autowired private ClassProblemListItemMapper itemMapper; @Override public CommonResult<Void> syncListToClass(Long listId, Long classId) { // 验证权限... // 获取题单所有题目 List<ClassProblemListItem> items = itemMapper.selectList( new QueryWrapper<ClassProblemListItem>().eq("list_id", listId) ); // 为班级每个成员分配这些题目 items.forEach(item -> { assignProblemToClassMembers(item.getPid(), classId); }); return CommonResult.success(); } private void assignProblemToClassMembers(Long pid, Long classId) { // 实现题目分配给班级成员的逻辑 } }5.3 前端界面
创建班级题单管理页面:
<template> <div class="class-problem-list"> <div class="header"> <h3>班级题单管理</h3> <button @click="showCreateDialog = true">新建题单</button> </div> <div class="list-container"> <div v-for="list in lists" :key="list.id" class="list-item"> <h4>{{ list.name }}</h4> <p>{{ list.description }}</p> <button @click="syncList(list.id)">同步到班级</button> </div> </div> </div> </template> <script> export default { data() { return { lists: [], showCreateDialog: false }; }, methods: { async syncList(listId) { try { await this.$api.classProblemList.sync(listId, this.classId); this.$success('题单同步成功'); } catch (error) { this.$error(error); } } } }; </script>在实现这些二次开发功能时,有几个关键点需要注意:首先确保理解HOJ原有的架构设计,遵循其代码规范;其次,修改前后端代码时要考虑兼容性,避免影响现有功能;最后,每个新功能都应该有完善的测试用例。