人工智能实战微信小程序demo:拍照识别全链路拆解
简介面向微信小程序与人工智能入门开发者的实战演示包围绕小程序页面组织、语音录制播放、消息交互等常见能力展开适合希望从可运行工程中理解 AI 功能如何嵌入微信生态的初中级开发者参考。压缩包共 59 个文件约 1.36MB以 JavaScript、WXML、WXSS、JSON 等小程序源码为主配套 PNG、GIF、JPG 图片与动图素材并附 README 说明整体轻便易读。已有 243 人学习下载。包内按关于、首页、个人、待办等页面模块组织包含录音与音频播放工具脚本、消息组件、二维码及微软 AI 演示动图等素材可用于对照页面结构、样式组织与交互逻辑借助页面配置、组件通信和工具函数的组织方式读者能快速搭建自己的小程序原型或在此基础上替换接口与业务数据做二次开发。1. 从拍照识别场景切入这份人工智能实战微信小程序 demo 能跑通什么把一份人工智能实战微信小程序 demo 源码包解压之后我先看的不是页面样式而是拍照、上传、推理、结果展示这条链路有没有真正打通。它对应的场景很具体用户在微信小程序里拍一张照片或者从相册选一张图前端压缩后传到服务端服务端调用视觉模型把识别结果整理成统一结构返回页面再用卡片形式展示标签、置信度和建议。它不是一个只摆按钮的 UI 模板也不是一个孤立的软件/插件而是把小程序端、Node 服务端和模型适配层拼成了一个最小闭环。这份 demo 适合三类人会一点 JavaScript、想学微信小程序接 AI 接口的开发者手里有模型服务、需要快速做一个演示原型的后端以及要给客户演示“拍照识别 结果页”的接单开发者。它的价值不在算法本身而在链路完整选图、压缩、上传、超时、错误态、历史记录都有落点。跑通的关键也很明确先把服务端起来再改小程序里的 baseUrl最后处理真机域名和图片大小否则很容易卡在“开发者工具正常、真机翻车”的玄学阶段。2. 技术栈与目录结构小程序端、服务端和模型适配层怎么摆2.1 技术选型原生小程序 轻量 Node 服务 统一模型适配层这份 demo 的前端采用原生微信小程序写法页面文件是 WXML、WXSS、JS、JSON 四件套没有上跨端框架。原因很实际demo 要的是包体小、调试路径短、开发者工具打开就能改遇到wx.chooseMedia、wx.uploadFile这类平台 API 时不用再查框架封装。服务端用 Node.js Express中间件只保留必要的几个multer接文件、cors处理本地联调、dotenv读配置、axios调模型、sharp做图片压缩和旋转校正。模型调用不直接写进路由而是收进services/ai-adapter.js页面和路由都不关心底层换的是哪家模型。选型上有一个常见分歧用云函数还是自建轻服务。云函数省运维但本地调试、文件流处理和超时控制会受平台约束自建 Express 多一个进程却能把上传大小、超时、日志、错误码都握在手里。这个 demo 走的是自建轻服务路线适合拿来理解完整请求链路。下面这张对比表是我在类似项目里常用的判断依据方案适合场景优点限制云函数快速验证、无固定服务器部署快和平台账号体系近文件流、超时、本地联调受约束自建 Node 服务要控制上传、超时、日志链路透明便于换模型需要自己起进程和配 HTTPS纯前端直调模型极简演示少一层服务密钥暴露图片大小和跨域难控这张表不是让你二选一而是先明确 demo 的边界它把 AI 调用放在服务端小程序端只拿临时票据和结果密钥不进前端。这个边界一旦定错后面换模型、加历史记录都会别扭。2.2 目录结构页面、组件、工具库和 server 各管什么源码包的目录不复杂但职责分得比较清楚。小程序端按页面和组件拆服务端按路由、中间件、服务拆。下面是我按这类 demo 常见组织方式整理的目录清单拿到包后可以对照着看路径职责改动频率miniprogram/pages/index首页选图、预览、触发上传高miniprogram/pages/result结果页渲染标签、置信度、建议高miniprogram/pages/history历史记录本地缓存或接口拉取中miniprogram/components/upload-card上传卡片含进度和重试中miniprogram/utils/request.js请求封装统一 baseUrl、超时、错误码中miniprogram/config.js环境配置开发/预览地址高server/app.jsExpress 入口挂中间件和路由低server/routes/recognize.js/api/recognize路由校验入参中server/services/ai-adapter.js模型适配层统一返回结构高server/middlewares/upload.js文件大小、类型、临时目录低server/.env.example密钥和模型地址占位低这个结构里最值得先看的是ai-adapter.js和utils/request.js。前者决定换模型成本后者决定前端错误处理是否统一。很多 demo 把wx.request散落在页面里改一个地址要翻十几个文件二次开发时很痛苦。这里把 baseUrl、超时、错误提示收在工具层页面只关心业务状态。2.3 数据流一次识别请求从点击到返回的完整链路一次识别请求的路径可以拆成六步用户点“拍照”或“相册”页面调用wx.chooseMedia拿到临时文件路径前端用wx.getImageInfo读宽高超过阈值先压缩wx.uploadFile把文件传到/api/recognize服务端multer接收后做类型和大小校验sharp旋转纠正并压缩适配层调用模型接口把不同返回结构归一成{ label, score, boxes, suggestion }前端拿到结果后跳结果页同时写一条本地历史。下面这段是前端请求封装的简化写法// miniprogram/utils/request.js const config require(../config) function uploadImage(filePath, onProgress) { return new Promise((resolve, reject) { const task wx.uploadFile({ url: ${config.baseUrl}/api/recognize, filePath, name: image, timeout: 20000, header: { X-Client: miniprogram }, formData: { scene: general, needBox: true }, success(res) { // uploadFile 返回的是字符串需要手动解析 let data try { data JSON.parse(res.data) } catch (e) { reject(new Error(返回格式不是 JSON)) return } if (res.statusCode ! 200 || data.code ! 0) { reject(new Error(data.message || 服务端错误 ${res.statusCode})) return } resolve(data.data) }, fail(err) { reject(new Error(err.errMsg || 上传失败)) } }) if (task.onProgressUpdate onProgress) { task.onProgressUpdate((e) onProgress(e.progress)) } }) } module.exports { uploadImage }这段代码的关键参数有四个url来自config.baseUrl开发时指向本地服务真机预览时必须换成已备案的 HTTPS 域名name必须和服务端multer的字段名一致否则服务端收不到文件timeout设为 20 秒比模型侧超时略长避免前端先断formData里的scene和needBox是业务参数服务端据此决定是否返回检测框。success里不能直接相信res.data是对象wx.uploadFile返回字符串手动JSON.parse是常见做法。2.4 本地跑通顺序三个终端和两个配置文件拿到源码包后不要急着点编译先把服务端跑起来。常见顺序是安装根目录和server的依赖复制环境变量文件启动服务再用开发者工具导入miniprogram目录修改config.js里的 baseUrl。下面这组命令可以直接抄# 1. 安装依赖 cd server npm install # 2. 准备环境变量 cp .env.example .env # 编辑 .env填入模型地址和密钥 # 3. 启动服务 npm run dev # 4. 另开终端确认端口 curl http://127.0.0.1:3000/health服务端默认监听 3000 端口/health返回{ ok: true }就说明进程正常。小程序端在miniprogram/config.js里改地址// miniprogram/config.js module.exports { // 开发者工具里可以先用本地地址 baseUrl: http://127.0.0.1:3000, // 真机预览必须换成 HTTPS 域名 // baseUrl: https://api.example.com, uploadMaxSize: 5 * 1024 * 1024 }baseUrl是唯一需要频繁改的配置uploadMaxSize要和后端multer的限制保持一致否则前端以为能传、后端直接 413。开发者工具里记得在“详情 - 本地设置”勾选不校验合法域名真机预览则必须走 HTTPS 域名并且域名要在小程序后台配置。这个切换点很容易忘翻车大多发生在这里。提示开发阶段可以把baseUrl写成局域网 IP例如http://192.168.1.10:3000但手机和电脑必须在同一网络且防火墙放行端口。3. 核心页面拆解拍照上传、结果渲染和四态状态管理3.1 相机与相册wx.chooseMedia 的参数和兼容处理首页的入口是wx.chooseMedia它同时覆盖拍照和相册。参数里count控制一次选几张demo 里设为 1mediaType只留imagesourceType放album和camera用户点按钮后由系统弹选择sizeType用compressed让平台先压一轮减少上传体积。下面这段是页面里的选图逻辑// miniprogram/pages/index/index.js Page({ data: { tempPath: , fileSize: 0, status: idle // idle | loading | success | error }, chooseImage() { wx.chooseMedia({ count: 1, mediaType: [image], sourceType: [album, camera], sizeType: [compressed], camera: back, success: (res) { const file res.tempFiles[0] if (!file) return // 前端先做一次大小兜底避免把超大图直接传给服务端 if (file.size 5 * 1024 * 1024) { wx.showToast({ title: 图片超过 5MB, icon: none }) return } this.setData({ tempPath: file.tempFilePath, fileSize: file.size, status: idle }) }, fail: (err) { // 用户取消也会走 fail不要一律弹错误 if (err.errMsg err.errMsg.includes(cancel)) return wx.showToast({ title: 选择图片失败, icon: none }) } }) } })count设为 1 是因为结果页按单图设计多图要改结果结构和后端字段。camera: back让拍照默认后置拍实物更常用。sizeType选compressed能减少传输量但压缩后的清晰度不一定满足文字识别所以后面服务端还会用sharp再做一次可控压缩。fail里要判断cancel用户点取消不是错误直接弹 toast 反而打扰。3.2 上传wx.uploadFile 的 formData、进度和失败重试上传不能只写一个wx.uploadFile就完事。demo 里把上传封装成 Promise页面只拿进度和结果。重试策略是失败后最多再试一次并且只对网络错误重试不对 4xx 重试。下面这段是带重试的上传调用// miniprogram/pages/index/index.js const { uploadImage } require(../../utils/request) Page({ // 省略 data 和 chooseImage async submit() { const { tempPath, status } this.data if (!tempPath || status loading) return this.setData({ status: loading, progress: 0 }) try { const result await this.uploadWithRetry(tempPath) this.setData({ status: success }) wx.navigateTo({ url: /pages/result/result?data${encodeURIComponent(JSON.stringify(result))} }) } catch (err) { this.setData({ status: error, errorMsg: err.message }) } }, async uploadWithRetry(filePath) { let lastError for (let i 0; i 2; i) { try { return await uploadImage(filePath, (progress) { this.setData({ progress }) }) } catch (err) { lastError err // 4xx 通常是参数或文件问题重试没有意义 if (/服务端错误 4\d\d/.test(err.message)) break } } throw lastError } })uploadWithRetry里循环两次第一次失败后不立刻弹错给网络抖动一次机会。onProgress回调用setData更新进度条但不要每 1% 都 set 一次真实项目里可以按 5% 节流减少渲染压力。navigateTo把结果用 URL 传过去只适合小数据正式项目更稳的做法是写进全局状态或本地缓存结果页按 id 读取避免 URL 过长。3.3 结果渲染setData 的性能边界与骨架屏结果页要处理四种状态idle空态、loading骨架屏、success结果卡片、error错误提示。很多 demo 只写成功态一旦接口失败页面就白屏。下面这段 WXML 用wx:if分支把四态拆开!-- miniprogram/pages/result/result.wxml -- view classpage view wx:if{{status loading}} classskeleton view classskeleton-line long/view view classskeleton-line/view view classskeleton-line short/view /view view wx:elif{{status error}} classerror-box text{{errorMsg}}/text button bindtapretry重新识别/button /view view wx:elif{{status success}} classresult-list view wx:for{{result.labels}} wx:keylabel classresult-item text classlabel{{item.label}}/text text classscore{{item.scoreText}}/text /view /view view wx:else classempty还没有识别结果/view /viewwx:elif的顺序有讲究先判loading再判error最后判success避免请求失败时还显示旧结果。wx:key用label而不是索引列表更新时复用更稳。setData不要一次塞整个大对象尤其是图片 base64能传 URL 就传 URL。结果页的score在服务端已经转成scoreText前端不做计算减少页面逻辑。3.4 历史记录本地缓存先兜底云数据库后补demo 默认把最近 20 条识别记录写进wx.setStorageSync刷新页面还能看到。缓存 key 用ai_history_v1带版本号方便以后改结构。下面这段是写入和读取// miniprogram/utils/history.js const KEY ai_history_v1 const MAX 20 function addHistory(item) { const list getHistory() // 用时间戳去重避免同一张图重复点击写入多条 const next [{ ...item, id: Date.now() }, ...list] wx.setStorageSync(KEY, next.slice(0, MAX)) } function getHistory() { try { return wx.getStorageSync(KEY) || [] } catch (e) { return [] } } module.exports { addHistory, getHistory }MAX限制 20 条是防止缓存无限增长小程序单个 key 有大小限制存图片路径和标签足够不要存原图。id用时间戳在 demo 里够用正式项目建议用服务端返回的请求 id。缓存方案适合单机演示多端同步就要换云数据库这个取舍在最后一章展开。4. 服务端与 AI 接口封装把模型调用关进一个适配层4.1 路由设计/api/recognize 的入参校验、超时和错误码服务端入口app.js挂三个中间件cors允许本地调试express.json解析小 JSONmulter处理multipart/form-data。路由只做三件事校验文件、调用适配层、统一返回。下面这段是路由的简化实现// server/routes/recognize.js const express require(express) const router express.Router() const { recognizeImage } require(../services/ai-adapter) router.post(/api/recognize, async (req, res) { const file req.file if (!file) { return res.status(400).json({ code: 4001, message: 缺少图片文件 }) } if (!/^image\/(jpeg|png|webp)$/.test(file.mimetype)) { return res.status(415).json({ code: 4150, message: 只支持 jpg/png/webp }) } try { const result await recognizeImage({ buffer: file.buffer, mimetype: file.mimetype, scene: req.body.scene || general }) return res.json({ code: 0, message: ok, data: result }) } catch (err) { // 模型超时和参数错误要分开前端才能决定是否重试 if (err.code AI_TIMEOUT) { return res.status(504).json({ code: 5040, message: 识别超时请重试 }) } return res.status(500).json({ code: 5000, message: 识别服务暂不可用 }) } }) module.exports routerreq.file来自multer的memoryStorage不落临时盘减少清理逻辑。mimetype校验不能只看扩展名前端可以伪造文件名。错误码用四位数4001表示缺文件4150表示类型不支持5040表示模型超时5000表示未知错误。前端根据code决定提示文案不要直接把服务端message弹给用户。错误码HTTP 状态含义前端动作4001400缺少图片提示重新选图4150415格式不支持提示换 jpg/png5040504模型超时可重试一次5000500服务异常提示稍后再试4.2 适配层统一不同模型返回结构适配层是这份 demo 最值得抄的部分。不同模型返回字段五花八门有的叫class_name有的叫categories有的置信度是 0-1有的是百分数。适配层只暴露一个recognizeImage内部做归一化。下面这段是带超时控制的写法// server/services/ai-adapter.js const axios require(axios) const sharp require(sharp) async function recognizeImage({ buffer, mimetype, scene }) { // 先做旋转校正和压缩减少模型侧处理时间 const normalized await sharp(buffer) .rotate() .resize({ width: 1024, height: 1024, fit: inside, withoutEnlargement: true }) .jpeg({ quality: 80 }) .toBuffer() const controller new AbortController() const timer setTimeout(() controller.abort(), 8000) try { const resp await axios.post( ${process.env.AI_BASE_URL}/v1/vision/recognize, { image: normalized.toString(base64), scene }, { headers: { Authorization: Bearer ${process.env.AI_API_KEY}, Content-Type: application/json }, timeout: 8000, signal: controller.signal } ) return normalize(resp.data) } catch (err) { if (err.name CanceledError || err.code ECONNABORTED) { const e new Error(AI_TIMEOUT) e.code AI_TIMEOUT throw e } throw err } finally { clearTimeout(timer) } } function normalize(raw) { const labels (raw.categories || raw.results || []).map((item) ({ label: item.class_name || item.name || 未知, score: Number(item.confidence ?? item.score ?? 0), scoreText: ${Math.round(Number(item.confidence ?? item.score ?? 0) * 100)}% })).slice(0, 5) return { labels, suggestion: raw.suggestion || 根据识别结果生成建议, requestId: raw.request_id || } } module.exports { recognizeImage }sharp的rotate()不带参数时会按 EXIF 自动纠正方向这是手机照片方向不对的常见解。resize限制最长边 1024withoutEnlargement防止小图被放大。AbortController和axios timeout双保险模型侧不回包时服务端不会一直挂着。normalize只取前 5 个标签避免结果页渲染过长。scoreText在服务端算好前端直接展示减少重复逻辑。4.3 密钥与配置.env、CORS 白名单和日志脱敏密钥只放服务端环境变量.env不进版本库仓库里只留.env.example。下面是一个够用的配置模板# server/.env.example PORT3000 AI_BASE_URLhttps://api.example.com AI_API_KEYreplace_with_your_key AI_TIMEOUT_MS8000 UPLOAD_MAX_MB5 ALLOW_ORIGINhttp://127.0.0.1:3000AI_BASE_URL和AI_API_KEY是模型服务地址和票据AI_TIMEOUT_MS要和适配层保持一致UPLOAD_MAX_MB要和multer的limits.fileSize对齐。ALLOW_ORIGIN在开发时放开本地地址上线时改成小程序合法域名对应的来源。日志里不要打完整Authorization常见做法是只打前 6 位加星号。cors不要图省事写origin: *尤其是带票据的接口。4.4 图片处理sharp 压缩、旋转和格式统一服务端收到图片后不要直接转发给模型。手机照片动辄 4-8MB模型接口按流量和耗时计费压缩一轮能省不少。除了适配层里的处理上传中间件也要限制大小和类型// server/middlewares/upload.js const multer require(multer) const upload multer({ storage: multer.memoryStorage(), limits: { fileSize: Number(process.env.UPLOAD_MAX_MB || 5) * 1024 * 1024, files: 1 }, fileFilter(req, file, cb) { if (!/^image\/(jpeg|png|webp)$/.test(file.mimetype)) { return cb(new Error(UNSUPPORTED_IMAGE_TYPE)) } cb(null, true) } }) module.exports uploadmemoryStorage适合小文件超过 5MB 的图在内存里处理会占资源所以limits.fileSize必须设。files: 1防止一次传多张打爆适配层。fileFilter和路由里的mimetype校验重复了一层这是有意为之中间件先挡明显非法请求路由再兜底。图片格式统一成 jpeg 后再 base64模型侧兼容性最好。5. 常见问题排查真机上传、跨域、图片方向和并发超时5.1 现象开发者工具能上传真机一直失败现象是开发者工具里选图、上传、出结果都正常手机预览时进度条走到一半就报“上传失败”。原因通常不是代码而是真机不允许请求http://地址或者域名没有在小程序后台配置。开发者工具的“不校验合法域名”只对工具生效真机走的是另一套校验。解决办法是把服务端部署到 HTTPS 域名在小程序后台把request和uploadFile合法域名都配上然后重新编译预览。本地联调可以先用局域网 IP但手机和电脑要同一网段电脑防火墙放行 3000 端口。这个坑我踩过不止一次真机调试确实有点玄学先看域名再看网络。5.2 现象AI 返回慢用户连点后重复扣费现象是用户点一次“识别”没反应又连点三四次最后服务端收到多条请求模型侧按次计费账单很难看。原因是前端没有锁住提交态按钮在loading期间仍可点击后端也没有做幂等。解决办法分两层前端在submit开头判断status loading直接返回按钮加disabled{{status loading}}后端用requestId或图片哈希做短时间幂等重复请求直接返回上一次结果。下面这段是前端锁的写法// 页面提交前先锁状态 if (this.data.status loading) return this.setData({ status: loading })status不只是展示用还是并发闸门。后端幂等可以用Map存hash - result设置 60 秒过期适合 demo正式项目用 Redis 更稳。别等账单出来才加锁。5.3 现象图片方向不对压缩后文字糊现象是手机竖拍的照片在小程序里预览正常传到服务端后模型识别结果很差或者结果页图片横过来。原因是 iPhone 等设备把方向写在 EXIF 里前端预览会自动纠正服务端读原始 buffer 不一定纠正。解决办法是在sharp链里先rotate()再resize顺序不能反。压缩参数也要按场景调通用物体识别 1024 最长边、quality 80 够用文字识别可以保留 1600 最长边、quality 85别一刀切压到 640。下面这段是可直接替换的处理链const normalized await sharp(buffer) .rotate() // 按 EXIF 自动纠正方向 .resize({ width: 1024, height: 1024, fit: inside, withoutEnlargement: true }) .jpeg({ quality: 80 }) .toBuffer()fit: inside保证不裁切withoutEnlargement防止小图放大后更糊。压缩后可以顺手打一条日志记录原始大小和处理后大小排查时一眼能看出是不是压过头。5.4 现象本地联调报域名不合法或 CORS现象是开发者工具请求本地服务报“不在以下 request 合法域名列表中”或者浏览器里调接口报 CORS。原因是小程序有域名校验服务端cors没放行来源。解决办法分环境处理开发者工具勾选“不校验合法域名”服务端cors的origin放行http://127.0.0.1:3000和局域网地址真机则必须 HTTPS 域名并在小程序后台配置。下面是一个最小 CORS 配置// server/app.js const cors require(cors) app.use(cors({ origin: (process.env.ALLOW_ORIGIN || ).split(,), methods: [GET, POST], allowedHeaders: [Content-Type, X-Client] }))ALLOW_ORIGIN支持逗号分隔多个来源开发时填本地地址上线时收紧。不要用*配Authorization浏览器和小程序都可能拒绝。CORS 报错先看响应头有没有Access-Control-Allow-Origin再看请求是不是预检OPTIONS被路由拦了。5.5 现象并发一高接口就 502 或超时现象是演示时几个人同时上传接口开始 502日志里模型调用排队。原因是 Node 单进程同时发起太多外部请求模型侧限流或连接池耗尽。解决办法是加一层轻量队列和限流服务端限制同时处理的识别请求数超出排队或直接返回 429axios连接数设上限前端失败后指数退避重试不要立刻连发。下面是一个极简并发闸门// server/services/queue.js let running 0 const MAX 3 const queue [] function run(task) { return new Promise((resolve, reject) { const execute async () { running try { resolve(await task()) } catch (e) { reject(e) } finally { running-- const next queue.shift() if (next) next() } } if (running MAX) execute() else queue.push(execute) }) } module.exports { run }MAX按模型侧配额调demo 设 3 就够。排队不是万能药队列太长要直接返回 429让前端提示“稍后再试”。日志里记录排队时长和模型耗时才能判断是服务端慢还是模型慢。6. 二次开发换模型、加历史记录和做结果分享的改造路径6.1 换模型只改适配层不动页面换模型时不要改页面也不要在路由里加if provider x。适配层暴露统一接口内部用注册表选 provider。下面这个结构够小也好扩// server/services/ai-adapter.js const providers { default: require(./providers/default), fast: require(./providers/fast) } async function recognizeImage({ buffer, mimetype, scene }) { const name process.env.AI_PROVIDER || default const provider providers[name] if (!provider) throw new Error(AI_PROVIDER_NOT_FOUND) const raw await provider.recognize({ buffer, mimetype, scene }) return normalize(raw) }新增模型时只加一个providers/xxx.js实现recognize并返回原始结构normalize负责统一字段。页面拿到的始终是labels、suggestion、requestId不需要知道底层换了谁。验证方法是把AI_PROVIDER切到新值用同一张测试图跑一遍结果字段不缺就算通过。6.2 历史记录本地缓存和云数据库怎么选本地缓存适合单机 demo云数据库适合多端同步和留存分析。下面这张表是我做取舍时看的点方案成本适合场景注意本地缓存低单人演示、离线查看换设备丢失容量有限云数据库中多端同步、后台统计要设计用户标识和权限服务端数据库中高已有账号体系要加鉴权和分页demo 默认本地缓存二次开发要加“我的记录”跨设备同步就在结果成功后调一个/api/history写库列表页分页拉取。别把原图存数据库存 URL 和标签就够。6.3 验证固定测试集跑回归别只看一次成功改完适配层或压缩参数后不要只试一张图。准备 10 张固定测试图覆盖竖拍、横拍、大图、小图、纯色背景和复杂背景用一个脚本批量调接口记录耗时和标签数量。下面这条命令够用for f in test-images/*.jpg; do curl -s -X POST http://127.0.0.1:3000/api/recognize \ -F image$f \ -F scenegeneral | node -e let s;process.stdin.on(data,dsd).on(end,(){const jJSON.parse(s);console.log($f,j.code,j.data?.labels?.length)}) done看三个指标code是否稳定为 0labels数量是否在 1-5 之间耗时是否超过 8 秒。任何一项波动大先查图片方向和压缩参数再查模型侧限流。从那以后我每次拿到这类 AI 小程序 demo都先跑固定测试集再改页面免得被一次成功骗了。希望帮到你。本文还有配套的精品资源点击获取