简介:面向毕业设计场景的数字化农产品溯源小程序完整源码包,采用TypeScript开发,适合计算机相关专业学生、教师及企业开发者作为毕设、课程设计或项目起步参考。项目代码经过运行验证,功能可用,具备微信小程序前端与构建配置,可在此基础上二次开发。资源共170个文件,主要包括38个TypeScript源码文件、31个TSX组件文件、18个SCSS样式文件、19个JavaScript脚本及项目配置,同时附有项目操作说明文档,整体压缩包仅504KB,结构紧凑便于快速上手。目前已有172人学习下载。借助完整源码和操作说明,可掌握小程序端TypeScript工程化开发流程、页面组件组织方式及微信小程序发布构建方法;从yarn安装依赖、开发调试到构建发布均有说明,适合从零开始复现毕设项目或作为功能扩展的起点。
1. 数字化农产品溯源小程序,毕设题目里最需要想透的一件事
我见过不少把“农产品溯源小程序”做成“农产品展示小程序”的毕设。页面做得很精致,但扫码之后只有一段产地文字和两张基地照片,答辩时评委问一句“怎么证明这些信息是真的”,项目就卡住了。真正的溯源,核心在于“链路”二字:每一批农产品从播种、施肥、采收、检测到发货,都要有带时间戳的操作记录,记录之间能串成一条不断裂的数据链,并且这条链路在小程序端可查询、在服务端可校验。本文要讲的,就是基于 TypeScript 把这条链路从数据模型一路实现到小程序扫码页面的完整路径。
选这个题目做毕业设计,业务边界足够清晰、技术栈足够主流、工作量也可控。前端是微信小程序加 TypeScript,后端只需要提供溯源查询和记录写入两类接口,数据库用 MySQL 或 MongoDB 都能支撑。用户扫码得到一条时间线,管理端录入一次农事操作,这两个场景正好覆盖“读写分离”的演示需求。对正在读这篇文章的你,我想先传达一个判断:选 TypeScript 不是为了在代码里多写几个类型,而是为了让“溯源链”这个领域模型在代码层面先立住。后面的每一节,都是围绕这一目标展开的。
2. 从 TypeScript 选型到小程序工程骨架:先定模型再写页面
2.1 TypeScript 解决的是溯源场景里的“字段信任”
农产品溯源小程序的数据流是典型的多实体关联:一个农产品对应对应多个生产批次,一个批次包含多条农事记录、一份检测报告和多条物流事件。这些实体之间的嵌套关系如果靠 JS 的普通对象去维护,页面取字段时手一抖就可能写错;而用 TypeScript 的接口把这些结构声明出来后,编译期就能发现大部分问题。
以农事记录为例,这是整个链路里最核心的实体之一:
// types/farm.ts export type FarmOpType = 'seeding' | 'irrigation' | 'fertilization' | 'pesticide' | 'harvest'; export interface FarmRecord { id: string; batchNo: string; opType: FarmOpType; operatorName: string; operatedAt: string; // ISO 8601 时间字符串 description: string; mediaUrls: string[]; // 图片或视频的临时地址 }这段代码有两个细节值得在答辩时主动讲。第一,opType使用字符串字面量联合类型而不是string,它把所有农事操作限定在五个值内,后端如果传来一个'watering',编译期就会报错,而不是等用户扫码时才看到一个无法渲染的节点。第二,operatedAt统一使用 ISO 8601 字符串而非 Date 对象,因为小程序的setData和网络传输对可序列化类型更友好,时间线排序时也能直接使用字符串比较。
接口联调时,我一般会额外提供一个类型守卫,防止后端返回的数据越过编译期检查:
export function isFarmRecord(r: unknown): r is FarmRecord { if (typeof r !== 'object' || r === null) return false; const obj = r as Record<string, unknown>; return ( typeof obj.id === 'string' && typeof obj.batchNo === 'string' && ['seeding', 'irrigation', 'fertilization', 'pesticide', 'harvest'].includes(obj.opType as string) && typeof obj.operatorName === 'string' && typeof obj.operatedAt === 'string' ); }isFarmRecord是 TypeScript 的类型守卫,它补上了“编译期类型”和“运行时数据”之间的鸿沟。小程序里从wx.request拿到的数据默认是unknown,进入页面之前先过一遍守卫,结构不对的直接拦截并提示接口异常,这比在渲染层到处判空要干净得多。这个写法在 TypeScript 教程和面试题里也常被当作考点,毕设里用上属于加分项。
2.2 原生小程序工程结构与 tsconfig 的关键配置
落地方式采用原生微信小程序加 TypeScript,工程目录建议按职责分成三块:小程序端、服务端、共享类型。常见的组织方式如下:
trace-miniapp/ ├── project.config.json ├── tsconfig.json ├── typings/ │ └── global.d.ts ├── miniprogram/ │ ├── app.ts │ ├── app.json │ ├── pages/ │ │ ├── index/ # 入口页,说明与扫码按钮 │ │ ├── scan/ # 扫码中转页 │ │ ├── trace/ # 溯源详情页 │ │ └── admin/ # 管理页,录入农事记录 │ ├── services/ # 接口请求封装 │ ├── types/ # 领域类型与类型守卫 │ └── utils/ # 工具函数 └── server/ # Node.js 后端 ├── src/ └── package.json目录结构本身没有太多玄机,重点是别把类型定义散落在各个页面里。所有业务实体的interface统一放在miniprogram/types/下,页面只 import 不重复定义;后端如果也是 TypeScript,可以通过 npm 包或路径映射复用同一套类型,前后端对“批次”“农事记录”的理解保持一致。
tsconfig.json的关键配置项如下,对应含义和解说列在表里:
| 配置项 | 推荐值 | 作用说明 |
|---|---|---|
strict | true | 打开全部严格检查,参数隐式 any 直接报错 |
target | ES2020 | 小程序基础库对 ES2020 的 async/await 支持良好 |
module | CommonJS | 小程序开发工具按 CommonJS 解析模块 |
typeRoots | ["./typings", "./node_modules/miniprogram-api-typings"] | 让编译器能找到 wx 全局 API 的类型声明 |
skipLibCheck | true | 跳过第三方声明文件的类型检查,加快编译 |
需要重点说的是typeRoots。微信小程序的全局对象wx、Page、getApp都需要miniprogram-api-typings这个类型包来声明。安装命令是npm i -D miniprogram-api-typings,然后在typings/global.d.ts里写一行引用:
/// <reference path="../node_modules/miniprogram-api-typings/index.d.ts" />如果不做这一步,编辑器里wx.scanCode、wx.setNavigationBarTitle这些 API 会一直提示“找不到名称 wx”。strict模式在毕设里务必打开,答辩评委很可能随手打开一个页面文件看有没有隐式any,严格模式本身就是最好的代码检查背书。
2.3 服务层与页面层的职责边界
把请求逻辑从页面里抽出来,是小程序工程是否“像样”的分水岭。常见的反面写法是在onLoad里直接写wx.request,然后setData一个巨大的嵌套对象。这样做的后果是:接口地址变更、加请求头、统一处理错误,都得在所有页面里改一遍。常规做法是抽一层服务封装:
// utils/request.ts const BASE_URL = 'https://api.trace.example.com'; interface RequestOptions<T> { url: string; method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; data?: T; header?: Record<string, string>; needAuth?: boolean; } export function request<TReq, TRes>(opts: RequestOptions<TReq>): Promise<TRes> { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${opts.url}`, method: opts.method || 'GET', data: opts.data, header: { 'content-type': 'application/json', ...(opts.needAuth ? { Authorization: `Bearer ${wx.getStorageSync('token')}` } : {}), ...opts.header, }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data as TRes); } else { reject(new Error(`HTTP ${res.statusCode}: ${res.errMsg}`)); } }, fail: (err) => reject(new Error(err.errMsg)), }); }); }参数说明:泛型TReq表示请求体类型,TRes表示响应体类型。调用方在拿到Promise<TRes>后不再需要做as断言,数据结构已经被泛型约束住了。needAuth为true时自动携带本地存储的 token,后台录入和查询接口通吃。
具体的溯源查询服务定义如下:
// services/trace.ts import { request } from '../utils/request'; import { TraceChain } from '../types/trace'; export function fetchTraceChain(code: string): Promise<TraceChain> { return request<unknown, TraceChain>({ url: `/v1/trace/${encodeURIComponent(code)}`, method: 'GET', }); }这里对溯源码先做了一次encodeURIComponent编码,防止码内出现#、?等会截断 URL 的字符。真机扫到带特殊符号的二维码时,这个细节能省去一次很难排查的白屏问题。
3. 小程序扫码入口与溯源时间线渲染的实现
3.1 追溯码的编码规则与合法性校验
追溯码是整条链路的入口,它不是随便一个随机字符串,而是要能被正则识别、能通过校验算法、能在数据库中定位到唯一批次。常见的设计是:固定前缀 + 基地代号 + 批次号 + 产品编码 + 校验位。下面的格式定义可以直接用在毕设里:
TRACE-<基地代号>-<年份批次>-<产品编码>-<校验位> 示例:TRACE-GD4420-2024B12-AG07-8k2f| 码段 | 示例值 | 说明 |
|---|---|---|
| TRACE | TRACE | 固定业务前缀,区分其它二维码 |
| 基地代号 | GD4420 | 省缩写加编号,标识种植基地 |
| 年份批次 | 2024B12 | 2024 年第二季度第 12 批 |
| 产品编码 | AG07 | 农产品目录中的唯一编码 |
| 校验位 | 8k2f | 由前几段算出的 4 位十六进制校验值 |
校验函数用来拦截手输错误和伪造码,防止无效请求打到后端:
// utils/traceCode.ts export function isValidTraceCode(code: string): boolean { const pattern = /^TRACE-[A-Z0-9]{3,6}-\d{4}[A-Z]\d+-[A-Z0-9]{2,6}-[0-9a-f]{4}$/i; if (!pattern.test(code)) return false; const parts = code.split('-'); const checkDigit = parts.pop()!.toLowerCase(); const payload = parts.join('-'); return simpleHash(payload) === checkDigit; } // FNV-1a 变体,取低 16 位作为校验值 function simpleHash(input: string): string { let hash = 5381; for (let i = 0; i < input.length; i++) { hash = ((hash << 5) + hash) ^ input.charCodeAt(i); } return (hash >>> 0).toString(16).padStart(8, '0').slice(-4); }逻辑说明:先由正则筛掉不符合格式的码,再取出末尾四位校验值,对剩余部分重新计算 FNV-1a 哈希。前后端共用同一个工具函数,保证录入的码和扫码的码使用同一套校验规则。产品编码和批次号嵌入码内,也为后端查询提供了路由线索,扫码时无需再查一次映射表。
3.2 扫码中转入口:wx.scanCode 与页面参数传递
扫码页的逻辑非常简单,但要注意两个容易被忽视的细节:用户取消扫码的回调和参数编码。以下是完整的扫码中转实现:
// pages/scan/index.ts import { isValidTraceCode } from '../../utils/traceCode'; Page({ onLoad() { wx.scanCode({ onlyFromCamera: true, success: (res) => { const code = res.result.trim(); if (!isValidTraceCode(code)) { wx.showToast({ title: '无效溯源码', icon: 'none' }); return; } wx.navigateTo({ url: `/pages/trace/index?code=${encodeURIComponent(code)}`, }); }, fail: (err) => { if (err.errMsg.includes('cancel')) return; wx.showToast({ title: '扫码失败,请重试', icon: 'none' }); }, }); }, });参数说明:onlyFromCamera: true限制只能调用摄像头扫码,避免弹出的相册选择器干扰演示节奏。fail回调里先判断errMsg是否包含cancel,用户主动取消不算错误,不弹提示;其它异常再统一提示。encodeURIComponent对 code 进行编码,因为导航 URL 中一旦出现&或=,参数会被query解析器截断。
另一种常见入口是二维码图片里直接包含小程序页面路径,用户长按识别后进入。此时小程序会把路径中的code参数放进onLoad的query对象,获取方式与扫码跳转完全一致,这为后续在包装盒上印刷二维码留下了扩展空间。
3.3 溯源时间线的数据加载与 TypeScript 数组操作
溯源详情页拿到code后的第一件事是调用服务层取数,并把节点按时间正序排列。时间线渲染的质量直接决定演示效果,这块的逻辑可以这样写:
// pages/trace/index.ts import { fetchTraceChain } from '../../services/trace'; import { TraceChain } from '../../types/trace'; import { isValidTraceCode } from '../../utils/traceCode'; Page({ data: { loading: true, errorText: '', productName: '未知农产品', timeline: [] as TraceNode[], }, async onLoad(query: Record<string, string>) { const code = query.code ? decodeURIComponent(query.code) : ''; if (!code || !isValidTraceCode(code)) { this.setData({ loading: false, errorText: '溯源参数不合法' }); return; } try { const chain: TraceChain = await fetchTraceChain(code); const sorted = [...chain.nodes].sort((a, b) => a.operatedAt.localeCompare(b.operatedAt) ); wx.setNavigationBarTitle({ title: chain.product.name }); this.setData({ productName: chain.product.name, timeline: sorted, loading: false, }); } catch (e) { this.setData({ loading: false, errorText: '溯源信息获取失败' }); } }, });onLoad中先对query.code做一次decodeURIComponent,与扫码页的encodeURIComponent形成对称的编解码链路。[...chain.nodes]这一步值得单独讲:Array.prototype.sort是原地排序,会修改原数组;先用展开运算符克隆数组再排序,避免污染服务层返回的缓存对象。排序依据是operatedAt的字符串值,ISO 8601 的时间格式天然支持字典序比较,这也是前面坚持用字符串存时间的原因。
wx.setNavigationBarTitle在这里顺手把导航栏标题改成了农产品名称,动态设置标题的能力让详情页看起来更像一个独立业务页面,而不是模板套出来的死页面。时间线在 WXML 侧用wx:for配合wx:key="id"渲染,每个节点展示操作类型、时间、地点和负责人,这里就不展开模板代码了。
4. 服务端溯源链校验与管理端权限模型
4.1 数据模型设计与库表关系
溯源数据在后端至少要落五张表:农产品表、批次表、农事记录表、检测报告表、物流事件表。它们之间的关系用一张表说明:
| 实体 | 关键字段 | 关系 | 用途 |
|---|---|---|---|
| 农产品表 | id, name, variety, origin, cover_url | 1:N 批次 | 被追溯的主体 |
| 批次表 | batch_no, product_id, base_name, planted_at, harvested_at | 1:N 农事记录 | 溯源查询主键入口 |
| 农事记录表 | record_id, batch_no, op_type, operator_name, operated_at | N:1 批次 | 时间线节点,核心履历 |
| 检测报告表 | report_id, batch_no, org_name, conclusion, report_url | 1:1 批次 | 合规证据 |
| 物流事件表 | event_id, batch_no, checkpoint, temperature, recorded_at | N:1 批次 | 履约链路节点 |
批次表是整个业务的中枢。农事记录、检测报告、物流事件全部挂在batch_no上,消费者扫码得到的是某一批次的完整履历。批次表里要预留planted_at和harvested_at,这两个字段决定时间线的起点和终点,缺失时前端要能容错显示“生长中”状态。
4.2 哈希链校验:低成本的数据防篡改方案
毕设里讲溯源防篡改,最合适的做法是哈希链。每条记录保存前一条记录的哈希值,构成单向链;任何一条中间的记录被修改,其后所有记录的校验都会失败。实现如下:
// server/src/trace/verifyChain.ts import * as crypto from 'crypto'; export interface ChainLink { index: number; prevHash: string; payload: string; timestamp: string; hash: string; } function sha256(text: string): string { return crypto.createHash('sha256').update(text).digest('hex'); } export function verifyChain(links: ChainLink[]): boolean { if (links.length === 0) return true; let expectedPrev = '0'.repeat(64); for (let i = 0; i < links.length; i++) { const link = links[i]; if (link.prevHash !== expectedPrev) return false; const recomputed = sha256( `${link.index}|${link.prevHash}|${link.payload}|${link.timestamp}` ); if (recomputed !== link.hash) return false; expectedPrev = link.hash; } return true; }逻辑说明:起始位用 64 个 0 作为虚拟前置哈希。每一条记录的hash在写入时由(index, prevHash, payload, timestamp)四段拼接后做 SHA-256 得到。校验时从头到尾重算一遍,任何一个节点不一致立刻返回false。
这个方案的优点是概念清晰、代码量少,答辩时可以挡住“数据被篡改怎么办”的追问。同时必须说明它的边界:它只能检测篡改,并不能防止有权限的人重新生成整条链。数据库管理员拿到写权限后可以把全部记录重算一遍,这在毕业设计的演示场景里是可以接受的,但如果往论文里写,建议补一句“基于防篡改记录的方案属于授权链模型,与公链共识机制有本质区别”。
4.3 基于 JWT 的角色权限控制
管理端的农事记录录入接口必须做权限校验,不能允许匿名请求写库。常见的做法是 JWT 令牌加角色中间件,示例代码如下:
// server/src/middleware/auth.ts import { NextFunction, Request, Response } from 'express'; import jwt from 'jsonwebtoken'; const JWT_SECRET = process.env.JWT_SECRET || 'trace_dev_secret'; export function requireRole(...roles: string[]) { return (req: Request, res: Response, next: NextFunction) => { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : ''; if (!token) { return res.status(401).json({ message: '缺少登录凭证' }); } try { const payload = jwt.verify(token, JWT_SECRET) as { uid: string; role: string }; if (roles.length > 0 && !roles.includes(payload.role)) { return res.status(403).json({ message: '权限不足' }); } (req as any).user = payload; next(); } catch { return res.status(401).json({ message: '登录凭证已失效' }); } }; }路由挂载示例:
router.post('/records', requireRole('farmer', 'admin'), createFarmRecord); router.get('/audit', requireRole('admin'), getAuditLogs); router.get('/trace/:code', getTraceChain); // 消费者端不需登录参数说明:requireRole接收可变参数,router.post('/records')允许 farmer 和 admin 角色写入农事记录;router.get('/audit')只放行 admin,展示操作审计日志;溯源查询接口对消费者是完全开放的,这符合业务常识。在课堂上说明这三条路由的权限差异,比单纯写一堆代码更能体现对权限模型的理解。
5. 毕业设计演示现场准备与四个高频坑位
5.1 真机预览与域名配置
真机预览是毕设演示最可能翻车的一环。小程序开发工具默认不校验合法域名,但在真机上wx.request的域名必须满足两个条件:HTTPS 协议、在微信公众平台后台配置为 request 合法域名。演示前一天一定要在“开发管理 - 开发设置 - 服务器域名”里检查一遍。如果后端跑在本机,用内网穿透工具把 8080 端口映射成 HTTPS 域名,一天内可以搞定。需要提醒的是,修改域名配置后要重新编译并清掉小程序缓存,否则真机仍可能请求旧地址。
5.2 四个高频问题定位与解决
下面这四类问题在答辩现场出现频率最高,提前核对能省去大量尴尬:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
编译器报Cannot find name 'wx' | 未安装miniprogram-api-typings或typeRoots配置缺失 | 安装类型包并检查global.d.ts引用 |
| 扫码后跳转页面参数为空 | URL 中的 code 未做encodeURIComponent,被&截断 | 扫码页和页面上下两处都加编解码 |
| 真机请求接口超时 | 域名未配置 HTTPS 或未加入合法域名列表 | 使用 HTTPS 域名并在后台完成配置 |
| 溯源图片不显示 | mediaUrls中的资源域名未在小程序后台配置 downloadFile 合法域名 | 在“downloadFile 合法域名”中加入图片所在域名 |
其中图片不显示的问题最隐蔽:模板里<image src>默认受 downloadFile 域名白名单约束,和后端接口域名是两套配置。演示素材提前用工具转成 base64 塞进代码里也可以应急,但不建议,因为会让包体积变大。
5.3 答辩演示的数据准备清单
演示数据需要在答辩前一小时准备到位,建议准备三份不同状态的批次数据:一份显示“生长中”,时间线只有三条农事记录;一份显示“已上市”,完整走完农事、检测、物流全链路;一份故意构造哈希链断裂,用来展示前端“数据异常”的提示。演示时先扫完整链路的码,再切换异常数据库实例展示校验失败的效果,比口头讲防篡改逻辑有说服力得多。最后记得在管理端预先录入两条待审核的农事记录,现场操作时直接点“通过”即可,避免答辩时手机输入法误触打断节奏。
本文还有配套的精品资源,点击获取