简介:基于TypeScript开发的数字化农产品溯源小程序毕设项目,代码已通过运行验证,并附带项目操作说明。面向计算机相关专业在校生、教师及企业开发者,适合承担毕业设计、课程设计或初期项目演示,也可作为学习微信小程序与TypeScript协作开发的完整样例。包内共170个文件,核心逻辑以38个ts模块与31个tsx页面组件呈现,另有scss样式、svg图标、json配置和md说明文档等,压缩包约504KB,目录分类明确。目前已有172人学习下载。通过源码可理清农产品从信息录入、溯源展示到小程序发布构建的完整流程;配合说明文档能快速完成依赖安装、本地调试与打包上传,并便于二次开发扩展新功能。
1. 数字化农产品溯源小程序,真正的难点在数据链而不在页面
一个溯源小程序,光看页面确实没有难度:扫码、展示产地、展示检测报告,三个页面就能撑起整个项目。但这类项目被问得最多的问题从来不是页面长什么样,而是——你怎么保证扫码出来的信息是这一批货的?这个问题背后是数据模型、追溯码体系、端侧类型约束三件事,恰好每一件都能用 TypeScript 在小程序端做得比纯 JS 项目扎实。
这个项目标题里“数字化”三个字才是核心。溯源不是把静态信息贴到小程序里,而是让消费者每次扫码都真实地触发一次数据查询和记录,让后台能看见“哪里有人在扫、哪个批次被看得多”。适合正在做毕设、想拿“农产品溯源”当选题,但又不想只做个套壳展示页的开发者。也适合想了解微信小程序原生工程化里 TypeScript 到底怎么落地的从业者。这篇按我平时接手这类项目的顺序,把数据模型、TS 工程化、核心页面和上线前容易被卡住的细节讲透。
2. 一物一码与数据模型:TypeScript 的底气从这里开始
2.1 追溯码怎么设计,才能“码”得住批次
溯源体系的第一件事是给每一批农产品一个唯一的追溯码。常见做法是“批次码 + 序号”的组合:批次码标识产地、日期、产品,序号区分同一批次里的不同包装。
我一般会用基地编号、采收日期和一个按天递增的序号拼出批次码,再用 36 进制把序号压缩成短码,这样码不会太长,粘贴到微信对话框里也不会被截断。
// 生成追溯码:如 BJ0101-00001 的变体,序号部分用36进制压缩 function genTraceCode(baseId: string, sn: number): string { const seq = sn.toString(36).padStart(5, "0").toUpperCase(); return `${baseId}-${seq}`; } // 前端解析追溯码:用于扫码后校验格式 const TRACE_CODE_REG = /^[A-Z0-9]{6,10}-[A-Z0-9]{4,6}$/; function isValidTraceCode(code: string): boolean { return TRACE_CODE_REG.test(code.trim().toUpperCase()); }这里padStart(5, "0")保证序号固定五位,转换成 36 进制后能容纳 60466175 个序号,日常农产品批发完全够用。前端拿到扫码结果后先做格式校验,不合法直接提示“不是有效的溯源编码”,避免把脏字符串打到后端接口上。
需要注意的是,这种码可以反推出批次和生产顺序,不适合做防伪。如果需要防伪,需要在服务端存一份随机混淆映射表,把表面码和真实批次 ID 分离。毕设阶段用“批次码+序号”足够了,但要在项目操作说明里写清楚设计取舍。
2.2 三张核心表,把“溯源”翻译成字段
溯源数据在数据库里通常拆成三张表:批次表、环节记录表、扫码日志表。环节记录表是溯源的时间线来源,扫码日志表则是“数字化”的体现,后台能看到真实扫码行为。
| 表名 | 字段 | 类型 | 用途 |
|---|---|---|---|
| trace_batch | id, batch_code, product_name, origin, farmer, produced_at | VARCHAR / DATETIME | 批次主信息,扫码后首屏展示 |
| trace_node | id, batch_id, node_type, content, operator, happened_at | VARCHAR / INT / DATETIME | 种植、采收、加工、仓储、运输各环节记录 |
| trace_log | id, trace_code, openid, viewed_at, region | VARCHAR / DATETIME | 每次扫码留痕,供后台统计扫码次数和地域分布 |
node_type建议用数字字典而不是直接存中文字符串:1种植、2采收、3加工、4仓储、5运输、6销售。字典写死在服务端枚举里,前端拿数字后映射中文。这样做的好处是后端改文案时,小程序端不用发版。
这也是 TypeScript 能发挥价值的地方——把字典和接口返回结构在前端提前定义好,后端字段变了,编译期就能发现。
2.3 接口返回结构:一次给全还是分两步
详情页接口我一般拆成两个:一个返回批次基础信息,一个返回环节时间线。基础信息接口和扫码日志插入是同步的,时间线接口可以后加载。
interface TraceBatchBase { batchCode: string; productName: string; origin: string; farmer: string; producedAt: string; certifiedBy?: string; } interface TraceNode { nodeType: number; content: string; operator: string; happenedAt: string; } interface TraceDetail extends TraceBatchBase { nodes: TraceNode[]; }certifiedBy用可选属性是因为不是所有批次都有认证信息。这样设计之后,页面渲染时只需要关心TraceDetail一种结构,不用在代码里到处判断字段是否存在。中间层的数据组装、时间线排序、类型收窄,都建立在这套接口定义之上,这也是后面 TypeScript 工程化能落地的前提。
3. 小程序端 TypeScript 工程化:类型声明、类型守卫与请求封装
3.1 tsconfig 先弄对,别把 TS 工程配成“JS 换名”
很多毕设项目里 TypeScript 只是个摆设,全程any,等于没写。要让 TS 真正兜住问题,tsconfig.json里至少保证下面几项是开着的。
{ "compilerOptions": { "strict": true, "strictNullChecks": true, "noImplicitAny": true, "target": "ES2020", "module": "ESNext", "moduleResolution": "node", "removeComments": false, "typeRoots": ["./typings"] }, "include": ["src/**/*.ts"] }strictNullChecks会在你没判断null/undefined就直接用变量时报错,这是小程序端最常见的空值异常来源。noImplicitAny强制你写出每个参数的类型,避免把隐患藏在隐式any里。
另外一个实际工程问题:TypeScript 新版本已经逐步弃用baseUrl,继续在配置文件里写baseUrl会触发 “Option 'baseUrl' is deprecated, and will stop functioning in TypeScript 7.0” 的警告。小程序工程里我直接全部用相对路径引用,不配baseUrl和paths,省掉一整套路径映射的心智负担。
3.2 给小程序页面定义类型:三个常用姿势
小程序页面的onLoad参数、事件对象、data字段都值得做类型标注。这里给出三个我常用的写法。
// 1. onLoad 接收的 query 参数类型 interface DetailQuery { code: string; from?: 'scan' | 'list'; } Page({ onLoad(query: DetailQuery) { // query.code 一定有值 const { code, from = 'list' } = query; } });onLoad里解构query之前,先按类型把参数定死,后续setData、请求拼接都不会出现query.code可能为undefined的告警。
// 2. 事件对象里的 dataset 类型 const handleTap = (e: WechatMiniprogram.TouchEvent) => { const { batchCode } = e.currentTarget.dataset; };微信开发者工具在装了类型声明包之后,WechatMiniprogram.TouchEvent是直接可用的。事件对象里的dataset是Record<string, any>,解构出来的值要再交给类型守卫确认,不要直接当字符串用。
// 3. 类型守卫:判断后端返回是否符合预期 function isTraceDetail(v: unknown): v is TraceDetail { if (!v || typeof v !== "object") return false; const obj = v as Record<string, unknown>; return ( typeof obj.batchCode === "string" && Array.isArray(obj.nodes) && (obj.nodes as unknown[]).every((n) => typeof n === "object") ); }这个类型守卫的价值在于:后端数据是 JSON,JSON 在运行时没有类型。TS 的类型标注是编译期的,接口返回的数据必须靠这种运行时函数兜底。
3.3 请求与响应:用泛型封装,让每个接口都“知道”自己返回什么
小程序原生wx.request是回调式 API,每次请求都要在success里做 JSON 解析和错误判断。我会包一个泛型请求函数,把“取数据”和“用数据”的边界划开。
interface ApiResponse<T> { code: number; data: T; msg: string; } function request<T>(options: { url: string; method?: "GET" | "POST"; data?: Record<string, unknown>; }): Promise<T> { return new Promise((resolve, reject) => { wx.request({ url: options.url, method: options.method || "GET", data: options.data, success(res) { const body = res.data as ApiResponse<T>; if (res.statusCode === 200 && body.code === 0) { resolve(body.data); } else { reject(new Error(body?.msg || `请求失败(${res.statusCode})`)); } }, fail(err) { reject(err); }, }); }); }调用方只需要写const detail = await request<TraceDetail>({ url: "/api/trace/detail", data: { code } })就能拿到类型完整的detail对象。成功率一低,报错直接带状态码,真机排错时少走很多弯路。
下面是这个封装在工程里的类型声明清单,照着建目录不会乱。
| 文件 | 声明内容 | 解决什么问题 |
|---|---|---|
| types/trace.ts | TraceBatchBase、TraceNode、TraceDetail | 溯源详情类接口的返回结构 |
| types/user.ts | UserProfile、LogisticsAddress | 用户和收货相关信息 |
| utils/request.ts | ApiResponse、request 泛型函数 | 统一错误处理、类型透传 |
| utils/guard.ts | isTraceDetail 等类型守卫 | 校验后端返回、表单数据 |
4. 溯源小程序的三个核心页面流程,用 TypeScript 怎么写
4.1 扫码溯源页:从扫码到时间线的一条完整链路
扫码页是整个溯源小程序的主入口。流程是:调用wx.scanCode拿到码字符串,前端先做格式校验,再请求详情接口,拿到数据后先走类型守卫,最后渲染时间线。
// 基础库 2.10.2+ 支持直接 await,但类型定义是回调式,这里包一层 function promisify<T>(fn: (opts: any) => void) { return (opts: Record<string, unknown> = {}) => new Promise<T>((resolve, reject) => { fn({ ...opts, success: resolve, fail: reject }); }); } const scanCode = promisify<{ result: string }>(wx.scanCode); async function handleScan() { const scanRes = await scanCode({ onlyFromCamera: false }); const code = scanRes.result.trim().toUpperCase(); if (!isValidTraceCode(code)) { wx.showToast({ title: "不是有效的溯源编码", icon: "none" }); return; } try { const detail = await request<TraceDetail>({ url: "https://api.example.com/api/trace/detail", data: { code }, }); if (!isTraceDetail(detail)) { throw new Error("溯源数据格式异常"); } } catch (e) { wx.showToast({ title: (e as Error).message, icon: "none" }); } }onlyFromCamera: false的意思是允许从相册识别二维码,这对线下场景很关键:消费者可能先拍下二维码,回家再扫。类型守卫之后才拿detail去做渲染,不会出现“接口 200 但页面白屏”的尴尬。
时间线渲染时,对detail.nodes数组按happenedAt升序排序,用数组的sort方法即可。注意sort是原地修改,先map出新的数组再排,避免污染原数据。
const sortedNodes = [...detail.nodes].sort( (a, b) => new Date(a.happenedAt).getTime() - new Date(b.happenedAt).getTime() );4.2 图片上传:先校验再上传,避免垃圾数据落库
溯源项目里图片上传的场景很多:上传检测报告、上传现场照片、用户评价晒图。最容易犯的错误是不做前置校验,把几 MB 的大图直接怼到服务器,小程序端卡死,服务端也要花时间处理无效请求。
const chooseMedia = promisify<{ tempFiles: { tempFilePath: string; size: number }[] }>( wx.chooseMedia ); async function handleUploadEvidence() { const res = await chooseMedia({ count: 3, mediaType: ["image"] }); for (const file of res.tempFiles) { if (file.size > 10 * 1024 * 1024) { wx.showToast({ title: "单张图片不能超过 10M", icon: "none" }); return; } } // 通过校验后再逐个上传 const uploadFile = promisify<{ statusCode: number; data: string }>(wx.uploadFile); for (const file of res.tempFiles) { const upRes = await uploadFile({ url: "https://api.example.com/api/upload", filePath: file.tempFilePath, name: "file", }); if (upRes.statusCode !== 200) { wx.showToast({ title: "上传失败", icon: "none" }); return; } } }wx.uploadFile的name参数是服务端接收文件的字段名,必须和后端约定好。图片路径存在tempFilePath,这个路径只在本次小程序会话内有效,前端不要持久化存储,要存的是上传成功后服务端返回的 URL。
超过 10M 的图片处理办法是先用wx.compressImage压缩再传,而不是直接调上传接口。压缩质量选 80,尺寸保持原比例,肉眼基本看不出差别。
4.3 评价提交:让表单校验在提交前完成
评价页的核心不是 UI,而是提交前把字段质量控住。这里定义一个ReviewPayload类型,把评分做成字面量联合类型,不合法值在编译期就进不来。
interface ReviewPayload { traceCode: string; score: 1 | 2 | 3 | 4 | 5; content: string; } function isReviewPayload(v: unknown): v is ReviewPayload { if (!v || typeof v !== "object") return false; const obj = v as Record<string, unknown>; return ( typeof obj.traceCode === "string" && [1, 2, 3, 4, 5].includes(obj.score as number) && typeof obj.content === "string" ); }表单提交时先跑isReviewPayload再发请求。有一个细节容易被忽略:content要限制长度且不能只包含空格。在类型守卫里加一步obj.content.trim().length > 0和<= 500,空内容在提交前就被拦下,后端也不需要为这种情况单独写判空逻辑。
| 页面 | 核心功能 | 对应接口 | 备注 |
|---|---|---|---|
| pages/index | 展示产品列表 / 扫码入口 | GET /api/batch/list | 可加广告位 |
| pages/scan | 调用扫码、渲染溯源时间线 | GET /api/trace/detail | 核心页面 |
| pages/report | 查看检测报告 | GET /api/upload/{id} | 图片展示 |
| pages/review | 提交评价 | POST /api/review | 校验放在前端 |
5. 从源码到可演示:联调、备案与上线前最容易被卡住的几步
5.1 本地联调:改三个设置就能跑通
拿到源码后的第一步不是看业务代码,而是先把请求地址改到你自己的服务端。项目操作说明里通常会写后端地址,但每个人本地环境不一样。这里的关键操作是打开微信开发者工具,在“详情—本地设置”里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。否则本地联调阶段所有请求都会被拦截,报url not in domain list。
本地后端接口起好之后,用 curl 先验证一遍接口本身是通的。
curl http://127.0.0.1:8080/api/trace/detail?code=BJ0101-00001返回的 JSON 里如果有batchCode和nodes字段,说明接口没问题。这时再把小程序里的请求地址从https://api.example.com改成http://127.0.0.1:8080。改完记得清缓存重启编译,开发者工具的缓存有时会吃掉配置变更。
真机预览时,127.0.0.1指向的是手机自己,需要把地址换成电脑的局域网 IP。在“真机调试”模式下开发者工具有时会自动帮你做地址替换,但更稳的做法是在项目里用一个config.ts文件集中管理 API 地址,按环境区分。
5.2 备案信息和服务内容备注怎么填
小程序正式上线前需要完成备案流程,这个在操作说明里往往只有一句话“请自行备案”,但很多人卡在“服务内容备注”这一栏。农产品溯源小程序属于商品信息展示类,不涉及新闻、金融、医疗等前置审批内容,备注直接写清楚业务范围即可。
我一般这么填:“本小程序用于展示农产品生产、加工、仓储、运输等环节的溯源信息,并提供产品质量评价功能,不涉及前置审批事项。” 理由写具体、可核验,审核员不用猜你做什么。备案期间小程序可以继续开发调试,但“发布”按钮是灰的,实际体验要用“体验版”扫码。
5.3 上线前必查的几个典型报错
| 症状 | 原因 | 处理 |
|---|---|---|
页面白屏,console 报Cannot read property 'nodes' of null | 接口返回结构与类型定义不符 | 类型守卫兜底,打印真实返回结构对比 |
uploadFile:fail | 后端地址配成了localhost | 改成局域网 IP 或线上域名 |
this.setData is not a function | 用普通函数而非箭头函数导致this丢失 | 回调全部用箭头函数或在onLoad外层const that = this |
app.json: 未找到 pages/xxx/xxx | 新建页面没注册到app.json | 在pages数组里补上页面路径 |
| 体验版打开就报“request:fail” | 域名未配置到小程序后台 | 管理后台“开发管理—服务器域名”里加白名单 |
最值得说的一点是this.setData报错。在小程序里,wx.request的success回调里如果用了普通function声明,this就会指向全局对象而不是页面实例。解决方式统一用箭头函数,或者把页面对象的方法抽成const handler = () => {}再引用。TypeScript 工程在编译期不会抓这个错误,这是运行时问题,只能靠规范写法规避。
6. 把扫码详情做得更快:动态标题、启动缓存与请求合并
6.1 动态标题与首屏信息拆分
扫码详情页进入时,用户看到的第一个瞬间是导航栏标题。默认叫“溯源详情”当然没错,但如果能显示产品名和批次号,截图分享时的效果完全不一样。
wx.setNavigationBarTitle({ title: `${detail.productName} · 溯源`, });这段代码放在详情接口返回之后、setData之前执行。要注意wx.setNavigationBarTitle的title有长度限制,productName过长的产品名要截断处理,比如超过 15 个字符就只保留前 12 个加省略号。
首屏信息的拆分也在这里体现:详情页只请求基础信息接口,时间线放在onReady之后再拉取。基础信息接口数据量小,通常 50ms 内能返回,导航栏标题、产地、采摘日期能立刻渲染;时间线接口可能涉及多表查询,晚几百毫秒出来用户感知不强。这个拆分动作能让首屏渲染时间砍掉一半左右。
6.2 请求合并与字段版本号缓存
扫码详情页同一个批次可能被反复查看,物流信息又频繁更新,缓存策略要区分热数据和冷数据。基础信息(产地、产品名、认证信息)一周内几乎不变,适合本地缓存;环节节点(物流流转、仓储记录)时效性高,每次都拉新的。
const CACHE_KEY = `trace_base_v3_${code}`; const cached = wx.getStorageSync(CACHE_KEY) as TraceBatchBase | ""; if (cached) { setData({ base: cached }); } const detail = await request<TraceDetail>("/api/trace/detail"); wx.setStorageSync(CACHE_KEY, detail);缓存 key 里拼了v3这个版本号,这是容易被忽略但很重要的小细节。后端如果改了字段结构,比如producedAt改成了produceTime,旧缓存里的数据字段对不上新代码,页面会拿到脏数据。每次发布涉及字段变更时,手动把 key 里的版本号 bump 一下,老缓存自动失效,不需要等用户手动清缓存。
请求合并的最后一招是:详情页已经单独拉取了基础信息,列表页跳转过来时把基础信息放进页面间参数里,详情页就用参数先渲染首屏,同时后台静默拉最新数据做比对更新。这个做法能省掉一次完整的请求往返,体感上扫码后几乎瞬间出内容,这也是答辩演示时最容易让评委“哇”一声的细节。
本文还有配套的精品资源,点击获取