
简介面向高校科研管理场景的完整小程序前后端项目适用于计算机专业毕业设计、课程实践、科研管理系统二次开发也可作为高校信息化部门梳理业务流程的参考。资源共1472个文件压缩包约33.19MB内含Java后端服务、Vue/JS前端模块、WXML/WXSS小程序页面、JSON配置、SQL数据库脚本、PNG/SVG界面素材以及部署辅助脚本目录覆盖后端Controller、实体类、前端页面组件、系统样式与配置文件结构清晰便于按模块学习。系统围绕科研项目申报、经费预算、成果登记、团队人员管理、审核流程、通知提醒和统计分析等核心功能展开覆盖科研活动全周期管理具备较完整的管理闭环。已有90人学习适合需要参考完整项目源码理解高校科研业务逻辑并在此基础上升级、扩展或改造的开发者。1. 科研管理系统的小程序形态与“三端分离”设计高校科研管理系统的痛点从来不在“记录”而在“多头填报”。教师要填项目申报书、管理员要核对经费、院系领导要看全院成果三套流程过去分散在PC端、Excel和纸质文件中。这套以微信小程序为载体的高校科研管理系统把PC端的管理后台、移动端的教师/审核端和前端展示层拆成三个独立模块小程序只负责触达用户核心业务逻辑全部收敛到后端接口中。项目压缩包里同时出现.vue.bak和.bat构建脚本说明这不是纯原生小程序而是采用了 Vue 技术栈编译到微信小程序的方案后端则存在KeyantuanduiController.class意味着已编译成 Java 字节码大概率跑在 Spring Boot 上。对需要在校园内快速上线、又要应对多角色权限的团队来说这种“小程序前端 Java 后端 关系型数据库”的组合是目前性价比最高的选型。2. 小程序前端骨架从 Vue 组件到页面路由2.1 工程结构初识为什么项目里到处都是.bak解压后能看到IndexMain.vue.bak、IndexAsideStatic.vue.bak、BreadCrumbs.vue.bak这一组文件。.bak是备份文件但它们不是无意义的历史残留而是一个信号这个项目的管理端页面原本是 Web 后台布局IndexAsideStatic是侧边栏BreadCrumbs是面包屑导航后来被改造或打算迁移到小程序端。真正的工程主体应当是以main.js或app.vue为入口的小程序项目而.bak文件保留的是 PC 端页面设计稿。理解这一点对后面接手代码很重要——你在跑1-install.bat时安装的是整个依赖树而不是某一个端。我一般会建议保留这些.bak文件用于对照但即刻在pages.json中确认当前工作目录。以 uni-app 工程为例典型结构如下├── pages/ │ ├── project/list.vue │ ├── project/detail.vue │ ├── fund/apply.vue │ ├── report/result.vue │ └── user/login.vue ├── static/ ├── utils/request.js ├── api/ # 接口层 ├── pages.json ├── manifest.json └── App.vuepages.json是微信小程序与 uni-app 共同的路由配置中心。所有页面必须先在这里注册才能被navigateTo跳转。下面是一个精简的页面路由配置{ pages: [ { path: pages/project/list, style: { navigationBarTitleText: 科研项目 } }, { path: pages/project/detail, style: { navigationBarTitleText: 项目详情 } }, { path: pages/fund/apply, style: { navigationBarTitleText: 经费申请 } }, { path: pages/user/login, style: { navigationBarTitleText: 登录 } } ], tabBar: { list: [ { pagePath: pages/project/list, text: 项目 }, { pagePath: pages/user/login, text: 我的 } ] } }参数说明navigationBarTitleText控制顶部导航栏文字tabBar.list里的pagePath必须与pages中路径完全一致否则微信开发者工具直接报“invalid pagePath”。这个文件在改动后需要重新编译且 tabBar 不支持动态修改只能全局配置一次。2.2 核心页面项目列表与申报表单教师最高频的操作是查看自己的项目列表和发起新项目申报。列表页不能简单用v-for渲染因为科研项目数据量虽然不大但每个项目要展示名称、经费、状态、负责人多个字段且需要下拉刷新和触底分页。这里我会用onPullDownRefresh配合onReachBottom两个小程序生命周期函数template view classproject-list view v-foritem in projects :keyitem.id classproject-card text classtitle{{ item.title }}/text text classstatus :classitem.status{{ statusText(item.status) }}/text text负责人{{ item.leader }}/text text到账经费{{ item.fund }} 万元/text /view /view /template script export default { data() { return { page: 1, pageSize: 10, projects: [], isLastPage: false }; }, onPullDownRefresh() { this.page 1; this.fetchProjects(true); }, onReachBottom() { if (this.isLastPage) return; this.page 1; this.fetchProjects(false); }, methods: { async fetchProjects(overwrite) { const res await this.$api.getProjectList({ page: this.page, size: this.pageSize }); const list res.data.list; this.projects overwrite ? list : this.projects.concat(list); this.isLastPage list.length this.pageSize; uni.stopPullDownRefresh(); } } }; /script这段代码的逻辑关键在于overwrite参数下拉刷新时用新数据覆盖数组触底加载时拼接。uni.stopPullDownRefresh()必须放在数据更新后调用否则 iOS 上会出现刷新动画卡死。状态显示我用了一个statusText方法转换状态码例如1显示“申报中”、2显示“审核中”、3显示“已立项”避免后端直接返回中文导致前后端耦合。2.3 请求封装与错误码统一小程序里不能直接使用 axios但可以封装uni.request把 token 注入、错误码提示和 401 跳转集中在同一个模块。这是我在所有小程序项目里的标准做法// utils/request.js const BASE_URL https://api.example.edu.cn/api/v1; export function request(path, method GET, data {}) { return new Promise((resolve, reject) { uni.request({ url: ${BASE_URL}${path}, method, data, header: { Content-Type: application/json, Authorization: uni.getStorageSync(token) || }, success: (res) { if (res.data.code 200) { resolve(res.data); } else if (res.data.code 401) { uni.navigateTo({ url: /pages/user/login }); uni.removeStorageSync(token); reject(new Error(登录过期)); } else { uni.showToast({ title: res.data.msg, icon: none }); reject(new Error(res.data.msg)); } }, fail: (err) { uni.showToast({ title: 网络异常, icon: none }); reject(err); } }); }); }这里的BASE_URL必须在小程序后台配置为合法域名且必须是 HTTPS。开发调试阶段可以在微信开发者工具里勾选“不校验合法域名”但真机预览时这个选项无效。错误码约定为200成功401未授权500业务错误。注意后端返回的 JSON 格式必须统一为{ code, data, msg }否则前端提取res.data.code会拿到 undefined。3. 后端科研团队管理KeyantuanduiController 解析3.1 Spring Boot 控制层设计思路压缩包中的KeyantuanduiController.class是一个已经编译好的 Java 类。从命名看“Keyantuandui”是“科研团队”的拼音缩写这个 Controller 管理的是科研团队资源。在 Spring Boot 中Controller 层只负责参数接收和响应封装业务逻辑下沉到 Service 层数据访问交给 Mapper。这种三层结构让团队管理接口既可以被小程序调用也可以被未来的 PC 管理端复用。核心是不把业务逻辑写在 Controller 里否则后期每一个权限改动都要动接口方法。一个干净的科研团队 Controller 应当包含以下接口方法路径作用请求参数GET/team/list分页查询团队page, size, keywordPOST/team/create创建团队teamName, leaderId, deptIdPUT/team/{id}更新团队信息teamName, descriptionDELETE/team/{id}删除团队无GET/team/{id}/members查询团队成员teamId3.2 团队创建与成员绑定接口实现创建团队这个动作不是简单 INSERT 一条记录。一个团队至少关联负责人、成员列表和所属院系还要校验同名校不重复。我通常会这样实现RestController RequestMapping(/api/v1/team) public class KeyantuanduiController { Autowired private IResearchTeamService teamService; PostMapping(/create) public RLong createTeam(RequestBody Valid TeamCreateDTO dto) { // 校验团队名唯一 boolean exists teamService.isNameExists(dto.getTeamName()); if (exists) { return R.fail(团队名称已存在); } ResearchTeam team new ResearchTeam(); team.setTeamName(dto.getTeamName()); team.setLeaderId(dto.getLeaderId()); team.setDeptId(dto.getDeptId()); team.setCreateTime(LocalDateTime.now()); teamService.saveTeamWithMembers(team, dto.getMemberIds()); return R.success(team.getId()); } GetMapping(/{id}/members) public RListMemberVO listMembers(PathVariable Long id) { ListMemberVO members teamService.getMembersByTeamId(id); return R.success(members); } }Valid注解触发的参数校验不能少。比如teamName用NotBlank限制空字符串leaderId用NotNull防止空指针。saveTeamWithMembers这个方法内部需要事务因为团队表插入后成员关联表必须同步插入任何一步失败都要回滚。项目中的.class是编译后的产物说明源码不在了但我们可以通过反编译工具如javap -c看到方法轮廓这对接手别人系统的人很实用。3.3 科研成果附件与文件上传接口科研管理系统离不开成果附件比如论文 PDF、专利扫描件。小程序端不能直接把文件存到服务器通常先调用上传接口拿到文件 URL再把 URL 作为参数写入成果记录。Spring Boot 的 multipart 文件接口如下PostMapping(/file/upload) public RString uploadFile(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return R.fail(上传文件为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); // 校验扩展名 ListString allowedExt Arrays.asList(.pdf, .docx, .zip, .jpg); if (!allowedExt.contains(ext.toLowerCase())) { return R.fail(不支持的文件类型); } String objectName research/ System.currentTimeMillis() _ originalFilename; String url fileStorageService.store(file, objectName); return R.success(url); }参数说明multipart/form-data的 key 必须与前端uni.uploadFile的name属性一致。如果前端传的是file后端用RequestParam(file)接收如果前端传的是fileName这里也要同步改。文件超过 Spring 默认的 1MB 会抛异常需要在application.yml里调大spring.servlet.multipart.max-file-size。文件存储一般不会存本地磁盘而是放到云存储对象服务这里为了保持通用性用fileStorageService.store抽象了一层。4. 科研数据模型与审核状态机设计4.1 项目、人员、经费三表如何关联管理系统的核心是数据模型。科研管理至少需要三张基础表project项目、teacher教师、fund经费流水。项目表描述“在研什么”教师表描述“谁参与”经费表描述“钱怎么花的”。另外还需要一张project_member关联表因为一个项目多人参与一个教师可以参与多个项目多对多关系不能直接在两张表中加外键字段。一个典型的数据模型CREATE TABLE project ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 项目名称, source_type tinyint(4) DEFAULT NULL COMMENT 1国家级 2省部级 3校级, budget decimal(10,2) DEFAULT NULL COMMENT 预算金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0草稿 1申报中 2审核中 3已立项 4已结题, leader_id bigint(20) NOT NULL COMMENT 负责人教师ID, start_date date DEFAULT NULL, end_date date DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_leader_id (leader_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT科研项目表; CREATE TABLE teacher ( id bigint(20) NOT NULL AUTO_INCREMENT, teacher_no varchar(20) NOT NULL COMMENT 工号, name varchar(50) NOT NULL, dept_id bigint(20) DEFAULT NULL COMMENT 院系ID, title varchar(20) DEFAULT NULL COMMENT 职称, PRIMARY KEY (id), UNIQUE KEY uk_teacher_no (teacher_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教师表; CREATE TABLE project_member ( id bigint(20) NOT NULL AUTO_INCREMENT, project_id bigint(20) NOT NULL, teacher_id bigint(20) NOT NULL, contribution varchar(255) DEFAULT NULL COMMENT 角色分工, PRIMARY KEY (id), UNIQUE KEY uk_project_teacher (project_id,teacher_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT项目成员关联表;这里约束要细说teacher_no的唯一索引保证工号不重复project_member的联合唯一索引防止同一教师被重复加入同一项目。status字段用tinyint而不是varchar是为了减少存储空间且方便比较大小因为审核流程需要判断状态是否允许跳转。4.2 审核流程的有限状态机项目申报的审核不是简单的增删改查而是一个状态流转过程。从“草稿”到“已立项”中间要经过院系审核、科研处审核两个节点。如果不在代码层控制流转可能会出现从“申报中”直接跳到“已结题”的非法操作。用状态机提前定义节点是避免脏数据最有效的手段。public enum ProjectStatus { DRAFT(0, 草稿, Arrays.asList(1)), SUBMITTED(1, 申报中, Arrays.asList(2)), DEPT_AUDIT(2, 院系审核, Arrays.asList(3, 4)), OFFICE_AUDIT(3, 科研处审核, Arrays.asList(4)), APPROVED(4, 已立项, Arrays.asList(5)), FINISHED(5, 已结题, Arrays.asList()); private final int code; private final String desc; private final ListInteger allowedNext; ProjectStatus(int code, String desc, ListInteger allowedNext) { this.code code; this.desc desc; this.allowedNext allowedNext; } public boolean canTransitTo(int targetCode) { return allowedNext.contains(targetCode); } }状态机的价值在于所有状态跳转都集中在这一个枚举中。Service 层做审核操作时先调用canTransitTo判断。如果当前状态是“院系审核”却试图跳到“已结题”直接抛业务异常。这个做法看起来增加了代码量但比起在 Controller 里写一堆if (status 2 action approve)后期维护成本低得多。而且微信小程序端只需要根据状态码渲染按钮不需要知道状态之间的约束关系。4.3 MyBatis-Plus 的条件构造器应用如果后端使用 MyBatis-Plus分页查询和条件过滤可以大幅简化。比如按项目名称模糊搜索、按状态筛选、按负责人 ID 精确查询组合代码如下LambdaQueryWrapperProject wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.isNotBlank(keyword), Project::getTitle, keyword) .eq(status ! null, Project::getStatus, status) .eq(leaderId ! null, Project::getLeaderId, leaderId) .orderByDesc(Project::getCreateTime); PageProject page new Page(pageNum, pageSize); IPageProject result projectMapper.selectPage(page, wrapper);注意条件构造器里的.like第一个参数是布尔表达式只有为true时才拼接该条件这是避免keyword为空时生成WHERE title LIKE %%这样的低效查询。Page对象传入selectPage后会返回带total、records的分页数据直接封装成{ list, total }给前端即可。5. 本地部署与微信开发者工具联调5.1 一键脚本 install/run/build 到底做了什么项目根目录下的1-install.bat、2-run.bat、3-build.bat是 Windows 环境的三个一键脚本。对于不熟悉 Node.js 的运维同事这三个脚本把依赖安装、本地启动、打包发布三步隔离了。它们的核心命令并不复杂拆解来看:: 1-install.bat echo off echo Installing dependencies... call npm install --registryhttps://registry.npm.taobao.org echo Install over. pause:: 2-run.bat echo off call npm run dev:mp-weixin pause:: 3-build.bat echo off call npm run build:mp-weixin pause参数说明npm install --registry指定镜像源在无外网环境下可以使用内网 npm 私服地址。dev:mp-weixin是 uni-app 编译到微信小程序的开发模式命令它生成dist/dev/mp-weixin目录。微信开发者工具导入项目时要选择这个编译输出目录而不是项目根目录这是新手最容易踩的坑。build:mp-weixin会生成压缩后的生产包发布到微信后台前必须执行这一步否则可能因为代码过大超出 2MB 限制。提示3 个脚本都用了pause目的是让出错信息停留在屏幕上方便拷贝报错。如果你的系统是 Linux/macOS需要把.bat换成.sh内容不变。5.2 HTTPS 与合法域名配置微信小程序生产环境强制要求所有网络请求必须走 HTTPS且域名要在“小程序管理后台-开发设置-服务器域名”中添加到request 合法域名。开发阶段可以临时跳过校验但联调时后端http://localhost:8080无法被真机访问因为真机的 localhost 指手机自身。我常用的联调方案是内网穿透工具把本机8080端口映射到一个临时 HTTPS 域名。但更稳定的方式是在同一局域网内让小程序开发工具关闭“校验合法域名”后直接请求http://192.168.x.x:8080。接口地址不要写死在代码里而是在utils/request.js里用一个环境变量控制// 开发环境 vs 生产环境 const BASE_URL process.env.NODE_ENV development ? http://192.168.31.50:8080/api/v1 : https://api.example.edu.cn/api/v1;5.3 常见联调报错排查对照表现象可能原因处理方式request:fail url not in domain list真机调试未关闭域名校验后台配置域名或开发期勾选不校验选项404 Not Found后端路径或请求方式不对检查RequestMapping拼接路径是否包含/api/v1401 Unauthorizedtoken 失效或未传在 request 拦截头中打印Authorization字段502 Bad Gateway后端崩溃或 Nginx 未启动查看后端日志中的Exception堆栈Failed to load resource: net::ERR_CERT_AUTHORITY_INVALIDHTTPS 证书为自签名测试环境导入证书到信任列表一次实践中小程序端明明调通了列表接口但 POST 创建接口总是 400。排查发现后端对象用了LocalDate而前端传的是2024-06-01 12:00:00字符串Jackson 反序列化格式不匹配。解决方案是在全局配置spring.jackson.date-formatyyyy-MM-dd HH:mm:ss同时前端传值严格按这个格式。6. 数据驾驶舱用 ECharts 在小程序端做科研趋势分析科研管理系统跑通后管理人员最需要的不是流程操作而是宏观决策数据。微信小程序原生 canvas 画图太繁琐更适合的做法是引入echarts-for-weixin组件库把后端统计接口返回的数据渲染成柱状图或折线图。这里给出一个按年度统计项目经费的示例。后端先写一个统计接口GetMapping(/stats/fund-by-year) public RListMapString, Object fundByYear() { return R.success(projectMapper.selectFundByYear()); }SQL 逻辑在 Mapper XML 中实现SELECT YEAR(create_time) AS year, SUM(budget) AS total_fund FROM project WHERE status 4 GROUP BY YEAR(create_time) ORDER BY YEAR(create_time);前端在小程序页面中加载这个组件template view classchart-container ec-canvas idfundChart canvas-idfundChart :ecec/ec-canvas /view /template script import * as echarts from ../../ec-canvas/echarts; function initChart(canvas, width, height) { const chart echarts.init(canvas, null, { width, height }); canvas.setChart(chart); return chart; } export default { data() { return { ec: { onInit: initChart } }; }, async mounted() { const res await this.$api.getFundStats(); const years res.data.map(item item.year); const funds res.data.map(item item.total_fund); this.ec { ...this.ec, option: { xAxis: { type: category, data: years }, yAxis: { type: value, name: 经费/万元 }, series: [{ type: bar, data: funds }] } }; } }; /script这里的ec-canvas组件监听ec对象变化当option赋值后自动刷新图表。需要注意的是图表容器必须有明确高度否则 canvas 初始化时拿到 0 高度图形渲染空白。另外小程序端 canvas 数量有限制同时渲染多个图表时用scroll-view包裹并懒加载不要在onLoad阶段一口气创建所有图表。除了经费统计还可以把“各院系立项数量”“成果类型占比”做成交互饼图。后端返回的聚合数据量很小前端查询频率也不高不需要引入 Redis 缓存。当统计接口的 SQL 涉及多表 LEFT JOIN 时务必给连接字段加索引否则随着项目数据积累接口会从毫秒级退化到秒级。这一步做完这个系统就从“能登记”进化到了“能辅助决策”也是这类管理项目真正体现开发价值的地方。本文还有配套的精品资源点击获取